wikimedia/mediawiki-extensions-Translate

View on GitHub
src/PageTranslation/Hooks.php

Summary

Maintainability
F
1 wk
Test Coverage
<?php

namespace MediaWiki\Extension\Translate\PageTranslation;

use Article;
use Content;
use Exception;
use IDBAccessObject;
use Language;
use LanguageCode;
use ManualLogEntry;
use MediaWiki\CommentStore\CommentStoreComment;
use MediaWiki\Config\Config;
use MediaWiki\Context\IContextSource;
use MediaWiki\Context\RequestContext;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Extension\Translate\MessageBundleTranslation\MessageBundleMessageGroup;
use MediaWiki\Extension\Translate\MessageLoading\MessageHandle;
use MediaWiki\Extension\Translate\Services;
use MediaWiki\Extension\Translate\Statistics\MessageGroupStats;
use MediaWiki\Extension\Translate\Statistics\RebuildMessageGroupStatsJob;
use MediaWiki\Extension\Translate\SystemUsers\FuzzyBot;
use MediaWiki\Extension\Translate\Utilities\Utilities;
use MediaWiki\Html\Html;
use MediaWiki\Languages\LanguageNameUtils;
use MediaWiki\Linker\LinkTarget;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Output\OutputPage;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Page\PageReference;
use MediaWiki\Parser\Parser;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\ResourceLoader\Context;
use MediaWiki\Revision\MutableRevisionRecord;
use MediaWiki\Revision\RenderedRevision;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Status\Status;
use MediaWiki\Storage\EditResult;
use MediaWiki\StubObject\StubUserLang;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentity;
use PPFrame;
use Skin;
use TextContent;
use UserBlockedError;
use Wikimedia\ScopedCallback;
use WikiPage;
use WikiPageMessageGroup;

/**
 * Hooks for page translation.
 * @author Niklas Laxström
 * @license GPL-2.0-or-later
 * @ingroup PageTranslation
 */
class Hooks {
    private const PAGEPROP_HAS_LANGUAGES_TAG = 'translate-has-languages-tag';
    /** @var bool Uuugly hacks */
    public static $allowTargetEdit = false;
    /** State flag used by DeleteTranslatableBundleJob for performance optimizations. */
    public static bool $isDeleteTranslatableBundleJobRunning = false;
    /** @var bool Check if we are just rendering tags or such */
    public static $renderingContext = false;
    /** @var array Used to communicate data between LanguageLinks and SkinTemplateGetLanguageLink hooks. */
    private static $languageLinkData = [];

    /**
     * Hook: ParserBeforeInternalParse
     * @param Parser $wikitextParser
     * @param null|string &$text
     * @param-taint $text escapes_htmlnoent
     * @param mixed $state
     */
    public static function renderTagPage( $wikitextParser, &$text, $state ): void {
        if ( $text === null ) {
            // SMW is unhelpfully sending null text if the source contains section tags. Do not explode.
            return;
        }

        self::preprocessTagPage( $wikitextParser, $text, $state );

        // Skip further interface message parsing
        if ( $wikitextParser->getOptions()->getInterfaceMessage() ) {
            return;
        }

        // For section previews, perform additional clean-up, given tags are often
        // unbalanced when we preview one section only.
        if ( $wikitextParser->getOptions()->getIsSectionPreview() ) {
            $translatablePageParser = Services::getInstance()->getTranslatablePageParser();
            $text = $translatablePageParser->cleanupTags( $text );
        }

        // Set display title
        $title = MediaWikiServices::getInstance()
            ->getTitleFactory()
            ->castFromPageReference( $wikitextParser->getPage() );
        if ( !$title ) {
            return;
        }

        $page = TranslatablePage::isTranslationPage( $title );
        if ( !$page ) {
            return;
        }

        $wikitextParser->getOutput()->setUnsortedPageProperty( 'translate-is-translation' );

        try {
            self::$renderingContext = true;
            [ , $code ] = Utilities::figureMessage( $title->getText() );
            $name = $page->getPageDisplayTitle( $code );
            if ( $name ) {
                $name = $wikitextParser->recursivePreprocess( $name );

                $langConv = MediaWikiServices::getInstance()->getLanguageConverterFactory()
                    ->getLanguageConverter( $wikitextParser->getTargetLanguage() );
                $name = $langConv->convert( $name );
                $wikitextParser->getOutput()->setDisplayTitle( $name );
            }
            self::$renderingContext = false;
        } catch ( Exception $e ) {
            LoggerFactory::getInstance( 'Translate' )->error(
                'T302754 Failed to set display title for page {title}',
                [
                    'title' => $title->getPrefixedDBkey(),
                    'text' => $text,
                    'pageid' => $title->getId(),
                ]
            );

            // Re-throw to preserve behavior
            throw $e;
        }

        $extensionData = [
            'languagecode' => $code,
            'messagegroupid' => $page->getMessageGroupId(),
            'sourcepagetitle' => [
                'namespace' => $page->getTitle()->getNamespace(),
                'dbkey' => $page->getTitle()->getDBkey()
            ]
        ];

        $wikitextParser->getOutput()->setExtensionData( 'translate-translation-page', $extensionData );
        // Disable edit section links
        $wikitextParser->getOutput()->setExtensionData( 'Translate-noeditsection', true );
    }

    /**
     * Hook: ParserBeforePreprocess
     * @param Parser $wikitextParser
     * @param string &$text
     * @param-taint $text escapes_htmlnoent
     * @param mixed $state
     */
    public static function preprocessTagPage( $wikitextParser, &$text, $state ): void {
        $translatablePageParser = Services::getInstance()->getTranslatablePageParser();

        if ( $translatablePageParser->containsMarkup( $text ) ) {
            try {
                $parserOutput = $translatablePageParser->parse( $text );
                // If parsing succeeds, replace text
                $text = $parserOutput->sourcePageTextForRendering(
                    $wikitextParser->getTargetLanguage()
                );
            } catch ( ParsingFailure $e ) {
                wfDebug( 'ParsingFailure caught; expected' );
            }
        } else {
            // If the text doesn't contain <translate> markup, it can still contain <tvar> in the
            // context of a Parsoid template expansion sub-pipeline. We strip these as well.
            $unit = new TranslationUnit( $text );
            $text = $unit->getTextForTrans();
        }
    }

    /**
     * Hook: ParserOutputPostCacheTransform
     * @param ParserOutput $out
     * @param string &$text
     * @param array &$options
     */
    public static function onParserOutputPostCacheTransform(
        ParserOutput $out,
        &$text,
        array &$options
    ) {
        if ( $out->getExtensionData( 'Translate-noeditsection' ) ) {
            $options['enableSectionEditLinks'] = false;
        }
    }

