Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
15.45% covered (danger)
15.45%
119 / 770
10.26% covered (danger)
10.26%
4 / 39
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
15.45% covered (danger)
15.45%
119 / 770
10.26% covered (danger)
10.26%
4 / 39
24860.89
0.00% covered (danger)
0.00%
0 / 1
 renderTagPage
59.57% covered (warning)
59.57%
28 / 47
0.00% covered (danger)
0.00%
0 / 1
12.23
 preprocessTagPage
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
3.00
 onParserOutputPostCacheTransform
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 fetchTranslatableTemplateAndTitle
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
132
 onPageContentLanguage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 onTitleGetEditNotices
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 onBeforePageDisplay
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
56
 onVisualEditorBeforeEditor
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onSectionSave
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 updateTranslationPage
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 onGetMagicVariableIDs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onParserGetVariableValueSwitch
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 languages
0.00% covered (danger)
0.00%
0 / 99
0.00% covered (danger)
0.00%
0 / 1
90
 tpProgressIcon
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 getTranslatablePageStatus
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
7.23
 addLanguageLinks
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
90
 formatLanguageLink
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 tpSyntaxCheckForEditContent
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 tpSyntaxError
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
5.51
 tpSyntaxCheck
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 addTranstagAfterSave
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 updateTranstagOnNullRevisions
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 onGetUserPermissionsErrorsExpensive
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
210
 checkTranslatablePageSlow
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 getTranslationRestrictions
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 preventDirectEditing
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
72
 disableDelete
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 translatablePageHeader
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 sourcePageHeader
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
72
 getTranslateLink
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 translationPageHeader
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
42
 replaceMovePage
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
2.00
 lockedPagesCheck
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 replaceSubtitle
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
132
 translateTab
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
56
 onMovePageTranslationUnits
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
90
 onDeleteTranslationUnit
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
56
 onReplaceTextFilterPageTitlesForEdit
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 onReplaceTextFilterPageTitlesForRename
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace MediaWiki\Extension\Translate\PageTranslation;
4
5use Article;
6use Content;
7use DeferredUpdates;
8use Exception;
9use IContextSource;
10use IDBAccessObject;
11use Language;
12use LanguageCode;
13use ManualLogEntry;
14use MediaWiki\CommentStore\CommentStoreComment;
15use MediaWiki\Extension\Translate\MessageBundleTranslation\MessageBundleMessageGroup;
16use MediaWiki\Extension\Translate\MessageLoading\MessageHandle;
17use MediaWiki\Extension\Translate\Services;
18use MediaWiki\Extension\Translate\Statistics\RebuildMessageGroupStatsJob;
19use MediaWiki\Extension\Translate\SystemUsers\FuzzyBot;
20use MediaWiki\Extension\Translate\Utilities\Utilities;
21use MediaWiki\Html\Html;
22use MediaWiki\Languages\LanguageNameUtils;
23use MediaWiki\Linker\LinkTarget;
24use MediaWiki\Logger\LoggerFactory;
25use MediaWiki\MainConfigNames;
26use MediaWiki\MediaWikiServices;
27use MediaWiki\Page\PageIdentity;
28use MediaWiki\Revision\MutableRevisionRecord;
29use MediaWiki\Revision\RenderedRevision;
30use MediaWiki\Revision\RevisionRecord;
31use MediaWiki\Revision\SlotRecord;
32use MediaWiki\Storage\EditResult;
33use MediaWiki\StubObject\StubUserLang;
34use MediaWiki\Title\Title;
35use MediaWiki\User\UserIdentity;
36use MessageGroupStats;
37use ObjectCache;
38use OutputPage;
39use Parser;
40use ParserOutput;
41use PPFrame;
42use RequestContext;
43use Skin;
44use SpecialPage;
45use Status;
46use TextContent;
47use User;
48use UserBlockedError;
49use Wikimedia\ScopedCallback;
50use WikiPage;
51use WikiPageMessageGroup;
52use WikitextContent;
53
54/**
55 * Hooks for page translation.
56 * @author Niklas Laxström
57 * @license GPL-2.0-or-later
58 * @ingroup PageTranslation
59 */
60class Hooks {
61    private const PAGEPROP_HAS_LANGUAGES_TAG = 'translate-has-languages-tag';
62    // Uuugly hacks
63    public static $allowTargetEdit = false;
64    /** State flag used by DeleteTranslatableBundleJob for performance optimizations. */
65    public static bool $isDeleteTranslatableBundleJobRunning = false;
66    // Check if we are just rendering tags or such
67    public static $renderingContext = false;
68    // Used to communicate data between LanguageLinks and SkinTemplateGetLanguageLink hooks.
69    private static $languageLinkData = [];
70
71    /**
72     * Hook: ParserBeforeInternalParse
73     * @param Parser $wikitextParser
74     * @param null|string &$text
75     * @param-taint $text escapes_htmlnoent
76     * @param mixed $state
77     */
78    public static function renderTagPage( $wikitextParser, &$text, $state ): void {
79        if ( $text === null ) {
80            // SMW is unhelpfully sending null text if the source contains section tags. Do not explode.
81            return;
82        }
83
84        self::preprocessTagPage( $wikitextParser, $text, $state );
85
86        // Skip further interface message parsing
87        if ( $wikitextParser->getOptions()->getInterfaceMessage() ) {
88            return;
89        }
90
91        // For section previews, perform additional clean-up, given tags are often
92        // unbalanced when we preview one section only.
93        if ( $wikitextParser->getOptions()->getIsSectionPreview() ) {
94            $translatablePageParser = Services::getInstance()->getTranslatablePageParser();
95            $text = $translatablePageParser->cleanupTags( $text );
96        }
97
98        // Set display title
99        $title = MediaWikiServices::getInstance()
100            ->getTitleFactory()
101            ->castFromPageReference( $wikitextParser->getPage() );
102        if ( !$title ) {
103            return;
104        }
105
106        $page = TranslatablePage::isTranslationPage( $title );
107        if ( !$page ) {
108            return;
109        }
110
111        $wikitextParser->getOutput()->setPageProperty( 'translate-is-translation', true );
112
113        try {
114            self::$renderingContext = true;
115            [ , $code ] = Utilities::figureMessage( $title->getText() );
116            $name = $page->getPageDisplayTitle( $code );
117            if ( $name ) {
118                $name = $wikitextParser->recursivePreprocess( $name );
119
120                $langConv = MediaWikiServices::getInstance()->getLanguageConverterFactory()
121                    ->getLanguageConverter( $wikitextParser->getTargetLanguage() );
122                $name = $langConv->convert( $name );
123                $wikitextParser->getOutput()->setDisplayTitle( $name );
124            }
125            self::$renderingContext = false;
126        } catch ( Exception $e ) {
127            LoggerFactory::getInstance( 'Translate' )->error(
128                'T302754 Failed to set display title for page {title}',
129                [
130                    'title' => $title->getPrefixedDBkey(),
131                    'text' => $text,
132                    'pageid' => $title->getId(),
133                ]
134            );
135
136            // Re-throw to preserve behavior
137            throw $e;
138        }
139
140        $extensionData = [
141            'languagecode' => $code,
142            'messagegroupid' => $page->getMessageGroupId(),
143            'sourcepagetitle' => [
144                'namespace' => $page->getTitle()->getNamespace(),
145                'dbkey' => $page->getTitle()->getDBkey()
146            ]
147        ];
148
149        $wikitextParser->getOutput()->setExtensionData( 'translate-translation-page', $extensionData );
150        // Disable edit section links
151        $wikitextParser->getOutput()->setExtensionData( 'Translate-noeditsection', true );
152    }
153
154    /**
155     * Hook: ParserBeforePreprocess
156     * @param Parser $wikitextParser
157     * @param string &$text
158     * @param-taint $text escapes_htmlnoent
159     * @param mixed $state
160     */
161    public static function preprocessTagPage( $wikitextParser, &$text, $state ): void {
162        $translatablePageParser = Services::getInstance()->getTranslatablePageParser();
163
164        if ( $translatablePageParser->containsMarkup( $text ) ) {
165            try {
166                $parserOutput = $translatablePageParser->parse( $text );
167                // If parsing succeeds, replace text and add styles
168                $text = $parserOutput->sourcePageTextForRendering(
169                    $wikitextParser->getTargetLanguage()
170                );
171                $wikitextParser->getOutput()->addModuleStyles( [
172                    'ext.translate',
173                ] );
174            } catch ( ParsingFailure $e ) {
175                wfDebug( 'ParsingFailure caught; expected' );
176            }
177        } else {
178            // If the text doesn't contain <translate> markup, it can still contain <tvar> in the
179            // context of a Parsoid template expansion sub-pipeline. We strip these as well.
180            $unit = new TranslationUnit( $text );
181            $text = $unit->getTextForTrans();
182        }
183    }
184
185    /**
186     * Hook: ParserOutputPostCacheTransform
187     * @param ParserOutput $out
188     * @param string &$text
189     * @param array &$options
190     */
191    public static function onParserOutputPostCacheTransform(
192        ParserOutput $out,
193        &$text,
194        array &$options
195    ) {
196        if ( $out->getExtensionData( 'Translate-noeditsection' ) ) {
197            $options['enableSectionEditLinks'] = false;
198        }
199    }
200
201    /**
202     * This sets &$revRecord to the revision of transcluded page translation if it exists,
203     * or sets it to the source language if the page translation does not exist.
204     * The page translation is chosen based on language of the source page.
205     *
206     * Hook: BeforeParserFetchTemplateRevisionRecord
207     * @param LinkTarget|null $contextLink
208     * @param LinkTarget|null $templateLink
209     * @param bool &$skip
210     * @param RevisionRecord|null &$revRecord
211     */
212    public static function fetchTranslatableTemplateAndTitle(
213        ?LinkTarget $contextLink,
214        ?LinkTarget $templateLink,
215        bool &$skip,
216        ?RevisionRecord &$revRecord
217    ): void {
218        if ( !$templateLink ) {
219            return;
220        }
221
222        $templateTitle = Title::castFromLinkTarget( $templateLink );
223
224        $templateTranslationPage = TranslatablePage::isTranslationPage( $templateTitle );
225        if ( $templateTranslationPage ) {
226            // Template is referring to a translation page, fetch it and incase it doesn't
227            // exist, fetch the source fallback.
228            $revRecord = $templateTranslationPage->getRevisionRecordWithFallback();
229            if ( !$revRecord ) {
230                // In rare cases fetching of the source fallback might fail. See: T323863
231                LoggerFactory::getInstance( 'Translate' )->warning(
232                    "T323863: Could not fetch any revision record for '{groupid}'",
233                    [ 'groupid' => $templateTranslationPage->getMessageGroupId() ]
234                );
235            }
236            return;
237        }
238
239        if ( !TranslatablePage::isSourcePage( $templateTitle ) ) {
240            return;
241        }
242
243        $translatableTemplatePage = TranslatablePage::newFromTitle( $templateTitle );
244
245        if ( !( $translatableTemplatePage->supportsTransclusion() ?? false ) ) {
246            // Page being transcluded does not support language aware transclusion
247            return;
248        }
249
250        $store = MediaWikiServices::getInstance()->getRevisionStore();
251
252        if ( $contextLink ) {
253            // Fetch the context page language, and then check if template is present in that language
254            $templateTranslationTitle = $templateTitle->getSubpage(
255                Title::castFromLinkTarget( $contextLink )->getPageLanguage()->getCode()
256             );
257
258            if ( $templateTranslationTitle ) {
259                if ( $templateTranslationTitle->exists() ) {
260                    // Template is present in the context page language, fetch the revision record and return
261                    $revRecord = $store->getRevisionByTitle( $templateTranslationTitle );
262                } else {
263                    // In case the template has not been translated to the context page language,
264                    // we assign a MutableRevisionRecord in order to add a dependency, so that when
265                    // it is created, the newly created page is loaded rather than the fallback
266                    $revRecord = new MutableRevisionRecord( $templateTranslationTitle );
267                }
268                return;
269            }
270        }
271
272        // Context page information not available OR the template translation title could not be determined.
273        // Fetch and return the RevisionRecord of the template in the source language
274        $sourceTemplateTitle = $templateTitle->getSubpage(
275            $translatableTemplatePage->getMessageGroup()->getSourceLanguage()
276        );
277        if ( $sourceTemplateTitle && $sourceTemplateTitle->exists() ) {
278            $revRecord = $store->getRevisionByTitle( $sourceTemplateTitle );
279        }
280    }
281
282    /**
283     * Set the right page content language for translated pages ("Page/xx").
284     * Hook: PageContentLanguage
285     * @param Title $title
286     * @param Language|StubUserLang|string &$pageLang
287     */
288    public static function onPageContentLanguage( Title $title, &$pageLang ) {
289        // For translation pages, parse plural, grammar etc. with correct language,
290        // and set the right direction
291        if ( TranslatablePage::isTranslationPage( $title ) ) {
292            [ , $code ] = Utilities::figureMessage( $title->getText() );
293            $pageLang = MediaWikiServices::getInstance()->getLanguageFactory()->getLanguage( $code );
294        }
295    }
296
297    /**
298     * Display an edit notice for translatable source pages if it's enabled
299     * Hook: TitleGetEditNotices
300     * @param Title $title
301     * @param int $oldid
302     * @param array &$notices
303     */
304    public static function onTitleGetEditNotices( Title $title, int $oldid, array &$notices ) {
305        if ( TranslatablePage::isSourcePage( $title ) ) {
306            $msg = wfMessage( 'translate-edit-tag-warning' )->inContentLanguage();
307            if ( !$msg->isDisabled() ) {
308                $notices['translate-tag'] = $msg->parseAsBlock();
309            }
310
311            $notices[] = Html::warningBox(
312                wfMessage( 'tps-edit-sourcepage-text' )->parse(),
313                'translate-edit-documentation'
314            );
315
316            // The check is "we're using visual editor for WYSIWYG" (as opposed to "for wikitext
317            // edition") - the message will not be displayed in that case.
318            $request = RequestContext::getMain()->getRequest();
319            if ( $request->getVal( 'action' ) === 'visualeditor' &&
320                $request->getVal( 'paction' ) !== 'wikitext'
321            ) {
322                $notices[] = Html::warningBox(
323                    wfMessage( 'tps-edit-sourcepage-ve-warning-limited-text' )->parse(),
324                    'translate-edit-documentation'
325                );
326            }
327        }
328    }
329
330    /**
331     * Hook: BeforePageDisplay
332     * @param OutputPage $out
333     * @param Skin $skin
334     */
335    public static function onBeforePageDisplay( OutputPage $out, Skin $skin ) {
336        global $wgTranslatePageTranslationULS;
337
338        $title = $out->getTitle();
339        $isSource = TranslatablePage::isSourcePage( $title );
340        $isTranslation = TranslatablePage::isTranslationPage( $title );
341
342        if ( $isSource || $isTranslation ) {
343            if ( $wgTranslatePageTranslationULS ) {
344                $out->addModules( 'ext.translate.pagetranslation.uls' );
345            }
346
347            if ( $isSource ) {
348                // Adding a help notice
349                $out->addModuleStyles( 'ext.translate.edit.documentation.styles' );
350            }
351
352            if ( $isTranslation ) {
353                // Source pages get this module via <translate>, but for translation
354                // pages we need to add it manually.
355                $out->addModuleStyles( 'ext.translate' );
356            }
357
358            $out->addJsConfigVars( 'wgTranslatePageTranslation', $isTranslation ? 'translation' : 'source' );
359        }
360    }
361
362    /**
363     * Hook: onVisualEditorBeforeEditor
364     * @param OutputPage $out
365     * @param Skin $skin
366     * @return bool
367     */
368    public static function onVisualEditorBeforeEditor( OutputPage $out, Skin $skin ) {
369        return !TranslatablePage::isTranslationPage( $out->getTitle() );
370    }
371
372    /**
373     * This is triggered after an edit to translation unit page
374     * @param WikiPage $wikiPage
375     * @param User $user
376     * @param TextContent $content
377     * @param string $summary
378     * @param bool $minor
379     * @param int $flags
380     * @param MessageHandle $handle
381     */
382    public static function onSectionSave(
383        WikiPage $wikiPage,
384        User $user,
385        TextContent $content,
386        $summary,
387        $minor,
388        $flags,
389        MessageHandle $handle
390    ) {
391        // FuzzyBot may do some duplicate work already worked on by other jobs
392        if ( $user->equals( FuzzyBot::getUser() ) ) {
393            return;
394        }
395
396        $group = $handle->getGroup();
397        if ( !$group instanceof WikiPageMessageGroup ) {
398            return;
399        }
400
401        // Finally we know the title and can construct a Translatable page
402        $page = TranslatablePage::newFromTitle( $group->getTitle() );
403
404        // Update the target translation page
405        if ( !$handle->isDoc() ) {
406            $code = $handle->getCode();
407            DeferredUpdates::addCallableUpdate(
408                function () use ( $page, $code, $user, $flags, $summary, $handle ) {
409                    $unitTitle = $handle->getTitle();
410                    self::updateTranslationPage( $page, $code, $user, $flags, $summary, null, $unitTitle );
411                }
412            );
413        }
414    }
415
416    private static function updateTranslationPage(
417        TranslatablePage $page,
418        string $code,
419        User $user,
420        int $flags,
421        string $summary,
422        ?string $triggerAction = null,
423        ?Title $unitTitle = null
424    ): void {
425        $source = $page->getTitle();
426        $target = $source->getSubpage( $code );
427        $mwInstance = MediaWikiServices::getInstance();
428
429        // We don't know and don't care
430        $flags &= ~EDIT_NEW & ~EDIT_UPDATE;
431
432        // Update the target page
433        $unitTitleText = $unitTitle ? $unitTitle->getPrefixedText() : null;
434        $job = RenderTranslationPageJob::newJob( $target, $triggerAction, $unitTitleText );
435        $job->setUser( $user );
436        $job->setSummary( $summary );
437        $job->setFlags( $flags );
438        $mwInstance->getJobQueueGroup()->push( $job );
439
440        // Invalidate caches so that language bar is up-to-date
441        $pages = $page->getTranslationPages();
442        $wikiPageFactory = $mwInstance->getWikiPageFactory();
443        foreach ( $pages as $title ) {
444            if ( $title->equals( $target ) ) {
445                // Handled by the RenderTranslationPageJob
446                continue;
447            }
448
449            $wikiPage = $wikiPageFactory->newFromTitle( $title );
450            $wikiPage->doPurge();
451        }
452        $sourceWikiPage = $wikiPageFactory->newFromTitle( $source );
453        $sourceWikiPage->doPurge();
454    }
455
456    /**
457     * Hook: GetMagicVariableIDs
458     * @param string[] &$variableIDs
459     */
460    public static function onGetMagicVariableIDs( &$variableIDs ): void {
461        $variableIDs[] = 'translatablepage';
462    }
463
464    /**
465     * Hook: ParserGetVariableValueSwitch
466     */
467    public static function onParserGetVariableValueSwitch(
468        Parser $parser,
469        array &$variableCache,
470        string $magicWordId,
471        ?string &$ret,
472        PPFrame $frame
473    ): void {
474        switch ( $magicWordId ) {
475            case 'translatablepage':
476                $title = Title::castFromPageReference( $parser->getPage() );
477                $pageStatus = self::getTranslatablePageStatus( $title );
478                $ret = $pageStatus !== null ? $pageStatus['page']->getTitle()->getPrefixedText() : '';
479                $variableCache[$magicWordId] = $ret;
480                break;
481        }
482    }
483
484    /**
485     * @param string $data
486     * @param array $params
487     * @param Parser $parser
488     * @return string
489     */
490    public static function languages( $data, $params, $parser ) {
491        global $wgPageTranslationLanguageList;
492
493        if ( $wgPageTranslationLanguageList === 'sidebar-only' ) {
494            return '';
495        }
496
497        self::$renderingContext = true;
498        $context = new ScopedCallback( static function () {
499            self::$renderingContext = false;
500        } );
501
502        // Store a property that we can avoid adding language links when
503        // $wgPageTranslationLanguageList === 'sidebar-fallback'
504        $parser->getOutput()->setPageProperty( self::PAGEPROP_HAS_LANGUAGES_TAG, true );
505
506        $currentTitle = $parser->getTitle();
507        $pageStatus = self::getTranslatablePageStatus( $currentTitle );
508        if ( !$pageStatus ) {
509            return '';
510        }
511
512        $page = $pageStatus[ 'page' ];
513        $status = $pageStatus[ 'languages' ];
514        $pageTitle = $page->getTitle();
515
516        // Sort by language code, which seems to be the only sane method
517        ksort( $status );
518
519        // This way the parser knows to fragment the parser cache by language code
520        $userLang = $parser->getOptions()->getUserLangObj();
521        $userLangCode = $userLang->getCode();
522        // Should call $page->getMessageGroup()->getSourceLanguage(), but
523        // group is sometimes null on WMF during page moves, reason unknown.
524        // This should do the same thing for now.
525        $sourceLanguage = $pageTitle->getPageLanguage()->getCode();
526
527        $languages = [];
528        $langFactory = MediaWikiServices::getInstance()->getLanguageFactory();
529        foreach ( $status as $code => $percent ) {
530            // Get autonyms (null)
531            $name = Utilities::getLanguageName( $code, LanguageNameUtils::AUTONYMS );
532
533            // Add links to other languages
534            $suffix = ( $code === $sourceLanguage ) ? '' : "/$code";
535            $targetTitleString = $pageTitle->getDBkey() . $suffix;
536            $subpage = Title::makeTitle( $pageTitle->getNamespace(), $targetTitleString );
537
538            $classes = [];
539            if ( $code === $userLangCode ) {
540                $classes[] = 'mw-pt-languages-ui';
541            }
542
543            $linker = $parser->getLinkRenderer();
544            $lang = $langFactory->getLanguage( $code );
545            if ( $currentTitle->equals( $subpage ) ) {
546                $classes[] = 'mw-pt-languages-selected';
547                $classes = array_merge( $classes, self::tpProgressIcon( (float)$percent ) );
548                $attribs = [
549                    'class' => $classes,
550                    'lang' => $lang->getHtmlCode(),
551                    'dir' => $lang->getDir(),
552                ];
553
554                $contents = Html::element( 'span', $attribs, $name );
555            } elseif ( $subpage->isKnown() ) {
556                $pagename = $page->getPageDisplayTitle( $code );
557                if ( !is_string( $pagename ) ) {
558                    $pagename = $subpage->getPrefixedText();
559                }
560
561                $classes = array_merge( $classes, self::tpProgressIcon( (float)$percent ) );
562
563                $title = wfMessage( 'tpt-languages-nonzero' )
564                    ->page( $parser->getPage() )
565                    ->inLanguage( $userLang )
566                    ->params( $pagename )
567                    ->numParams( 100 * $percent )
568                    ->text();
569                $attribs = [
570                    'title' => $title,
571                    'class' => $classes,
572                    'lang' => $lang->getHtmlCode(),
573                    'dir' => $lang->getDir(),
574                ];
575
576                $contents = $linker->makeKnownLink( $subpage, $name, $attribs );
577            } else {
578                /* When language is included because it is a priority language,
579                 * but translations don't exist link directly to the
580                 * translation view. */
581                $specialTranslateTitle = SpecialPage::getTitleFor( 'Translate' );
582                $params = [
583                    'group' => $page->getMessageGroupId(),
584                    'language' => $code,
585                    'task' => 'view'
586                ];
587
588                $classes[] = 'new'; // For red link color
589
590                $attribs = [
591                    'title' => wfMessage( 'tpt-languages-zero' )
592                        ->page( $parser->getPage() )
593                        ->inLanguage( $userLang )
594                        ->text(),
595                    'class' => $classes,
596                    'lang' => $lang->getHtmlCode(),
597                    'dir' => $lang->getDir(),
598                ];
599                $contents = $linker->makeKnownLink( $specialTranslateTitle, $name, $attribs, $params );
600            }
601            $languages[ $name ] = Html::rawElement( 'li', [], $contents );
602        }
603
604        // Sort languages by autonym
605        ksort( $languages );
606        $languages = array_values( $languages );
607        $languages = implode( "\n", $languages );
608
609        $out = Html::openElement( 'div', [
610            'class' => 'mw-pt-languages noprint navigation-not-searchable',
611            'lang' => $userLang->getHtmlCode(),
612            'dir' => $userLang->getDir()
613        ] );
614        $out .= Html::rawElement( 'div', [ 'class' => 'mw-pt-languages-label' ],
615            wfMessage( 'tpt-languages-legend' )
616                ->page( $parser->getPage() )
617                ->inLanguage( $userLang )
618                ->escaped()
619        );
620        $out .= Html::rawElement(
621            'ul',
622            [ 'class' => 'mw-pt-languages-list' ],
623            $languages
624        );
625        $out .= Html::closeElement( 'div' );
626
627        $parser->getOutput()->addModuleStyles( [
628            'ext.translate.tag.languages',
629        ] );
630
631        return $out;
632    }
633
634    /**
635     * Return icon CSS class for given progress status: percentages
636     * are too accurate and take more space than simple images.
637     * @param float $percent
638     * @return string[]
639     */
640    private static function tpProgressIcon( float $percent ) {
641        $classes = [ 'mw-pt-progress' ];
642        $percent *= 100;
643        if ( $percent < 15 ) {
644            $classes[] = 'mw-pt-progress--low';
645        } elseif ( $percent < 70 ) {
646            $classes[] = 'mw-pt-progress--med';
647        } elseif ( $percent < 100 ) {
648            $classes[] = 'mw-pt-progress--high';
649        } else {
650            $classes[] = 'mw-pt-progress--complete';
651        }
652        return $classes;
653    }
654
655    /**
656     * Returns translatable page and language stats for given title.
657     * @return array{page:TranslatablePage,languages:array}|null Returns null if not a translatable page.
658     */
659    private static function getTranslatablePageStatus( ?Title $title ): ?array {
660        if ( $title === null ) {
661            return null;
662        }
663        // Check if this is a source page or a translation page
664        $page = TranslatablePage::newFromTitle( $title );
665        if ( $page->getMarkedTag() === null ) {
666            $page = TranslatablePage::isTranslationPage( $title );
667        }
668
669        if ( $page === false || $page->getMarkedTag() === null ) {
670            return null;
671        }
672
673        $status = $page->getTranslationPercentages();
674        if ( !$status ) {
675            return null;
676        }
677
678        $messageGroupMetadata = Services::getInstance()->getMessageGroupMetadata();
679        // If priority languages have been set, always show those languages
680        $priorityLanguages = $messageGroupMetadata->get( $page->getMessageGroupId(), 'prioritylangs' );
681        if ( (string)$priorityLanguages !== '' ) {
682            $status += array_fill_keys( explode( ',', $priorityLanguages ), 0 );
683        }
684
685        return [
686            'page' => $page,
687            'languages' => $status
688        ];
689    }
690
691    /**
692     * Hooks: LanguageLinks
693     * @param Title $title Title of the page for which links are needed.
694     * @param array &$languageLinks List of language links to modify.
695     */
696    public static function addLanguageLinks( Title $title, array &$languageLinks ) {
697        global $wgPageTranslationLanguageList;
698
699        if ( $wgPageTranslationLanguageList === 'tag-only' ) {
700            return;
701        }
702
703        if ( $wgPageTranslationLanguageList === 'sidebar-fallback' ) {
704            $pageProps = MediaWikiServices::getInstance()->getPageProps();
705            $languageProp = $pageProps->getProperties( $title, self::PAGEPROP_HAS_LANGUAGES_TAG );
706            if ( $languageProp !== [] ) {
707                return;
708            }
709        }
710
711        // $wgPageTranslationLanguageList === 'sidebar-always' OR 'sidebar-only'
712
713        $status = self::getTranslatablePageStatus( $title );
714        if ( !$status ) {
715            return;
716        }
717
718        self::$renderingContext = true;
719        $context = new ScopedCallback( static function () {
720            self::$renderingContext = false;
721        } );
722
723        $page = $status[ 'page' ];
724        $languages = $status[ 'languages' ];
725        $mwServices = MediaWikiServices::getInstance();
726        $en = $mwServices->getLanguageFactory()->getLanguage( 'en' );
727
728        $newLanguageLinks = [];
729
730        // Batch the Title::exists queries used below
731        $lb = $mwServices->getLinkBatchFactory()->newLinkBatch();
732        foreach ( array_keys( $languages ) as $code ) {
733            $title = $page->getTitle()->getSubpage( $code );
734            $lb->addObj( $title );
735        }
736        $lb->execute();
737        $languageNameUtils = $mwServices->getLanguageNameUtils();
738        foreach ( $languages as $code => $percentage ) {
739            $title = $page->getTitle()->getSubpage( $code );
740            $key = "x-pagetranslation:{$title->getPrefixedText()}";
741            $translatedName = $page->getPageDisplayTitle( $code ) ?: $title->getPrefixedText();
742
743            if ( $title->exists() ) {
744                $href = $title->getLocalURL();
745                $classes = self::tpProgressIcon( (float)$percentage );
746                $title = wfMessage( 'tpt-languages-nonzero' )
747                    ->params( $translatedName )
748                    ->numParams( 100 * $percentage );
749            } else {
750                $href = SpecialPage::getTitleFor( 'Translate' )->getLocalURL( [
751                    'group' => $page->getMessageGroupId(),
752                    'language' => $code,
753                ] );
754                $classes = [ 'mw-pt-progress--none' ];
755                $title = wfMessage( 'tpt-languages-zero' );
756            }
757
758            self::$languageLinkData[ $key ] = [
759                'href' => $href,
760                'language' => $code,
761                'percentage' => $percentage,
762                'classes' => $classes,
763                'autonym' => $en->ucfirst( $languageNameUtils->getLanguageName( $code ) ),
764                'title' => $title,
765            ];
766
767            $newLanguageLinks[ $key ] = self::$languageLinkData[ $key ][ 'autonym' ];
768        }
769
770        asort( $newLanguageLinks );
771        $languageLinks = array_merge( array_keys( $newLanguageLinks ), $languageLinks );
772    }
773
774    /**
775     * Hooks: SkinTemplateGetLanguageLink
776     * @param array &$link
777     * @param Title $linkTitle
778     * @param Title $pageTitle
779     * @param OutputPage $out
780     */
781    public static function formatLanguageLink(
782        array &$link,
783        Title $linkTitle,
784        Title $pageTitle,
785        OutputPage $out
786    ) {
787        if ( !str_starts_with( $link['text'], 'x-pagetranslation:' ) ||
788            !isset( self::$languageLinkData[ $link['text'] ] )
789        ) {
790            return;
791        }
792
793        $data = self::$languageLinkData[ $link[ 'text' ] ];
794
795        $link[ 'class' ] .= ' ' . implode( ' ', $data[ 'classes' ] );
796        $link[ 'href' ] = $data[ 'href' ];
797        $link[ 'text' ] = $data[ 'autonym' ];
798        $link[ 'title' ] = $data[ 'title' ]->inLanguage( $out->getLanguage()->getCode() )->text();
799        $link[ 'lang'] = LanguageCode::bcp47( $data[ 'language' ] );
800        $link[ 'hreflang'] = LanguageCode::bcp47( $data[ 'language' ] );
801
802        $out->addModuleStyles( 'ext.translate.tag.languages' );
803    }
804
805    /**
806     * Display nice error when editing content.
807     * Hook: EditFilterMergedContent
808     * @param IContextSource $context
809     * @param Content $content
810     * @param Status $status
811     * @param string $summary
812     * @return bool
813     */
814    public static function tpSyntaxCheckForEditContent(
815        $context,
816        $content,
817        $status,
818        $summary
819    ) {
820        $syntaxErrorStatus = self::tpSyntaxError( $context->getTitle(), $content );
821
822        if ( $syntaxErrorStatus ) {
823            $status->merge( $syntaxErrorStatus );
824            return $syntaxErrorStatus->isGood();
825        }
826
827        return true;
828    }
829
830    private static function tpSyntaxError( ?PageIdentity $page, ?Content $content ): ?Status {
831        // T163254: Ignore translation markup on non-wikitext pages
832        if ( !$content instanceof WikitextContent || !$page ) {
833            return null;
834        }
835
836        $text = $content->getText();
837
838        // See T154500
839        $text = TextContent::normalizeLineEndings( $text );
840        $status = Status::newGood();
841        $parser = Services::getInstance()->getTranslatablePageParser();
842        if ( $parser->containsMarkup( $text ) ) {
843            try {
844                $parser->parse( $text );
845            } catch ( ParsingFailure $e ) {
846                $status->fatal( ...( $e->getMessageSpecification() ) );
847            }
848        }
849
850        return $status;
851    }
852
853    /**
854     * When attempting to save, last resort. Edit page would only display
855     * edit conflict if there wasn't tpSyntaxCheckForEditPage.
856     * Hook: MultiContentSave
857     * @param RenderedRevision $renderedRevision
858     * @param UserIdentity $user
859     * @param CommentStoreComment $summary
860     * @param int $flags
861     * @param Status $hookStatus
862     * @return bool
863     */
864    public static function tpSyntaxCheck(
865        RenderedRevision $renderedRevision,
866        UserIdentity $user,
867        CommentStoreComment $summary,
868        $flags,
869        Status $hookStatus
870    ) {
871        $content = $renderedRevision->getRevision()->getContent( SlotRecord::MAIN );
872
873        $status = self::tpSyntaxError(
874            $renderedRevision->getRevision()->getPage(),
875            $content
876        );
877
878        if ( $status ) {
879            $hookStatus->merge( $status );
880            return $status->isGood();
881        }
882
883        return true;
884    }
885
886    /**
887     * Hook: PageSaveComplete
888     *
889     * @param WikiPage $wikiPage
890     * @param UserIdentity $userIdentity
891     * @param string $summary
892     * @param int $flags
893     * @param RevisionRecord $revisionRecord
894     * @param EditResult $editResult
895     */
896    public static function addTranstagAfterSave(
897        WikiPage $wikiPage,
898        UserIdentity $userIdentity,
899        string $summary,
900        int $flags,
901        RevisionRecord $revisionRecord,
902        EditResult $editResult
903    ) {
904        $content = $wikiPage->getContent();
905
906        // T163254: Disable page translation on non-wikitext pages
907        if ( $content instanceof WikitextContent ) {
908            $text = $content->getText();
909        } else {
910            // Not applicable
911            return;
912        }
913
914        $parser = Services::getInstance()->getTranslatablePageParser();
915        if ( $parser->containsMarkup( $text ) ) {
916            // Add the ready tag
917            $page = TranslatablePage::newFromTitle( $wikiPage->getTitle() );
918            $page->addReadyTag( $revisionRecord->getId() );
919        }
920
921        // Schedule a deferred status update for the translatable page.
922        $tpStatusUpdater = Services::getInstance()->getTranslatablePageStore();
923        $tpStatusUpdater->performStatusUpdate( $wikiPage->getTitle() );
924    }
925
926    /**
927     * Page moving and page protection (and possibly other things) creates null
928     * revisions. These revisions re-use the previous text already stored in
929     * the database. Those however do not trigger re-parsing of the page and
930     * thus the ready tag is not updated. This watches for new revisions,
931     * checks if they reuse existing text, checks whether the parent version
932     * is the latest version and has a ready tag. If that is the case,
933     * also adds a ready tag for the new revision (which is safe, because
934     * the text hasn't changed). The interface will say that there has been
935     * a change, but shows no change in the content. This lets the user to
936     * re-mark the translations of the page title as outdated (if enabled
937     * for translation).
938     * Hook: RevisionRecordInserted
939     * @param RevisionRecord $rev
940     */
941    public static function updateTranstagOnNullRevisions( RevisionRecord $rev ) {
942        $parentId = $rev->getParentId();
943        if ( $parentId === 0 || $parentId === null ) {
944            // No parent, bail out.
945            return;
946        }
947
948        $prevRev = MediaWikiServices::getInstance()
949            ->getRevisionLookup()
950            ->getRevisionById( $parentId );
951
952        if ( !$prevRev || !$rev->hasSameContent( $prevRev ) ) {
953            // Not a null revision, bail out.
954            return;
955        }
956
957        $title = Title::newFromLinkTarget( $rev->getPageAsLinkTarget() );
958        $bundleFactory = Services::getInstance()->getTranslatableBundleFactory();
959        $bundle = $bundleFactory->getBundle( $title );
960
961        if ( $bundle ) {
962            $bundleStore = $bundleFactory->getStore( $bundle );
963            $bundleStore->handleNullRevisionInsert( $bundle, $rev );
964        }
965    }
966
967    /**
968     * Prevent creation of orphan translation units in Translations namespace.
969     * Hook: getUserPermissionsErrorsExpensive
970     *
971     * @param Title $title
972     * @param User $user
973     * @param string $action
974     * @param mixed &$result
975     * @return bool
976     */
977    public static function onGetUserPermissionsErrorsExpensive(
978        Title $title,
979        User $user,
980        $action,
981        &$result
982    ) {
983        $handle = new MessageHandle( $title );
984
985        if ( !$handle->isPageTranslation() || $action === 'read' ) {
986            return true;
987        }
988
989        $isValid = true;
990        $groupId = null;
991
992        if ( $handle->isValid() ) {
993            $group = $handle->getGroup();
994            $groupId = $group->getId();
995            $permissionTitleCheck = null;
996
997            if ( $group instanceof WikiPageMessageGroup ) {
998                $permissionTitleCheck = $group->getTitle();
999            } elseif ( $group instanceof MessageBundleMessageGroup ) {
1000                // TODO: This check for MessageBundle related permission should be in
1001                // the MessageBundleTranslation/Hook
1002                $permissionTitleCheck = Title::newFromID( $group->getBundlePageId() );
1003            }
1004
1005            if ( $permissionTitleCheck ) {
1006                // Check for blocks
1007                $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
1008                if ( $permissionManager->isBlockedFrom( $user, $permissionTitleCheck ) ) {
1009                    $block = $user->getBlock();
1010                    if ( $block ) {
1011                        $error = new UserBlockedError( $block, $user );
1012                        $errorMessage = $error->getMessageObject();
1013                        $result = array_merge( [ $errorMessage->getKey() ], $errorMessage->getParams() );
1014                        return false;
1015                    }
1016                }
1017            }
1018        }
1019
1020        // Allow editing units that become orphaned in regular use, so that
1021        // people can delete them or fix links or other issues in them.
1022        if ( $action !== 'create' ) {
1023            return true;
1024        }
1025
1026        if ( !$handle->isValid() ) {
1027            // TODO: These checks may no longer be needed
1028            // Sometimes the message index can be out of date. Either the rebuild job failed or
1029            // it just hasn't finished yet. Do a secondary check to make sure we are not
1030            // inconveniencing translators for no good reason.
1031            // See https://phabricator.wikimedia.org/T221119
1032            $statsdDataFactory = MediaWikiServices::getInstance()->getStatsdDataFactory();
1033            $statsdDataFactory->increment( 'translate.slow_translatable_page_check' );
1034            $translatablePage = self::checkTranslatablePageSlow( $title );
1035            if ( $translatablePage ) {
1036                $groupId = $translatablePage->getMessageGroupId();
1037                $statsdDataFactory->increment( 'translate.slow_translatable_page_check_valid' );
1038            } else {
1039                $isValid = false;
1040            }
1041        }
1042
1043        if ( $isValid ) {
1044            $error = self::getTranslationRestrictions( $handle, $groupId );
1045            $result = $error ?: $result;
1046            return $error === [];
1047        }
1048
1049        // Don't allow editing invalid messages that do not belong to any translatable page
1050        LoggerFactory::getInstance( 'Translate' )->info(
1051            'Unknown translation page: {title}',
1052            [ 'title' => $title->getPrefixedDBkey() ]
1053        );
1054        $result = [ 'tpt-unknown-page' ];
1055        return false;
1056    }
1057
1058    private static function checkTranslatablePageSlow( LinkTarget $unit ): ?TranslatablePage {
1059        $parts = TranslatablePage::parseTranslationUnit( $unit );
1060        $translationPageTitle = Title::newFromText(
1061            $parts[ 'sourcepage' ] . '/' . $parts[ 'language' ]
1062        );
1063        if ( !$translationPageTitle ) {
1064            return null;
1065        }
1066
1067        $translatablePage = TranslatablePage::isTranslationPage( $translationPageTitle );
1068        if ( !$translatablePage ) {
1069            return null;
1070        }
1071
1072        $factory = Services::getInstance()->getTranslationUnitStoreFactory();
1073        $store = $factory->getReader( $translatablePage->getTitle() );
1074        $units = $store->getNames();
1075
1076        if ( !in_array( $parts[ 'section' ], $units ) ) {
1077            return null;
1078        }
1079
1080        return $translatablePage;
1081    }
1082
1083    /**
1084     * Prevent editing of restricted languages when prioritized.
1085     *
1086     * @param MessageHandle $handle
1087     * @param string $groupId
1088     * @return array array containing error message if restricted, empty otherwise
1089     */
1090    private static function getTranslationRestrictions( MessageHandle $handle, $groupId ) {
1091        global $wgTranslateDocumentationLanguageCode;
1092
1093        // Allow adding message documentation even when translation is restricted
1094        if ( $handle->getCode() === $wgTranslateDocumentationLanguageCode ) {
1095            return [];
1096        }
1097
1098        $messageGroupMetadata = Services::getInstance()->getMessageGroupMetadata();
1099        // Check if anything is prevented for the group in the first place
1100        $force = $messageGroupMetadata->get( $groupId, 'priorityforce' );
1101        if ( $force !== 'on' ) {
1102            return [];
1103        }
1104
1105        // And finally check whether the language is in the inclusion list
1106        $languages = $messageGroupMetadata->get( $groupId, 'prioritylangs' );
1107        $reason = $messageGroupMetadata->get( $groupId, 'priorityreason' );
1108        if ( !$languages ) {
1109            if ( $reason ) {
1110                return [ 'tpt-translation-restricted-no-priority-languages', $reason ];
1111            }
1112            return [ 'tpt-translation-restricted-no-priority-languages-no-reason' ];
1113        }
1114
1115        $filter = array_flip( explode( ',', $languages ) );
1116        if ( !isset( $filter[$handle->getCode()] ) ) {
1117            if ( $reason ) {
1118                return [ 'tpt-translation-restricted', $reason ];
1119            }
1120
1121            return [ 'tpt-translation-restricted-no-reason' ];
1122        }
1123
1124        return [];
1125    }
1126
1127    /**
1128     * Prevent editing of translation pages directly.
1129     * Hook: getUserPermissionsErrorsExpensive
1130     * @param Title $title
1131     * @param User $user
1132     * @param string $action
1133     * @param bool &$result
1134     * @return bool
1135     */
1136    public static function preventDirectEditing( Title $title, User $user, $action, &$result ) {
1137        if ( self::$allowTargetEdit ) {
1138            return true;
1139        }
1140
1141        $inclusionList = [
1142            'read', 'deletedtext', 'deletedhistory',
1143            'deleterevision', 'suppressrevision', 'viewsuppressed', // T286884
1144            'review', // FlaggedRevs
1145            'patrol', // T151172
1146        ];
1147        $needsPageTranslationRight = in_array( $action, [ 'delete', 'undelete' ] );
1148        if ( in_array( $action, $inclusionList ) ||
1149            ( $needsPageTranslationRight && $user->isAllowed( 'pagetranslation' ) )
1150        ) {
1151            return true;
1152        }
1153
1154        $page = TranslatablePage::isTranslationPage( $title );
1155        if ( $page !== false && $page->getMarkedTag() ) {
1156            if ( $needsPageTranslationRight ) {
1157                $result = User::newFatalPermissionDeniedStatus( 'pagetranslation' )->getMessage();
1158                return false;
1159            }
1160
1161            [ , $code ] = Utilities::figureMessage( $title->getText() );
1162            $mwService = MediaWikiServices::getInstance();
1163
1164            $translationUrl = $mwService->getUrlUtils()->expand(
1165                $page->getTranslationUrl( $code ), PROTO_RELATIVE
1166            );
1167
1168            $result = [
1169                'tpt-target-page',
1170                ':' . $page->getTitle()->getPrefixedText(),
1171                // This url shouldn't get cached
1172                $translationUrl
1173            ];
1174
1175            return false;
1176        }
1177
1178        return true;
1179    }
1180
1181    /**
1182     * Redirects the delete action to our own for translatable pages.
1183     * Hook: ArticleConfirmDelete
1184     *
1185     * @param Article $article
1186     * @param OutputPage $out
1187     * @param string &$reason
1188     *
1189     * @return bool
1190     */
1191    public static function disableDelete( $article, $out, &$reason ) {
1192        $title = $article->getTitle();
1193        $bundle = Services::getInstance()->getTranslatableBundleFactory()->getBundle( $title );
1194        $isDeletableBundle = $bundle && $bundle->isDeletable();
1195        if ( $isDeletableBundle || TranslatablePage::isTranslationPage( $title ) ) {
1196            $new = SpecialPage::getTitleFor(
1197                'PageTranslationDeletePage',
1198                $title->getPrefixedText()
1199            );
1200            $out->redirect( $new->getFullURL() );
1201        }
1202
1203        return true;
1204    }
1205
1206    /**
1207     * Hook: ArticleViewHeader
1208     *
1209     * @param Article $article
1210     * @param bool|ParserOutput|null &$outputDone
1211     * @param bool &$pcache
1212     */
1213    public static function translatablePageHeader( $article, &$outputDone, &$pcache ) {
1214        if ( $article->getOldID() ) {
1215            return;
1216        }
1217
1218        $transPage = TranslatablePage::isTranslationPage( $article->getTitle() );
1219        $context = $article->getContext();
1220        if ( $transPage ) {
1221            self::translationPageHeader( $context, $transPage );
1222        } else {
1223            // Check for pages that are tagged or marked
1224            self::sourcePageHeader( $context );
1225        }
1226    }
1227
1228    private static function sourcePageHeader( IContextSource $context ) {
1229        $linker = MediaWikiServices::getInstance()->getLinkRenderer();
1230
1231        $language = $context->getLanguage();
1232        $title = $context->getTitle();
1233
1234        $page = TranslatablePage::newFromTitle( $title );
1235
1236        $marked = $page->getMarkedTag();
1237        $ready = $page->getReadyTag();
1238        $latest = $title->getLatestRevID();
1239
1240        $actions = [];
1241        if ( $marked && $context->getUser()->isAllowed( 'translate' ) ) {
1242            $actions[] = self::getTranslateLink( $context, $page, null );
1243        }
1244
1245        $hasChanges = $ready === $latest && $marked !== $latest;
1246        if ( $hasChanges ) {
1247            $diffUrl = $title->getFullURL( [ 'oldid' => $marked, 'diff' => $latest ] );
1248
1249            if ( $context->getUser()->isAllowed( 'pagetranslation' ) ) {
1250                $pageTranslation = SpecialPage::getTitleFor( 'PageTranslation' );
1251                $params = [ 'target' => $title->getPrefixedText(), 'do' => 'mark' ];
1252
1253                if ( $marked === null ) {
1254                    // This page has never been marked
1255                    $linkDesc = $context->msg( 'translate-tag-markthis' )->text();
1256                    $actions[] = $linker->makeKnownLink( $pageTranslation, $linkDesc, [], $params );
1257                } else {
1258                    $markUrl = $pageTranslation->getFullURL( $params );
1259                    $actions[] = $context->msg( 'translate-tag-markthisagain', $diffUrl, $markUrl )
1260                        ->parse();
1261                }
1262            } else {
1263                $actions[] = $context->msg( 'translate-tag-hasnew', $diffUrl )->parse();
1264            }
1265        }
1266
1267        if ( !count( $actions ) ) {
1268            return;
1269        }
1270
1271        $header = Html::rawElement(
1272            'div',
1273            [
1274                'class' => 'mw-pt-translate-header noprint nomobile',
1275                'dir' => $language->getDir(),
1276                'lang' => $language->getHtmlCode(),
1277            ],
1278            $language->semicolonList( $actions )
1279        );
1280
1281        $context->getOutput()->addHTML( $header );
1282    }
1283
1284    private static function getTranslateLink(
1285        IContextSource $context,
1286        TranslatablePage $page,
1287        ?string $langCode
1288    ): string {
1289        $linker = MediaWikiServices::getInstance()->getLinkRenderer();
1290
1291        return $linker->makeKnownLink(
1292                SpecialPage::getTitleFor( 'Translate' ),
1293                $context->msg( 'translate-tag-translate-link-desc' )->text(),
1294                [],
1295                [
1296                    'group' => $page->getMessageGroupId(),
1297                    'language' => $langCode,
1298                    'action' => 'page',
1299                    'filter' => '',
1300                ]
1301            );
1302    }
1303
1304    private static function translationPageHeader( IContextSource $context, TranslatablePage $page ) {
1305        global $wgTranslateKeepOutdatedTranslations;
1306
1307        $title = $context->getTitle();
1308        if ( !$title->exists() ) {
1309            return;
1310        }
1311
1312        [ , $code ] = Utilities::figureMessage( $title->getText() );
1313
1314        // Get the translation percentage
1315        $pers = $page->getTranslationPercentages();
1316        $per = 0;
1317        if ( isset( $pers[$code] ) ) {
1318            $per = $pers[$code] * 100;
1319        }
1320
1321        $language = $context->getLanguage();
1322        $output = $context->getOutput();
1323
1324        if ( $page->getSourceLanguageCode() === $code ) {
1325            // If we are on the source language page, link to translate for user's language
1326            $msg = self::getTranslateLink( $context, $page, $language->getCode() );
1327        } else {
1328            $mwService = MediaWikiServices::getInstance();
1329
1330            $translationUrl = $mwService->getUrlUtils()->expand(
1331                $page->getTranslationUrl( $code ), PROTO_RELATIVE
1332            );
1333
1334            $msg = $context->msg( 'tpt-translation-intro',
1335                $translationUrl,
1336                ':' . $page->getTitle()->getPrefixedText(),
1337                $language->formatNum( $per )
1338            )->parse();
1339        }
1340
1341        $header = Html::rawElement(
1342            'div',
1343            [
1344                'class' => 'mw-pt-translate-header noprint',
1345                'dir' => $language->getDir(),
1346                'lang' => $language->getHtmlCode(),
1347            ],
1348            $msg
1349        );
1350
1351        $output->addHTML( $header );
1352
1353        if ( $wgTranslateKeepOutdatedTranslations ) {
1354            $groupId = $page->getMessageGroupId();
1355            // This is already calculated and cached by above call to getTranslationPercentages
1356            $stats = MessageGroupStats::forItem( $groupId, $code );
1357            if ( $stats[MessageGroupStats::FUZZY] ) {
1358                // Only show if there is fuzzy messages
1359                $wrap = Html::rawElement(
1360                    'div',
1361                    [
1362                        'class' => 'mw-pt-translate-header',
1363                        'dir' => $language->getDir(),
1364                        'lang' => $language->getHtmlCode()
1365                    ],
1366                    '<span class="mw-translate-fuzzy">$1</span>'
1367                );
1368
1369                $output->wrapWikiMsg( $wrap, [ 'tpt-translation-intro-fuzzy' ] );
1370            }
1371        }
1372    }
1373
1374    /**
1375     * Hook: SpecialPage_initList
1376     * @param array &$list
1377     */
1378    public static function replaceMovePage( &$list ) {
1379        $movePageSpec = $list['Movepage'] ?? null;
1380
1381        // This should never happen, but apparently is happening? See: T296568
1382        if ( $movePageSpec === null ) {
1383            return;
1384        }
1385
1386        $list['Movepage'] = [
1387            'class' => MoveTranslatableBundleSpecialPage::class,
1388            'services' => [
1389                'ObjectFactory',
1390                'PermissionManager',
1391                'Translate:TranslatableBundleMover',
1392                'Translate:TranslatableBundleFactory'
1393            ],
1394            'args' => [
1395                $movePageSpec
1396            ]
1397        ];
1398    }
1399
1400    /**
1401     * Hook: getUserPermissionsErrorsExpensive
1402     * @param Title $title
1403     * @param User $user
1404     * @param string $action
1405     * @param mixed &$result
1406     * @return bool
1407     */
1408    public static function lockedPagesCheck( Title $title, User $user, $action, &$result ) {
1409        if ( $action === 'read' ) {
1410            return true;
1411        }
1412
1413        $cache = ObjectCache::getInstance( CACHE_ANYTHING );
1414        $key = $cache->makeKey( 'pt-lock', sha1( $title->getPrefixedText() ) );
1415        if ( $cache->get( $key ) === 'locked' ) {
1416            $result = [ 'pt-locked-page' ];
1417
1418            return false;
1419        }
1420
1421        return true;
1422    }
1423
1424    /**
1425     * Hook: SkinSubPageSubtitle
1426     * @param array &$subpages
1427     * @param ?Skin $skin
1428     * @param OutputPage $out
1429     * @return bool
1430     */
1431    public static function replaceSubtitle( &$subpages, ?Skin $skin, OutputPage $out ) {
1432        $linker = MediaWikiServices::getInstance()->getLinkRenderer();
1433
1434        $isTranslationPage = TranslatablePage::isTranslationPage( $out->getTitle() );
1435        if ( !$isTranslationPage
1436            && !TranslatablePage::isSourcePage( $out->getTitle() )
1437        ) {
1438            return true;
1439        }
1440
1441        // Copied from Skin::subPageSubtitle()
1442        $nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
1443        if (
1444            $out->isArticle() &&
1445            $nsInfo->hasSubpages( $out->getTitle()->getNamespace() )
1446        ) {
1447            $ptext = $out->getTitle()->getPrefixedText();
1448            $links = explode( '/', $ptext );
1449            if ( count( $links ) > 1 ) {
1450                array_pop( $links );
1451                if ( $isTranslationPage ) {
1452                    // Also remove language code page
1453                    array_pop( $links );
1454                }
1455                $c = 0;
1456                $growinglink = '';
1457                $display = '';
1458                $lang = $skin->getLanguage();
1459
1460                foreach ( $links as $link ) {
1461                    $growinglink .= $link;
1462                    $display .= $link;
1463                    $linkObj = Title::newFromText( $growinglink );
1464
1465                    if ( $linkObj && $linkObj->isKnown() ) {
1466                        $getlink = $linker->makeKnownLink(
1467                            SpecialPage::getTitleFor( 'MyLanguage', $growinglink ),
1468                            $display
1469                        );
1470
1471                        $c++;
1472
1473                        if ( $c > 1 ) {
1474                            $subpages .= $lang->getDirMarkEntity() . $skin->msg( 'pipe-separator' )->escaped();
1475                        } else {
1476                            $subpages .= '&lt; ';
1477                        }
1478
1479                        $subpages .= $getlink;
1480                        $display = '';
1481                    } else {
1482                        $display .= '/';
1483                    }
1484
1485                    $growinglink .= '/';
1486                }
1487            }
1488
1489            return false;
1490        }
1491
1492        return true;
1493    }
1494
1495    /**
1496     * Converts the edit tab (if exists) for translation pages to translate tab.
1497     * Hook: SkinTemplateNavigation::Universal
1498     * @param Skin $skin
1499     * @param array &$tabs
1500     */
1501    public static function translateTab( Skin $skin, array &$tabs ) {
1502        $title = $skin->getTitle();
1503        $handle = new MessageHandle( $title );
1504        $code = $handle->getCode();
1505        $page = TranslatablePage::isTranslationPage( $title );
1506        // The source language has a subpage too, but cannot be translated
1507        if ( !$page || $page->getSourceLanguageCode() === $code ) {
1508            return;
1509        }
1510
1511        $user = $skin->getUser();
1512        if ( isset( $tabs['views']['edit'] ) ) {
1513            // There is an edit tab, just replace its text and URL with ours, keeping the tooltip and access key
1514            $tabs['views']['edit']['text'] = $skin->msg( 'tpt-tab-translate' )->text();
1515            $tabs['views']['edit']['href'] = $page->getTranslationUrl( $code );
1516        } elseif ( $user->isAllowed( 'translate' ) ) {
1517            $mwInstance = MediaWikiServices::getInstance();
1518            $namespaceProtection = $mwInstance->getMainConfig()->get( MainConfigNames::NamespaceProtection );
1519            $permissionManager = $mwInstance->getPermissionManager();
1520            if (
1521                !$permissionManager->userHasAllRights(
1522                    $user, ...(array)( $namespaceProtection[ NS_TRANSLATIONS ] ?? [] )
1523                )
1524            ) {
1525                return;
1526            }
1527
1528            $tab = [
1529                'text' => $skin->msg( 'tpt-tab-translate' )->text(),
1530                'href' => $page->getTranslationUrl( $code ),
1531            ];
1532
1533            // Get the position of the viewsource tab within the array (if any)
1534            $viewsourcePos = array_keys( array_keys( $tabs['views'] ), 'viewsource', true )[0] ?? null;
1535
1536            if ( $viewsourcePos !== null ) {
1537                // Remove the viewsource tab and insert the translate tab at its place. Showing the tooltip
1538                // of the viewsource tab for the translate tab would be confusing.
1539                array_splice( $tabs['views'], $viewsourcePos, 1, [ 'translate' => $tab ] );
1540            } else {
1541                // We have neither an edit tab nor a viewsource tab to replace with the translate tab,
1542                // put the translate tab at the end
1543                $tabs['views']['translate'] = $tab;
1544            }
1545        }
1546    }
1547
1548    /**
1549     * Hook to update source and destination translation pages on moving translation units
1550     * Hook: PageMoveComplete
1551     *
1552     * @param LinkTarget $oldLinkTarget
1553     * @param LinkTarget $newLinkTarget
1554     * @param UserIdentity $userIdentity
1555     * @param int $oldid
1556     * @param int $newid
1557     * @param string $reason
1558     * @param RevisionRecord $revisionRecord
1559     */
1560    public static function onMovePageTranslationUnits(
1561        LinkTarget $oldLinkTarget,
1562        LinkTarget $newLinkTarget,
1563        UserIdentity $userIdentity,
1564        int $oldid,
1565        int $newid,
1566        string $reason,
1567        RevisionRecord $revisionRecord
1568    ) {
1569        $user = MediaWikiServices::getInstance()->getUserFactory()->newFromUserIdentity( $userIdentity );
1570        // MoveTranslatableBundleJob takes care of handling updates because it performs
1571        // a lot of moves at once. As a performance optimization, skip this hook if
1572        // we detect moves from that job. As there isn't a good way to pass information
1573        // to this hook what originated the move, we use some heuristics.
1574        if ( defined( 'MEDIAWIKI_JOB_RUNNER' ) && $user->equals( FuzzyBot::getUser() ) ) {
1575            return;
1576        }
1577
1578        $oldTitle = Title::newFromLinkTarget( $oldLinkTarget );
1579        $newTitle = Title::newFromLinkTarget( $newLinkTarget );
1580        $groupLast = null;
1581        foreach ( [ $oldTitle, $newTitle ] as $title ) {
1582            $handle = new MessageHandle( $title );
1583            // Documentation pages are never translation pages
1584            if ( !$handle->isValid() || $handle->isDoc() ) {
1585                continue;
1586            }
1587
1588            $group = $handle->getGroup();
1589            if ( !$group instanceof WikiPageMessageGroup ) {
1590                continue;
1591            }
1592
1593            $language = $handle->getCode();
1594
1595            // Ignore pages such as Translations:Page/unit without language code
1596            if ( $language === '' ) {
1597                continue;
1598            }
1599
1600            // Update the page only once if source and destination units
1601            // belong to the same page
1602            if ( $group !== $groupLast ) {
1603                $groupLast = $group;
1604                $page = TranslatablePage::newFromTitle( $group->getTitle() );
1605                self::updateTranslationPage( $page, $language, $user, 0, $reason );
1606            }
1607        }
1608    }
1609
1610    /**
1611     * Hook to update translation page on deleting a translation unit
1612     * Hook: ArticleDeleteComplete
1613     * @param WikiPage $unit
1614     * @param User $user
1615     * @param string $reason
1616     * @param int $id
1617     * @param Content $content
1618     * @param ManualLogEntry $logEntry
1619     */
1620    public static function onDeleteTranslationUnit(
1621        WikiPage $unit,
1622        User $user,
1623        $reason,
1624        $id,
1625        $content,
1626        $logEntry
1627    ) {
1628        $title = $unit->getTitle();
1629        $handle = new MessageHandle( $title );
1630        if ( !$handle->isValid() ) {
1631            return;
1632        }
1633
1634        $group = $handle->getGroup();
1635        if ( !$group instanceof WikiPageMessageGroup ) {
1636            return;
1637        }
1638
1639        $mwServices = MediaWikiServices::getInstance();
1640        // During deletions this may cause creation of a lot of duplicate jobs. It is expected that
1641        // job queue will deduplicate them to reduce the number of jobs actually run.
1642        $mwServices->getJobQueueGroup()->push(
1643            RebuildMessageGroupStatsJob::newRefreshGroupsJob( [ $group->getId() ] )
1644        );
1645
1646        // Logic to update translation pages, skipped if we are in a middle of a deletion
1647        if ( self::$isDeleteTranslatableBundleJobRunning ) {
1648            return;
1649        }
1650
1651        $target = $group->getTitle();
1652        $langCode = $handle->getCode();
1653        $fname = __METHOD__;
1654
1655        $dbw = $mwServices->getDBLoadBalancer()->getConnection( DB_PRIMARY );
1656        $callback = function () use (
1657            $dbw,
1658            $target,
1659            $handle,
1660            $langCode,
1661            $user,
1662            $reason,
1663            $fname
1664        ) {
1665            $translationPageTitle = $target->getSubpage( $langCode );
1666            // Do a more thorough check for the translation page in case the translation page is deleted in a
1667            // different transaction.
1668            if ( !$translationPageTitle || !$translationPageTitle->exists( IDBAccessObject::READ_LATEST ) ) {
1669                return;
1670            }
1671
1672            $dbw->startAtomic( $fname );
1673
1674            $page = TranslatablePage::newFromTitle( $target );
1675
1676            if ( !$handle->isDoc() ) {
1677                $unitTitle = $handle->getTitle();
1678                // Assume that $user and $reason for the first deletion is the same for all
1679                self::updateTranslationPage(
1680                    $page, $langCode, $user, 0, $reason, RenderTranslationPageJob::ACTION_DELETE, $unitTitle
1681                );
1682            }
1683
1684            $dbw->endAtomic( $fname );
1685        };
1686
1687        $dbw->onTransactionCommitOrIdle( $callback, __METHOD__ );
1688    }
1689
1690    /**
1691     * Removes translation pages from the list of page titles to be edited
1692     * Hook: ReplaceTextFilterPageTitlesForEdit
1693     */
1694    public static function onReplaceTextFilterPageTitlesForEdit( array &$titles ): void {
1695        foreach ( $titles as $index => $title ) {
1696            $handle = new MessageHandle( $title );
1697            if ( Utilities::isTranslationPage( $handle ) ) {
1698                unset( $titles[ $index ] );
1699            }
1700        }
1701    }
1702
1703    /**
1704     * Removes translatable and translation pages from the list of titles to be renamed
1705     * Hook: ReplaceTextFilterPageTitlesForRename
1706     */
1707    public static function onReplaceTextFilterPageTitlesForRename( array &$titles ): void {
1708        foreach ( $titles as $index => $title ) {
1709            $handle = new MessageHandle( $title );
1710            if (
1711                TranslatablePage::isSourcePage( $title ) ||
1712                Utilities::isTranslationPage( $handle )
1713            ) {
1714                unset( $titles[ $index ] );
1715            }
1716        }
1717    }
1718}