    /**
     * This sets &$revRecord to the revision of transcluded page translation if it exists,
     * or sets it to the source language if the page translation does not exist.
     * The page translation is chosen based on language of the source page.
     *
     * Hook: BeforeParserFetchTemplateRevisionRecord
     * @param LinkTarget|null $contextLink
     * @param LinkTarget|null $templateLink
     * @param bool &$skip
     * @param RevisionRecord|null &$revRecord
     */
    public static function fetchTranslatableTemplateAndTitle(
        ?LinkTarget $contextLink,
        ?LinkTarget $templateLink,
        bool &$skip,
        ?RevisionRecord &$revRecord
    ): void {
        if ( !$templateLink ) {
            return;
        }

        $templateTitle = Title::castFromLinkTarget( $templateLink );

        $templateTranslationPage = TranslatablePage::isTranslationPage( $templateTitle );
        if ( $templateTranslationPage ) {
            // Template is referring to a translation page, fetch it and incase it doesn't
            // exist, fetch the source fallback.
            $revRecord = $templateTranslationPage->getRevisionRecordWithFallback();
            if ( !$revRecord ) {
                // In rare cases fetching of the source fallback might fail. See: T323863
                LoggerFactory::getInstance( 'Translate' )->warning(
                    "T323863: Could not fetch any revision record for '{groupid}'",
                    [ 'groupid' => $templateTranslationPage->getMessageGroupId() ]
                );
            }
            return;
        }

        if ( !TranslatablePage::isSourcePage( $templateTitle ) ) {
            return;
        }

        $translatableTemplatePage = TranslatablePage::newFromTitle( $templateTitle );

        if ( !( $translatableTemplatePage->supportsTransclusion() ?? false ) ) {
            // Page being transcluded does not support language aware transclusion
            return;
        }

        $store = MediaWikiServices::getInstance()->getRevisionStore();

        if ( $contextLink ) {
            // Fetch the context page language, and then check if template is present in that language
            $templateTranslationTitle = $templateTitle->getSubpage(
                Title::castFromLinkTarget( $contextLink )->getPageLanguage()->getCode()
             );

            if ( $templateTranslationTitle ) {
                if ( $templateTranslationTitle->exists() ) {
                    // Template is present in the context page language, fetch the revision record and return
                    $revRecord = $store->getRevisionByTitle( $templateTranslationTitle );
                } else {
                    // In case the template has not been translated to the context page language,
                    // we assign a MutableRevisionRecord in order to add a dependency, so that when
                    // it is created, the newly created page is loaded rather than the fallback
                    $revRecord = new MutableRevisionRecord( $templateTranslationTitle );
                }
                return;
            }
        }

        // Context page information not available OR the template translation title could not be determined.
        // Fetch and return the RevisionRecord of the template in the source language
        $sourceTemplateTitle = $templateTitle->getSubpage(
            $translatableTemplatePage->getMessageGroup()->getSourceLanguage()
        );
        if ( $sourceTemplateTitle && $sourceTemplateTitle->exists() ) {
            $revRecord = $store->getRevisionByTitle( $sourceTemplateTitle );
        }
    }

    /**
     * Set the right page content language for translated pages ("Page/xx").
     * Hook: PageContentLanguage
     * @param Title $title
     * @param Language|StubUserLang|string &$pageLang
     */
    public static function onPageContentLanguage( Title $title, &$pageLang ) {
        // For translation pages, parse plural, grammar etc. with correct language,
        // and set the right direction
        if ( TranslatablePage::isTranslationPage( $title ) ) {
            [ , $code ] = Utilities::figureMessage( $title->getText() );
            $pageLang = MediaWikiServices::getInstance()->getLanguageFactory()->getLanguage( $code );
        }
    }

    /**
     * Display an edit notice for translatable source pages if it's enabled
     * Hook: TitleGetEditNotices
     * @param Title $title
     * @param int $oldid
     * @param array &$notices
     */
    public static function onTitleGetEditNotices( Title $title, int $oldid, array &$notices ) {
        if ( TranslatablePage::isSourcePage( $title ) ) {
            $msg = wfMessage( 'translate-edit-tag-warning' )->inContentLanguage();
            if ( !$msg->isDisabled() ) {
                $notices['translate-tag'] = $msg->parseAsBlock();
            }

            $notices[] = Html::warningBox(
                wfMessage( 'tps-edit-sourcepage-text' )->parse(),
                'translate-edit-documentation'
            );

            // The check is "we're using visual editor for WYSIWYG" (as opposed to "for wikitext
            // edition") - the message will not be displayed in that case.
            $request = RequestContext::getMain()->getRequest();
            if ( $request->getVal( 'action' ) === 'visualeditor' &&
                $request->getVal( 'paction' ) !== 'wikitext'
            ) {
                $notices[] = Html::warningBox(
                    wfMessage( 'tps-edit-sourcepage-ve-warning-limited-text' )->parse(),
                    'translate-edit-documentation'
                );
            }
        }
    }

    /**
     * Hook: BeforePageDisplay
     * @param OutputPage $out
     * @param Skin $skin
     */
    public static function onBeforePageDisplay( OutputPage $out, Skin $skin ) {
        global $wgTranslatePageTranslationULS;

        $title = $out->getTitle();
        $isSource = TranslatablePage::isSourcePage( $title );
        $isTranslation = TranslatablePage::isTranslationPage( $title );

        if ( $isSource || $isTranslation ) {
            if ( $wgTranslatePageTranslationULS ) {
                $out->addModules( 'ext.translate.pagetranslation.uls' );
            }

            if ( $isSource ) {
                // Adding a help notice
                $out->addModuleStyles( 'ext.translate.edit.documentation.styles' );
            }

            $out->addModuleStyles( 'ext.translate' );

            $out->addJsConfigVars( 'wgTranslatePageTranslation', $isTranslation ? 'translation' : 'source' );
        }
    }

    /**
     * Hook: onVisualEditorBeforeEditor
     * @param OutputPage $out
     * @param Skin $skin
     * @return bool
     */
    public static function onVisualEditorBeforeEditor( OutputPage $out, Skin $skin ) {
        return !TranslatablePage::isTranslationPage( $out->getTitle() );
    }

    /**
     * This is triggered after an edit to translation unit page
     * @param WikiPage $wikiPage
     * @param User $user
     * @param TextContent $content
     * @param string $summary
     * @param bool $minor
     * @param int $flags
     * @param MessageHandle $handle
     */
    public static function onSectionSave(
        WikiPage $wikiPage,
        User $user,
        TextContent $content,
        $summary,
        $minor,
        $flags,
        MessageHandle $handle
    ) {
        // FuzzyBot may do some duplicate work already worked on by other jobs
        if ( $user->equals( FuzzyBot::getUser() ) ) {
            return;
        }

        $group = $handle->getGroup();
        if ( !$group instanceof WikiPageMessageGroup ) {
            return;
        }

        // Finally we know the title and can construct a Translatable page
        $page = TranslatablePage::newFromTitle( $group->getTitle() );

        // Update the target translation page
        if ( !$handle->isDoc() ) {
            $code = $handle->getCode();
            DeferredUpdates::addCallableUpdate(
                function () use ( $page, $code, $user, $flags, $summary, $handle ) {
                    $unitTitle = $handle->getTitle();
                    self::updateTranslationPage( $page, $code, $user, $flags, $summary, null, $unitTitle );
                }
            );
        }
    }

    private static function updateTranslationPage(
        TranslatablePage $page,
        string $code,
        User $user,
        int $flags,
        string $summary,
        ?string $triggerAction = null,
        ?Title $unitTitle = null
    ): void {
        $source = $page->getTitle();
        $target = $source->getSubpage( $code );
        $mwInstance = MediaWikiServices::getInstance();

        // We don't know and don't care
        $flags &= ~EDIT_NEW & ~EDIT_UPDATE;

        // Update the target page
        $unitTitleText = $unitTitle ? $unitTitle->getPrefixedText() : null;
        $job = RenderTranslationPageJob::newJob( $target, $triggerAction, $unitTitleText );
        $job->setUser( $user );
        $job->setSummary( $summary );
        $job->setFlags( $flags );
        $mwInstance->getJobQueueGroup()->push( $job );

        // Invalidate caches so that language bar is up-to-date
        $pages = $page->getTranslationPages();
        $wikiPageFactory = $mwInstance->getWikiPageFactory();
        foreach ( $pages as $title ) {
            if ( $title->equals( $target ) ) {
                // Handled by the RenderTranslationPageJob
                continue;
            }

            $wikiPage = $wikiPageFactory->newFromTitle( $title );
            $wikiPage->doPurge();
        }
        $sourceWikiPage = $wikiPageFactory->newFromTitle( $source );
        $sourceWikiPage->doPurge();
    }

    /**
     * Hook: GetMagicVariableIDs
     * @param string[] &$variableIDs
     */
    public static function onGetMagicVariableIDs( &$variableIDs ): void {
        $variableIDs[] = 'translatablepage';
    }

    /**
     * Hook: ParserGetVariableValueSwitch
     */
    public static function onParserGetVariableValueSwitch(
        Parser $parser,
        array &$variableCache,
        string $magicWordId,
        ?string &$ret,
        PPFrame $frame
    ): void {
        switch ( $magicWordId ) {
            case 'translatablepage':
                $pageStatus = self::getTranslatablePageStatus( $parser->getPage() );
                $ret = $pageStatus !== null ? $pageStatus['page']->getTitle()->getPrefixedText() : '';
                $variableCache[$magicWordId] = $ret;
                break;
        }
    }

    /**
     * @param string $data
     * @param array $params
     * @param Parser $parser
     * @return string
     */
    public static function languages( $data, $params, $parser ) {
        global $wgPageTranslationLanguageList;

        if ( $wgPageTranslationLanguageList === 'sidebar-only' ) {
            return '';
        }

        self::$renderingContext = true;
        $context = new ScopedCallback( static function () {
            self::$renderingContext = false;
        } );

        // Store a property that we can avoid adding language links when
        // $wgPageTranslationLanguageList === 'sidebar-fallback'
        $parser->getOutput()->setUnsortedPageProperty( self::PAGEPROP_HAS_LANGUAGES_TAG );

        $currentPage = $parser->getPage();
        $pageStatus = self::getTranslatablePageStatus( $currentPage );
        if ( !$pageStatus ) {
            return '';
        }

        $page = $pageStatus[ 'page' ];
        $status = $pageStatus[ 'languages' ];
        $pageTitle = $page->getTitle();

        // Sort by language code, which seems to be the only sane method
        ksort( $status );

        // This way the parser knows to fragment the parser cache by language code
        $userLang = $parser->getOptions()->getUserLangObj();
        $userLangCode = $userLang->getCode();
        // Should call $page->getMessageGroup()->getSourceLanguage(), but
        // group is sometimes null on WMF during page moves, reason unknown.
        // This should do the same thing for now.
        $sourceLanguage = $pageTitle->getPageLanguage()->getCode();

        $languages = [];
        $langFactory = MediaWikiServices::getInstance()->getLanguageFactory();
        foreach ( $status as $code => $percent ) {
            // Get autonyms (null)
            $name = Utilities::getLanguageName( $code, LanguageNameUtils::AUTONYMS );

            // Add links to other languages
            $suffix = ( $code === $sourceLanguage ) ? '' : "/$code";
            $targetTitleString = $pageTitle->getDBkey() . $suffix;
            $subpage = Title::makeTitle( $pageTitle->getNamespace(), $targetTitleString );

            $classes = [];
            if ( $code === $userLangCode ) {
                $classes[] = 'mw-pt-languages-ui';
            }

            $linker = $parser->getLinkRenderer();
            $lang = $langFactory->getLanguage( $code );
            if ( $currentPage->isSamePageAs( $subpage ) ) {
                $classes[] = 'mw-pt-languages-selected';
                $classes = array_merge( $classes, self::tpProgressIcon( (float)$percent ) );
                $attribs = [
                    'class' => $classes,
                    'lang' => $lang->getHtmlCode(),
                    'dir' => $lang->getDir(),
                ];

                $contents = Html::element( 'span', $attribs, $name );
            } elseif ( $subpage->isKnown() ) {
                $pagename = $page->getPageDisplayTitle( $code );
                if ( !is_string( $pagename ) ) {
                    $pagename = $subpage->getPrefixedText();
                }

                $classes = array_merge( $classes, self::tpProgressIcon( (float)$percent ) );

                $title = wfMessage( 'tpt-languages-nonzero' )
                    ->page( $parser->getPage() )
                    ->inLanguage( $userLang )
                    ->params( $pagename )
                    ->numParams( 100 * $percent )
                    ->text();
                $attribs = [
                    'title' => $title,
                    'class' => $classes,
                    'lang' => $lang->getHtmlCode(),
                    'dir' => $lang->getDir(),
                ];

                $contents = $linker->makeKnownLink( $subpage, $name, $attribs );
            } else {
                /* When language is included because it is a priority language,
                 * but translations don't exist link directly to the
                 * translation view. */
                $specialTranslateTitle = SpecialPage::getTitleFor( 'Translate' );
                $params = [
                    'group' => $page->getMessageGroupId(),
                    'language' => $code,
                    'task' => 'view'
                ];

                $classes[] = 'new'; // For red link color

                $attribs = [
                    'title' => wfMessage( 'tpt-languages-zero' )
                        ->page( $parser->getPage() )
                        ->inLanguage( $userLang )
                        ->text(),
                    'class' => $classes,
                    'lang' => $lang->getHtmlCode(),
                    'dir' => $lang->getDir(),
                ];
                $contents = $linker->makeKnownLink( $specialTranslateTitle, $name, $attribs, $params );
            }
            $languages[ $name ] = Html::rawElement( 'li', [], $contents );
        }

        // Sort languages by autonym
        ksort( $languages );
        $languages = array_values( $languages );
        $languages = implode( "\n", $languages );

        $out = Html::openElement( 'div', [
            'class' => 'mw-pt-languages noprint navigation-not-searchable',
            'lang' => $userLang->getHtmlCode(),
            'dir' => $userLang->getDir()
        ] );
        $out .= Html::rawElement( 'div', [ 'class' => 'mw-pt-languages-label' ],
            wfMessage( 'tpt-languages-legend' )
                ->page( $parser->getPage() )
                ->inLanguage( $userLang )
                ->escaped()
        );
        $out .= Html::rawElement(
            'ul',
            [ 'class' => 'mw-pt-languages-list' ],
            $languages
        );
        $out .= Html::closeElement( 'div' );

        $parser->getOutput()->addModuleStyles( [
            'ext.translate.tag.languages',
        ] );

        return $out;
    }

    /**
     * Return icon CSS class for given progress status: percentages
     * are too accurate and take more space than simple images.
     * @param float $percent
     * @return string[]
     */
    private static function tpProgressIcon( float $percent ) {
        $classes = [ 'mw-pt-progress' ];
        $percent *= 100;
        if ( $percent < 15 ) {
            $classes[] = 'mw-pt-progress--low';
        } elseif ( $percent < 70 ) {
            $classes[] = 'mw-pt-progress--med';
        } elseif ( $percent < 100 ) {
            $classes[] = 'mw-pt-progress--high';
        } else {
            $classes[] = 'mw-pt-progress--complete';
        }
        return $classes;
    }

    /**
     * Returns translatable page and language stats for the given page.
     * @return array{page:TranslatablePage,languages:array}|null Returns null if not a translatable page.
     */
    private static function getTranslatablePageStatus( ?PageReference $pageReference ): ?array {
        if ( $pageReference === null ) {
            return null;
        }
        $title = Title::newFromPageReference( $pageReference );
        // Check if this is a source page or a translation page
        $page = TranslatablePage::newFromTitle( $title );
        if ( $page->getMarkedTag() === null ) {
            $page = TranslatablePage::isTranslationPage( $title );
        }

        if ( $page === false || $page->getMarkedTag() === null ) {
            return null;
        }

        $status = $page->getTranslationPercentages();
        if ( !$status ) {
            return null;
        }

        $messageGroupMetadata = Services::getInstance()->getMessageGroupMetadata();
        // If priority languages have been set, always show those languages
        $priorityLanguages = $messageGroupMetadata->get( $page->getMessageGroupId(), 'prioritylangs' );
        if ( (string)$priorityLanguages !== '' ) {
            $status += array_fill_keys( explode( ',', $priorityLanguages ), 0 );
        }

        return [
            'page' => $page,
            'languages' => $status
        ];
    }

    /**
     * Hooks: LanguageLinks
     * @param Title $title Title of the page for which links are needed.
     * @param array &$languageLinks List of language links to modify.
     */
    public static function addLanguageLinks( Title $title, array &$languageLinks ) {
        global $wgPageTranslationLanguageList;

        if ( $wgPageTranslationLanguageList === 'tag-only' ) {
            return;
        }

        if ( $wgPageTranslationLanguageList === 'sidebar-fallback' ) {
            $pageProps = MediaWikiServices::getInstance()->getPageProps();
            $languageProp = $pageProps->getProperties( $title, self::PAGEPROP_HAS_LANGUAGES_TAG );
            if ( $languageProp !== [] ) {
                return;
            }
        }

        // $wgPageTranslationLanguageList === 'sidebar-always' OR 'sidebar-only'

        $status = self::getTranslatablePageStatus( $title );
        if ( !$status ) {
            return;
        }

        self::$renderingContext = true;
        $context = new ScopedCallback( static function () {
            self::$renderingContext = false;
        } );

        $page = $status[ 'page' ];
        $languages = $status[ 'languages' ];
        $mwServices = MediaWikiServices::getInstance();
        $en = $mwServices->getLanguageFactory()->getLanguage( 'en' );

        $newLanguageLinks = [];

        // Batch the Title::exists queries used below
        $lb = $mwServices->getLinkBatchFactory()->newLinkBatch();
        foreach ( array_keys( $languages ) as $code ) {
            $title = $page->getTitle()->getSubpage( $code );
            $lb->addObj( $title );
        }
        $lb->execute();
        $languageNameUtils = $mwServices->getLanguageNameUtils();
        foreach ( $languages as $code => $percentage ) {
            $title = $page->getTitle()->getSubpage( $code );
            $key = "x-pagetranslation:{$title->getPrefixedText()}";
            $translatedName = $page->getPageDisplayTitle( $code ) ?: $title->getPrefixedText();

            if ( $title->exists() ) {
                $href = $title->getLocalURL();
                $classes = self::tpProgressIcon( (float)$percentage );
                $title = wfMessage( 'tpt-languages-nonzero' )
                    ->params( $translatedName )
                    ->numParams( 100 * $percentage );
            } else {
                $href = SpecialPage::getTitleFor( 'Translate' )->getLocalURL( [
                    'group' => $page->getMessageGroupId(),
                    'language' => $code,
                ] );
                $classes = [ 'mw-pt-progress--none' ];
                $title = wfMessage( 'tpt-languages-zero' );
            }

            self::$languageLinkData[ $key ] = [
                'href' => $href,
                'language' => $code,
                'percentage' => $percentage,
                'classes' => $classes,
                'autonym' => $en->ucfirst( $languageNameUtils->getLanguageName( $code ) ),
                'title' => $title,
            ];

            $newLanguageLinks[ $key ] = self::$languageLinkData[ $key ][ 'autonym' ];
        }

        asort( $newLanguageLinks );
        $languageLinks = array_merge( array_keys( $newLanguageLinks ), $languageLinks );
    }

    /**
     * Hooks: SkinTemplateGetLanguageLink
     * @param array &$link
     * @param Title $linkTitle
     * @param Title $pageTitle
     * @param OutputPage $out
     */
    public static function formatLanguageLink(
        array &$link,
        Title $linkTitle,
        Title $pageTitle,
        OutputPage $out
    ) {
        if ( !str_starts_with( $link['text'], 'x-pagetranslation:' ) ||
            !isset( self::$languageLinkData[ $link['text'] ] )
        ) {
            return;
        }

        $data = self::$languageLinkData[ $link[ 'text' ] ];

        $link[ 'class' ] .= ' ' . implode( ' ', $data[ 'classes' ] );
        $link[ 'href' ] = $data[ 'href' ];
        $link[ 'text' ] = $data[ 'autonym' ];
        $link[ 'title' ] = $data[ 'title' ]->inLanguage( $out->getLanguage()->getCode() )->text();
        $link[ 'lang'] = LanguageCode::bcp47( $data[ 'language' ] );
        $link[ 'hreflang'] = LanguageCode::bcp47( $data[ 'language' ] );

        $out->addModuleStyles( 'ext.translate.tag.languages' );
    }

    /**
     * Display nice error when editing content.
     * Hook: EditFilterMergedContent
     * @param IContextSource $context
     * @param Content $content
     * @param Status $status
     * @param string $summary
     * @return bool
     */
    public static function tpSyntaxCheckForEditContent(
        $context,
        $content,
        $status,
        $summary
    ) {
        $syntaxErrorStatus = self::tpSyntaxError( $context->getTitle(), $content );

        if ( $syntaxErrorStatus ) {
            $status->merge( $syntaxErrorStatus );
            return $syntaxErrorStatus->isGood();
        }

        return true;
    }

    private static function tpSyntaxError( ?PageIdentity $page, ?Content $content ): ?Status {
        if ( !$page || !self::isAllowedContentModel( $content, $page ) ) {
            return null;
        }

        '@phan-var TextContent $content';
        $text = $content->getText();

        // See T154500
        $text = TextContent::normalizeLineEndings( $text );
        $status = Status::newGood();
        $parser = Services::getInstance()->getTranslatablePageParser();
        if ( $parser->containsMarkup( $text ) ) {
            try {
                $parser->parse( $text );
            } catch ( ParsingFailure $e ) {
                $status->fatal( ...( $e->getMessageSpecification() ) );
            }
        }

        return $status;
    }

    /**
     * When attempting to save, last resort. Edit page would only display
     * edit conflict if there wasn't tpSyntaxCheckForEditPage.
     * Hook: MultiContentSave
     * @param RenderedRevision $renderedRevision
     * @param UserIdentity $user
     * @param CommentStoreComment $summary
     * @param int $flags
     * @param Status $hookStatus
     * @return bool
     */
    public static function tpSyntaxCheck(
        RenderedRevision $renderedRevision,
        UserIdentity $user,
        CommentStoreComment $summary,
        $flags,
        Status $hookStatus
    ) {
        $content = $renderedRevision->getRevision()->getContent( SlotRecord::MAIN );

        $status = self::tpSyntaxError(
            $renderedRevision->getRevision()->getPage(),
            $content
        );

        if ( $status ) {
            $hookStatus->merge( $status );
            return $status->isGood();
        }

        return true;
    }

    /**
     * Hook: PageSaveComplete
     *
     * @param WikiPage $wikiPage
     * @param UserIdentity $userIdentity
     * @param string $summary
     * @param int $flags
     * @param RevisionRecord $revisionRecord
     * @param EditResult $editResult
     */
    public static function addTranstagAfterSave(
        WikiPage $wikiPage,
        UserIdentity $userIdentity,
        string $summary,
        int $flags,
        RevisionRecord $revisionRecord,
        EditResult $editResult
    ) {
        $content = $wikiPage->getContent();

        // Only allow translating configured content models (T360544)
        if ( !self::isAllowedContentModel( $content, $wikiPage ) ) {
            return;
        }

        '@phan-var TextContent $content';
        $text = $content->getText();

        $parser = Services::getInstance()->getTranslatablePageParser();
        if ( $parser->containsMarkup( $text ) ) {
            // Add the ready tag
            $page = TranslatablePage::newFromTitle( $wikiPage->getTitle() );
            $page->addReadyTag( $revisionRecord->getId() );
        }

        // Schedule a deferred status update for the translatable page.
        $tpStatusUpdater = Services::getInstance()->getTranslatablePageStore();
        $tpStatusUpdater->performStatusUpdate( $wikiPage->getTitle() );
    }

    /**
     * Page moving and page protection (and possibly other things) creates null
     * revisions. These revisions re-use the previous text already stored in
     * the database. Those however do not trigger re-parsing of the page and
     * thus the ready tag is not updated. This watches for new revisions,
     * checks if they reuse existing text, checks whether the parent version
     * is the latest version and has a ready tag. If that is the case,
     * also adds a ready tag for the new revision (which is safe, because
     * the text hasn't changed). The interface will say that there has been
     * a change, but shows no change in the content. This lets the user to
     * re-mark the translations of the page title as outdated (if enabled
     * for translation).
     * Hook: RevisionRecordInserted
     * @param RevisionRecord $rev
     */
    public static function updateTranstagOnNullRevisions( RevisionRecord $rev ) {
        $parentId = $rev->getParentId();
        if ( $parentId === 0 || $parentId === null ) {
            // No parent, bail out.
            return;
        }

        $prevRev = MediaWikiServices::getInstance()
            ->getRevisionLookup()
            ->getRevisionById( $parentId );

        if ( !$prevRev || !$rev->hasSameContent( $prevRev ) ) {
            // Not a null revision, bail out.
            return;
        }

        $title = Title::newFromLinkTarget( $rev->getPageAsLinkTarget() );
        $bundleFactory = Services::getInstance()->getTranslatableBundleFactory();
        $bundle = $bundleFactory->getBundle( $title );

        if ( $bundle ) {
            $bundleStore = $bundleFactory->getStore( $bundle );
            $bundleStore->handleNullRevisionInsert( $bundle, $rev );
        }
    }

    /**
     * Prevent creation of orphan translation units in Translations namespace.
     * Prevent editing of translation units relating to the source language (these should only be touched by FuzzyBot)
     * Prevent editing of translation units relating to a page if you're blocked from that page
     * Prevent editing of translation units relating to a language that the page isn't allowed to be translated into.
     * Hook: getUserPermissionsErrorsExpensive
     *
     * @param Title $title
     * @param User $user
     * @param string $action
     * @param mixed &$result
     * @return bool
     */
    public static function onGetUserPermissionsErrorsExpensive(
        Title $title,
        User $user,
        $action,
        &$result
    ) {
        $handle = new MessageHandle( $title );

        if ( !$handle->isPageTranslation() || $action === 'read' ) {
            return true;
        }

        $isValid = true;
        $groupId = null;

        if ( $handle->isValid() ) {
            $group = $handle->getGroup();
            $groupId = $group->getId();
            $permissionTitleCheck = null;

            if ( $group instanceof WikiPageMessageGroup ) {
                $permissionTitleCheck = $group->getTitle();
            } elseif ( $group instanceof MessageBundleMessageGroup ) {
                // TODO: This check for MessageBundle related permission should be in
                // the MessageBundleTranslation/Hook
                $permissionTitleCheck = Title::newFromID( $group->getBundlePageId() );
            }

            if ( $permissionTitleCheck ) {
                if ( $handle->getCode() === $group->getSourceLanguage() && !$user->equals( FuzzyBot::getUser() ) ) {
                    // Allow revision deletion actions as per T286884 since if something bad somehow gets marked for
                    // translation, deleting revisions everywhere should be possible without deliberately
                    // invalidating the unit
                    $allowedActionList = [
                        'deleterevision', 'suppressrevision', 'viewsuppressed', // T286884
                    ];
                    if ( !in_array( $action, $allowedActionList ) ) {
                        $result = [ 'tpt-cant-edit-source-language', $permissionTitleCheck ];
                        return false;
                    }
                }
                // Check for blocks
                $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
                if ( $permissionManager->isBlockedFrom( $user, $permissionTitleCheck ) ) {
                    $block = $user->getBlock();
                    if ( $block ) {
                        $error = new UserBlockedError( $block, $user );
                        $errorMessage = $error->getMessageObject();
                        $result = array_merge( [ $errorMessage->getKey() ], $errorMessage->getParams() );
                        return false;
                    }
                }
            }
        }

        // Allow editing units that become orphaned in regular use, so that
        // people can delete them or fix links or other issues in them.
        if ( $action !== 'create' ) {
            return true;
        }

        if ( !$handle->isValid() ) {
            // TODO: These checks may no longer be needed
            // Sometimes the message index can be out of date. Either the rebuild job failed or
            // it just hasn't finished yet. Do a secondary check to make sure we are not
            // inconveniencing translators for no good reason.
            // See https://phabricator.wikimedia.org/T221119
            $translatablePage = self::checkTranslatablePageSlow( $title );
            MediaWikiServices::getInstance()->getStatsFactory()
                ->withComponent( 'Translate' )
                ->getCounter( 'slow_translatable_page_check' )
                ->setLabel( 'valid', $translatablePage ? 'yes' : 'no' )
                ->increment();

            if ( $translatablePage ) {
                $groupId = $translatablePage->getMessageGroupId();
            } else {
                $isValid = false;
            }
        }

        if ( $isValid ) {
            $error = self::getTranslationRestrictions( $handle, $groupId );
            $result = $error ?: $result;
            return $error === [];
        }

        // Don't allow editing invalid messages that do not belong to any translatable page
        LoggerFactory::getInstance( 'Translate' )->info(
            'Unknown translation page: {title}',
            [ 'title' => $title->getPrefixedDBkey() ]
        );
        $result = [ 'tpt-unknown-page' ];
        return false;
    }

    private static function checkTranslatablePageSlow( LinkTarget $unit ): ?TranslatablePage {
        $parts = TranslatablePage::parseTranslationUnit( $unit );
        $translationPageTitle = Title::newFromText(
            $parts[ 'sourcepage' ] . '/' . $parts[ 'language' ]
        );
        if ( !$translationPageTitle ) {
            return null;
        }

        $translatablePage = TranslatablePage::isTranslationPage( $translationPageTitle );
        if ( !$translatablePage ) {
            return null;
        }

        $factory = Services::getInstance()->getTranslationUnitStoreFactory();
        $store = $factory->getReader( $translatablePage->getTitle() );
        $units = $store->getNames();

        if ( !in_array( $parts[ 'section' ], $units ) ) {
            return null;
        }

        return $translatablePage;
    }

    /**
     * Prevent editing of restricted languages when prioritized.
     *
     * @param MessageHandle $handle
     * @param string $groupId
     * @return array array containing error message if restricted, empty otherwise
     */
    private static function getTranslationRestrictions( MessageHandle $handle, $groupId ) {
        global $wgTranslateDocumentationLanguageCode;

        // Allow adding message documentation even when translation is restricted
        if ( $handle->getCode() === $wgTranslateDocumentationLanguageCode ) {
            return [];
        }

        $messageGroupMetadata = Services::getInstance()->getMessageGroupMetadata();
        // Check if anything is prevented for the group in the first place
        $force = $messageGroupMetadata->get( $groupId, 'priorityforce' );
        if ( $force !== 'on' ) {
            return [];
        }

        // And finally check whether the language is in the inclusion list
        $languages = $messageGroupMetadata->get( $groupId, 'prioritylangs' );
        $reason = $messageGroupMetadata->get( $groupId, 'priorityreason' );
        if ( !$languages ) {
            if ( $reason ) {
                return [ 'tpt-translation-restricted-no-priority-languages', $reason ];
            }
            return [ 'tpt-translation-restricted-no-priority-languages-no-reason' ];
        }

        $filter = array_flip( explode( ',', $languages ) );
        if ( !isset( $filter[$handle->getCode()] ) ) {
            if ( $reason ) {
                return [ 'tpt-translation-restricted', $reason ];
            }

            return [ 'tpt-translation-restricted-no-reason' ];
        }

        return [];
    }

    /**
     * Prevent editing of translation pages directly.
     * Hook: getUserPermissionsErrorsExpensive
     * @param Title $title
     * @param User $user
     * @param string $action
     * @param bool &$result
     * @return bool
     */
    public static function preventDirectEditing( Title $title, User $user, $action, &$result ) {
        if ( self::$allowTargetEdit ) {
            return true;
        }

        $inclusionList = [
            'read', 'deletedtext', 'deletedhistory',
            'deleterevision', 'suppressrevision', 'viewsuppressed', // T286884
            'review', // FlaggedRevs
            'patrol', // T151172
        ];
        $needsPageTranslationRight = in_array( $action, [ 'delete', 'undelete' ] );
        if ( in_array( $action, $inclusionList ) ||
            ( $needsPageTranslationRight && $user->isAllowed( 'pagetranslation' ) )
        ) {
            return true;
        }

        $page = TranslatablePage::isTranslationPage( $title );
        if ( $page !== false && $page->getMarkedTag() ) {
            $mwService = MediaWikiServices::getInstance();
            if ( $needsPageTranslationRight ) {
                $context = RequestContext::getMain();
                $statusFormatter = $mwService->getFormatterFactory()->getStatusFormatter( $context );
                $permissionError = $mwService->getPermissionManager()
                    ->newFatalPermissionDeniedStatus( 'pagetranslation', $context );
                $result = $statusFormatter->getMessage( $permissionError );
                return false;
            }

            [ , $code ] = Utilities::figureMessage( $title->getText() );

            $translationUrl = $mwService->getUrlUtils()->expand(
                $page->getTranslationUrl( $code ), PROTO_RELATIVE
            );

            $result = [
                'tpt-target-page',
                ':' . $page->getTitle()->getPrefixedText(),
                // This url shouldn't get cached
                $translationUrl
            ];

            return false;
        }

        return true;
    }

    /**
     * Redirects the delete action to our own for translatable pages.
     * Hook: ArticleConfirmDelete
     *
     * @param Article $article
     * @param OutputPage $out
     * @param string &$reason
     *
     * @return bool
     */
    public static function disableDelete( $article, $out, &$reason ) {
        $title = $article->getTitle();
        $bundle = Services::getInstance()->getTranslatableBundleFactory()->getBundle( $title );
        $isDeletableBundle = $bundle && $bundle->isDeletable();
        if ( $isDeletableBundle || TranslatablePage::isTranslationPage( $title ) ) {
            $new = SpecialPage::getTitleFor(
                'PageTranslationDeletePage',
                $title->getPrefixedText()
            );
            $out->redirect( $new->getFullURL() );
        }

        return true;
    }

    /**
     * Hook: ArticleViewHeader
     *
     * @param Article $article
     * @param bool|ParserOutput|null &$outputDone
     * @param bool &$pcache
     */
    public static function translatablePageHeader( $article, &$outputDone, &$pcache ) {
        if ( $article->getOldID() ) {
            return;
        }

        $articleTitle = $article->getTitle();
        $transPage = TranslatablePage::isTranslationPage( $articleTitle );
        $context = $article->getContext();
        if ( $transPage ) {
            self::translationPageHeader( $context, $transPage );
        } else {
            $viewTranslatablePage = Services::getInstance()->getTranslatablePageView();
            $user = $context->getUser();
            if ( $viewTranslatablePage->canDisplayTranslationSettingsBanner( $articleTitle, $user ) ) {
                $output = $context->getOutput();
                $pageUrl = SpecialPage::getTitleFor( 'PageTranslation' )->getFullURL( [
                    'do' => 'settings',
                    'target' => $articleTitle->getPrefixedDBkey(),
                ] );
                $output->addHTML(
                    Html::noticeBox(
                        $context->msg( 'pt-cta-mark-translation', $pageUrl )->parse(),
                        'translate-cta-pt-mark'
                    )
                );
            } else {
                self::sourcePageHeader( $context );
            }
        }
    }

    private static function sourcePageHeader( IContextSource $context ) {
        $linker = MediaWikiServices::getInstance()->getLinkRenderer();

        $language = $context->getLanguage();
        $title = $context->getTitle();

        $page = TranslatablePage::newFromTitle( $title );

        $marked = $page->getMarkedTag();
        $ready = $page->getReadyTag();
        $latest = $title->getLatestRevID();

        $actions = [];
        if ( $marked && $context->getUser()->isAllowed( 'translate' ) ) {
            $actions[] = self::getTranslateLink( $context, $page, null );
        }

        $hasChanges = $ready === $latest && $marked !== $latest;
        if ( $hasChanges ) {
            $diffUrl = $title->getFullURL( [ 'oldid' => $marked, 'diff' => $latest ] );

            if ( $context->getUser()->isAllowed( 'pagetranslation' ) ) {
                $pageTranslation = SpecialPage::getTitleFor( 'PageTranslation' );
                $params = [ 'target' => $title->getPrefixedText(), 'do' => 'mark' ];

                if ( $marked === null ) {
                    // This page has never been marked
                    $linkDesc = $context->msg( 'translate-tag-markthis' )->text();
                    $actions[] = $linker->makeKnownLink( $pageTranslation, $linkDesc, [], $params );
                } else {
                    $markUrl = $pageTranslation->getFullURL( $params );
                    $actions[] = $context->msg( 'translate-tag-markthisagain', $diffUrl, $markUrl )
                        ->parse();
                }
            } else {
                $actions[] = $context->msg( 'translate-tag-hasnew', $diffUrl )->parse();
            }
        }

        if ( !count( $actions ) ) {
            return;
        }

        $header = Html::rawElement(
            'div',
            [
                'class' => 'mw-pt-translate-header noprint nomobile',
                'dir' => $language->getDir(),
                'lang' => $language->getHtmlCode(),
            ],
            $language->semicolonList( $actions )
        );

        $context->getOutput()->addHTML( $header );
    }

    private static function getTranslateLink(
        IContextSource $context,
        TranslatablePage $page,
        ?string $langCode
    ): string {
        $linker = MediaWikiServices::getInstance()->getLinkRenderer();

        return $linker->makeKnownLink(
                SpecialPage::getTitleFor( 'Translate' ),
                $context->msg( 'translate-tag-translate-link-desc' )->text(),
                [],
                [
                    'group' => $page->getMessageGroupId(),
                    'language' => $langCode,
                    'action' => 'page',
                    'filter' => '',
                    'action_source' => 'translate_page'
                ]
            );
    }

    private static function translationPageHeader( IContextSource $context, TranslatablePage $page ) {
        global $wgTranslateKeepOutdatedTranslations;

        $title = $context->getTitle();
        if ( !$title->exists() ) {
            return;
        }

        [ , $code ] = Utilities::figureMessage( $title->getText() );

        // Get the translation percentage
        $pers = $page->getTranslationPercentages();
        $per = 0;
        if ( isset( $pers[$code] ) ) {
            $per = $pers[$code] * 100;
        }

        $language = $context->getLanguage();
        $output = $context->getOutput();

        if ( $page->getSourceLanguageCode() === $code ) {
            // If we are on the source language page, link to translate for user's language
            $msg = self::getTranslateLink( $context, $page, $language->getCode() );
        } else {
            $mwService = MediaWikiServices::getInstance();

            $translationUrl = $mwService->getUrlUtils()->expand(
                $page->getTranslationUrl( $code ), PROTO_RELATIVE
            );

            $msg = $context->msg( 'tpt-translation-intro',
                $translationUrl,
                ':' . $page->getTitle()->getPrefixedText(),
                $language->formatNum( $per )
            )->parse();
        }

        $header = Html::rawElement(
            'div',
            [
                'class' => 'mw-pt-translate-header noprint',
                'dir' => $language->getDir(),
                'lang' => $language->getHtmlCode(),
            ],
            $msg
        );

        $output->addHTML( $header );

        if ( $wgTranslateKeepOutdatedTranslations ) {
            $groupId = $page->getMessageGroupId();
            // This is already calculated and cached by above call to getTranslationPercentages
            $stats = MessageGroupStats::forItem( $groupId, $code );
            if ( $stats[MessageGroupStats::FUZZY] ) {
                // Only show if there is fuzzy messages
                $wrap = Html::rawElement(
                    'div',
                    [
                        'class' => 'mw-pt-translate-header',
                        'dir' => $language->getDir(),
                        'lang' => $language->getHtmlCode()
                    ],
                    '<span class="mw-translate-fuzzy">$1</span>'
                );

                $output->wrapWikiMsg( $wrap, [ 'tpt-translation-intro-fuzzy' ] );
            }
        }
    }

    private static function isAllowedContentModel( Content $content, PageReference $page ): bool {
        $config = MediaWikiServices::getInstance()->getMainConfig();
        $allowedModels = $config->get( 'PageTranslationAllowedContentModels' );
        $contentModel = $content->getModel();
        $allowed = (bool)( $allowedModels[$contentModel] ?? false );

        // T163254: Disable page translation on non-text pages
        if ( $allowed && !$content instanceof TextContent ) {
            LoggerFactory::getInstance( 'Translate' )->error(
                'Expected {title} to have content of type TextContent, got {contentType}. ' .
                '$wgPageTranslationAllowedContentModels is incorrectly configured with a non-text content model.',
                [
                    'title' => (string)$page,
                    'contentType' => get_class( $content )
                ]
            );
            return false;
        }

        return $allowed;
    }

    /**
     * Hook: SpecialPage_initList
     * @param array &$list
     */
    public static function replaceMovePage( &$list ) {
        $movePageSpec = $list['Movepage'] ?? null;

        // This should never happen, but apparently is happening? See: T296568
        if ( $movePageSpec === null ) {
            return;
        }

        $list['Movepage'] = [
            'class' => MoveTranslatableBundleSpecialPage::class,
            'services' => [
                'ObjectFactory',
                'PermissionManager',
                'Translate:TranslatableBundleMover',
                'Translate:TranslatableBundleFactory',
                'FormatterFactory'
            ],
            'args' => [
                $movePageSpec
            ]
        ];
    }

    /**
     * Hook: getUserPermissionsErrorsExpensive
     * @param Title $title
     * @param User $user
     * @param string $action
     * @param mixed &$result
     * @return bool
     */
    public static function lockedPagesCheck( Title $title, User $user, $action, &$result ) {
        if ( $action === 'read' ) {
            return true;
        }

        $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()->getInstance( CACHE_ANYTHING );
        $key = $cache->makeKey( 'pt-lock', sha1( $title->getPrefixedText() ) );
        if ( $cache->get( $key ) === 'locked' ) {
            $result = [ 'pt-locked-page' ];

            return false;
        }

        return true;
    }

    /**
     * Hook: SkinSubPageSubtitle
     * @param array &$subpages
     * @param ?Skin $skin
     * @param OutputPage $out
     * @return bool
     */
    public static function replaceSubtitle( &$subpages, ?Skin $skin, OutputPage $out ) {
        $linker = MediaWikiServices::getInstance()->getLinkRenderer();

        $isTranslationPage = TranslatablePage::isTranslationPage( $out->getTitle() );
        if ( !$isTranslationPage
            && !TranslatablePage::isSourcePage( $out->getTitle() )
        ) {
            return true;
        }

        // Copied from Skin::subPageSubtitle()
        $nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
        if (
            $out->isArticle() &&
            $nsInfo->hasSubpages( $out->getTitle()->getNamespace() )
        ) {
            $ptext = $out->getTitle()->getPrefixedText();
            $links = explode( '/', $ptext );
            if ( count( $links ) > 1 ) {
                array_pop( $links );
                if ( $isTranslationPage ) {
                    // Also remove language code page
                    array_pop( $links );
                }
                $c = 0;
                $growinglink = '';
                $display = '';
                $sitedir = $skin->getLanguage()->getDir();

                foreach ( $links as $link ) {
                    $growinglink .= $link;
                    $display .= $link;
                    $linkObj = Title::newFromText( $growinglink );

                    if ( $linkObj && $linkObj->isKnown() ) {
                        $getlink = $linker->makeKnownLink(
                            SpecialPage::getTitleFor( 'MyLanguage', $growinglink ),
                            $display
                        );

                        $c++;

                        if ( $c > 1 ) {
                            $subpages .= $skin->msg( 'pipe-separator' )->escaped();
                        } else {
                            $subpages .= '&lt; ';
                        }

                        $subpages .= Html::rawElement( 'bdi', [ 'dir' => $sitedir ], $getlink );
                        $display = '';
                    } else {
                        $display .= '/';
                    }

                    $growinglink .= '/';
                }
            }

            return false;
        }

        return true;
    }

    /**
     * Converts the edit tab (if exists) for translation pages to translate tab.
     * Hook: SkinTemplateNavigation::Universal
     * @param Skin $skin
     * @param array &$tabs
     */
    public static function translateTab( Skin $skin, array &$tabs ) {
        $title = $skin->getTitle();
        $handle = new MessageHandle( $title );
        $code = $handle->getCode();
        $page = TranslatablePage::isTranslationPage( $title );
        // The source language has a subpage too, but cannot be translated
        if ( !$page || $page->getSourceLanguageCode() === $code ) {
            return;
        }

        $user = $skin->getUser();
        if ( isset( $tabs['views']['edit'] ) ) {
            // There is an edit tab, just replace its text and URL with ours, keeping the tooltip and access key
            $tabs['views']['edit']['text'] = $skin->msg( 'tpt-tab-translate' )->text();
            $tabs['views']['edit']['href'] = $page->getTranslationUrl( $code );
        } elseif ( $user->isAllowed( 'translate' ) ) {
            $mwInstance = MediaWikiServices::getInstance();
            $namespaceProtection = $mwInstance->getMainConfig()->get( MainConfigNames::NamespaceProtection );
            $permissionManager = $mwInstance->getPermissionManager();
            if (
                !$permissionManager->userHasAllRights(
                    $user, ...(array)( $namespaceProtection[ NS_TRANSLATIONS ] ?? [] )
                )
            ) {
                return;
            }

            $tab = [
                'text' => $skin->msg( 'tpt-tab-translate' )->text(),
                'href' => $page->getTranslationUrl( $code ),
            ];

            // Get the position of the viewsource tab within the array (if any)
            $viewsourcePos = array_keys( array_keys( $tabs['views'] ), 'viewsource', true )[0] ?? null;

            if ( $viewsourcePos !== null ) {
                // Remove the viewsource tab and insert the translate tab at its place. Showing the tooltip
                // of the viewsource tab for the translate tab would be confusing.
                array_splice( $tabs['views'], $viewsourcePos, 1, [ 'translate' => $tab ] );
            } else {
                // We have neither an edit tab nor a viewsource tab to replace with the translate tab,
                // put the translate tab at the end
                $tabs['views']['translate'] = $tab;
            }
        }
    }

    /**
     * Hook to update source and destination translation pages on moving translation units
     * Hook: PageMoveComplete
     *
     * @param LinkTarget $oldLinkTarget
     * @param LinkTarget $newLinkTarget
     * @param UserIdentity $userIdentity
     * @param int $oldid
     * @param int $newid
     * @param string $reason
     * @param RevisionRecord $revisionRecord
     */
    public static function onMovePageTranslationUnits(
        LinkTarget $oldLinkTarget,
        LinkTarget $newLinkTarget,
        UserIdentity $userIdentity,
        int $oldid,
        int $newid,
        string $reason,
        RevisionRecord $revisionRecord
    ) {
        $user = MediaWikiServices::getInstance()->getUserFactory()->newFromUserIdentity( $userIdentity );
        // MoveTranslatableBundleJob takes care of handling updates because it performs
        // a lot of moves at once. As a performance optimization, skip this hook if
        // we detect moves from that job. As there isn't a good way to pass information
        // to this hook what originated the move, we use some heuristics.
        if ( defined( 'MEDIAWIKI_JOB_RUNNER' ) && $user->equals( FuzzyBot::getUser() ) ) {
            return;
        }

        $oldTitle = Title::newFromLinkTarget( $oldLinkTarget );
        $newTitle = Title::newFromLinkTarget( $newLinkTarget );
        $groupLast = null;
        foreach ( [ $oldTitle, $newTitle ] as $title ) {
            $handle = new MessageHandle( $title );
            // Documentation pages are never translation pages
            if ( !$handle->isValid() || $handle->isDoc() ) {
                continue;
            }

            $group = $handle->getGroup();
            if ( !$group instanceof WikiPageMessageGroup ) {
                continue;
            }

            $language = $handle->getCode();

            // Ignore pages such as Translations:Page/unit without language code
            if ( $language === '' ) {
                continue;
            }

            // Update the page only once if source and destination units
            // belong to the same page
            if ( $group !== $groupLast ) {
                $groupLast = $group;
                $page = TranslatablePage::newFromTitle( $group->getTitle() );
                self::updateTranslationPage( $page, $language, $user, 0, $reason );
            }
        }
    }

    /**
     * Hook to update translation page on deleting a translation unit
     * Hook: ArticleDeleteComplete
     * @param WikiPage $unit
     * @param User $user
     * @param string $reason
     * @param int $id
     * @param Content $content
     * @param ManualLogEntry $logEntry
     */
    public static function onDeleteTranslationUnit(
        WikiPage $unit,
        User $user,
        $reason,
        $id,
        $content,
        $logEntry
    ) {
        $title = $unit->getTitle();
        $handle = new MessageHandle( $title );
        if ( !$handle->isValid() ) {
            return;
        }

        $group = $handle->getGroup();
        if ( !$group instanceof WikiPageMessageGroup ) {
            return;
        }

        $mwServices = MediaWikiServices::getInstance();
        // During deletions this may cause creation of a lot of duplicate jobs. It is expected that
        // job queue will deduplicate them to reduce the number of jobs actually run.
        $mwServices->getJobQueueGroup()->push(
            RebuildMessageGroupStatsJob::newRefreshGroupsJob( [ $group->getId() ] )
        );

        // Logic to update translation pages, skipped if we are in a middle of a deletion
        if ( self::$isDeleteTranslatableBundleJobRunning ) {
            return;
        }

        $target = $group->getTitle();
        $langCode = $handle->getCode();
        $fname = __METHOD__;

        $dbw = $mwServices->getConnectionProvider()->getPrimaryDatabase();
        $callback = function () use (
            $dbw,
            $target,
            $handle,
            $langCode,
            $user,
            $reason,
            $fname
        ) {
            $translationPageTitle = $target->getSubpage( $langCode );
            // Do a more thorough check for the translation page in case the translation page is deleted in a
            // different transaction.
            if ( !$translationPageTitle || !$translationPageTitle->exists( IDBAccessObject::READ_LATEST ) ) {
                return;
            }

            $dbw->startAtomic( $fname );

            $page = TranslatablePage::newFromTitle( $target );

            if ( !$handle->isDoc() ) {
                $unitTitle = $handle->getTitle();
                // Assume that $user and $reason for the first deletion is the same for all
                self::updateTranslationPage(
                    $page, $langCode, $user, 0, $reason, RenderTranslationPageJob::ACTION_DELETE, $unitTitle
                );
            }

            $dbw->endAtomic( $fname );
        };

        $dbw->onTransactionCommitOrIdle( $callback, __METHOD__ );
    }

    /**
     * Removes translation pages from the list of page titles to be edited
     * Hook: ReplaceTextFilterPageTitlesForEdit
     */
    public static function onReplaceTextFilterPageTitlesForEdit( array &$titles ): void {
        foreach ( $titles as $index => $title ) {
            $handle = new MessageHandle( $title );
            if ( Utilities::isTranslationPage( $handle ) ) {
                unset( $titles[ $index ] );
            }
        }
    }

    /**
     * Removes translatable and translation pages from the list of titles to be renamed
     * Hook: ReplaceTextFilterPageTitlesForRename
     */
    public static function onReplaceTextFilterPageTitlesForRename( array &$titles ): void {
        foreach ( $titles as $index => $title ) {
            $handle = new MessageHandle( $title );
            if (
                TranslatablePage::isSourcePage( $title ) ||
                Utilities::isTranslationPage( $handle )
            ) {
                unset( $titles[ $index ] );
            }
        }
    }

    public static function getSpecialManageMessageGroupSubscriptionsLink(
        Context $context,
        Config $config
    ): array {
        return [
            'pagelink' => SpecialPage::getTitleFor( 'ManageMessageGroupSubscriptions' )->getPrefixedText()
        ];
    }
}