diff --git a/Translate.i18n.magic.php b/Translate.i18n.magic.php index 891c11e70..c319d5697 100644 --- a/Translate.i18n.magic.php +++ b/Translate.i18n.magic.php @@ -1,19 +1,20 @@ [ 0, 'translation' ], + 'translatablepage' => [ 1, 'TRANSLATABLEPAGE' ], ]; /** Urdu (اردو) */ $magicWords['ur'] = [ 'translation' => [ 0, 'ترجمہ', 'translation' ], ]; diff --git a/src/HookHandler.php b/src/HookHandler.php index 532de3ba0..0368def8a 100644 --- a/src/HookHandler.php +++ b/src/HookHandler.php @@ -1,1042 +1,1046 @@ 'ts_user', 'translate_reviews' => 'trr_user', ]; private RevisionLookup $revisionLookup; private ILoadBalancer $loadBalancer; private Config $config; public function __construct( RevisionLookup $revisionLookup, ILoadBalancer $loadBalancer, Config $config ) { $this->revisionLookup = $revisionLookup; $this->loadBalancer = $loadBalancer; $this->config = $config; } /** Do late setup that depends on configuration. */ public static function setupTranslate(): void { global $wgTranslateYamlLibrary; $hooks = []; /* * Text that will be shown in translations if the translation is outdated. * Must be something that does not conflict with actual content. */ if ( !defined( 'TRANSLATE_FUZZY' ) ) { define( 'TRANSLATE_FUZZY', '!!FUZZY!!' ); } if ( $wgTranslateYamlLibrary === null ) { $wgTranslateYamlLibrary = function_exists( 'yaml_parse' ) ? 'phpyaml' : 'spyc'; } $hooks['PageSaveComplete'][] = [ TranslateEditAddons::class, 'onSaveComplete' ]; // Page translation setup check and init if enabled. global $wgEnablePageTranslation; if ( $wgEnablePageTranslation ) { // Special page and the right to use it global $wgSpecialPages, $wgAvailableRights; $wgSpecialPages['PageTranslation'] = [ 'class' => PageTranslationSpecialPage::class, 'services' => [ 'LanguageNameUtils', 'LanguageFactory', 'Translate:TranslationUnitStoreFactory', 'Translate:TranslatablePageParser', 'LinkBatchFactory', 'JobQueueGroup', 'DBLoadBalancer', 'Translate:MessageIndex' ] ]; $wgSpecialPages['PageTranslationDeletePage'] = [ 'class' => DeleteTranslatableBundleSpecialPage::class, 'services' => [ 'MainObjectStash', 'PermissionManager', 'Translate:TranslatableBundleFactory', 'Translate:SubpageListBuilder', 'JobQueueGroup', ] ]; // right-pagetranslation action-pagetranslation $wgAvailableRights[] = 'pagetranslation'; $wgSpecialPages['PageMigration'] = MigrateTranslatablePageSpecialPage::class; $wgSpecialPages['PagePreparation'] = PrepareTranslatablePageSpecialPage::class; global $wgActionFilteredLogs, $wgLogActionsHandlers, $wgLogTypes; // log-description-pagetranslation log-name-pagetranslation logentry-pagetranslation-mark // logentry-pagetranslation-unmark logentry-pagetranslation-moveok // logentry-pagetranslation-movenok logentry-pagetranslation-deletefok // logentry-pagetranslation-deletefnok logentry-pagetranslation-deletelok // logentry-pagetranslation-deletelnok logentry-pagetranslation-encourage // logentry-pagetranslation-discourage logentry-pagetranslation-prioritylanguages // logentry-pagetranslation-associate logentry-pagetranslation-dissociate $wgLogTypes[] = 'pagetranslation'; $wgLogActionsHandlers['pagetranslation/mark'] = TranslatableBundleLogFormatter::class; $wgLogActionsHandlers['pagetranslation/unmark'] = TranslatableBundleLogFormatter::class; $wgLogActionsHandlers['pagetranslation/moveok'] = TranslatableBundleLogFormatter::class; $wgLogActionsHandlers['pagetranslation/movenok'] = TranslatableBundleLogFormatter::class; $wgLogActionsHandlers['pagetranslation/deletelok'] = TranslatableBundleLogFormatter::class; $wgLogActionsHandlers['pagetranslation/deletefok'] = TranslatableBundleLogFormatter::class; $wgLogActionsHandlers['pagetranslation/deletelnok'] = TranslatableBundleLogFormatter::class; $wgLogActionsHandlers['pagetranslation/deletefnok'] = TranslatableBundleLogFormatter::class; $wgLogActionsHandlers['pagetranslation/encourage'] = TranslatableBundleLogFormatter::class; $wgLogActionsHandlers['pagetranslation/discourage'] = TranslatableBundleLogFormatter::class; $wgLogActionsHandlers['pagetranslation/prioritylanguages'] = TranslatableBundleLogFormatter::class; $wgLogActionsHandlers['pagetranslation/associate'] = TranslatableBundleLogFormatter::class; $wgLogActionsHandlers['pagetranslation/dissociate'] = TranslatableBundleLogFormatter::class; $wgActionFilteredLogs['pagetranslation'] = [ 'mark' => [ 'mark' ], 'unmark' => [ 'unmark' ], 'move' => [ 'moveok', 'movenok' ], 'delete' => [ 'deletefok', 'deletefnok', 'deletelok', 'deletelnok' ], 'encourage' => [ 'encourage' ], 'discourage' => [ 'discourage' ], 'prioritylanguages' => [ 'prioritylanguages' ], 'aggregategroups' => [ 'associate', 'dissociate' ], ]; $wgLogTypes[] = 'messagebundle'; $wgLogActionsHandlers['messagebundle/moveok'] = TranslatableBundleLogFormatter::class; $wgLogActionsHandlers['messagebundle/movenok'] = TranslatableBundleLogFormatter::class; $wgLogActionsHandlers['messagebundle/deletefok'] = TranslatableBundleLogFormatter::class; $wgLogActionsHandlers['messagebundle/deletefnok'] = TranslatableBundleLogFormatter::class; $wgActionFilteredLogs['messagebundle'] = [ 'move' => [ 'moveok', 'movenok' ], 'delete' => [ 'deletefok', 'deletefnok' ], ]; global $wgJobClasses; $wgJobClasses['RenderTranslationPageJob'] = RenderTranslationPageJob::class; // Remove after MLEB 2022.10 release $wgJobClasses['TranslateRenderJob'] = RenderTranslationPageJob::class; // Remove after MLEB 2022.07 release $wgJobClasses['TranslatableBundleMoveJob'] = MoveTranslatableBundleJob::class; $wgJobClasses['MoveTranslatableBundleJob'] = MoveTranslatableBundleJob::class; // Remove after MLEB 2022.07 release $wgJobClasses['TranslatableBundleDeleteJob'] = DeleteTranslatableBundleJob::class; $wgJobClasses['DeleteTranslatableBundleJob'] = DeleteTranslatableBundleJob::class; $wgJobClasses['UpdateTranslatablePageJob'] = UpdateTranslatablePageJob::class; // Remove after MLEB 2022.10 release $wgJobClasses['TranslationsUpdateJob'] = UpdateTranslatablePageJob::class; // Namespaces global $wgNamespacesWithSubpages, $wgNamespaceProtection; global $wgTranslateMessageNamespaces; $wgNamespacesWithSubpages[NS_TRANSLATIONS] = true; $wgNamespacesWithSubpages[NS_TRANSLATIONS_TALK] = true; // Standard protection and register it for filtering $wgNamespaceProtection[NS_TRANSLATIONS] = [ 'translate' ]; $wgTranslateMessageNamespaces[] = NS_TRANSLATIONS; /// Page translation hooks /// Register our CSS and metadata $hooks['BeforePageDisplay'][] = [ Hooks::class, 'onBeforePageDisplay' ]; // Disable VE $hooks['VisualEditorBeforeEditor'][] = [ Hooks::class, 'onVisualEditorBeforeEditor' ]; // Check syntax for \ $hooks['MultiContentSave'][] = [ Hooks::class, 'tpSyntaxCheck' ]; $hooks['EditFilterMergedContent'][] = [ Hooks::class, 'tpSyntaxCheckForEditContent' ]; // Add transtag to page props for discovery $hooks['PageSaveComplete'][] = [ Hooks::class, 'addTranstagAfterSave' ]; $hooks['RevisionRecordInserted'][] = [ Hooks::class, 'updateTranstagOnNullRevisions' ]; // Register different ways to show language links $hooks['ParserFirstCallInit'][] = [ self::class, 'setupParserHooks' ]; $hooks['LanguageLinks'][] = [ Hooks::class, 'addLanguageLinks' ]; $hooks['SkinTemplateGetLanguageLink'][] = [ Hooks::class, 'formatLanguageLink' ]; + // Allow templates to query whether they are transcluded in a translatable/translated page + $hooks['GetMagicVariableIDs'][] = [ Hooks::class, 'onGetMagicVariableIDs' ]; + $hooks['ParserGetVariableValueSwitch'][] = [ Hooks::class, 'onParserGetVariableValueSwitch' ]; + // Strip \ tags etc. from source pages when rendering $hooks['ParserBeforeInternalParse'][] = [ Hooks::class, 'renderTagPage' ]; // Strip \ tags etc. from source pages when preprocessing $hooks['ParserBeforePreprocess'][] = [ Hooks::class, 'preprocessTagPage' ]; $hooks['ParserOutputPostCacheTransform'][] = [ Hooks::class, 'onParserOutputPostCacheTransform' ]; $hooks['BeforeParserFetchTemplateRevisionRecord'][] = [ Hooks::class, 'fetchTranslatableTemplateAndTitle' ]; // Set the page content language $hooks['PageContentLanguage'][] = [ Hooks::class, 'onPageContentLanguage' ]; // Prevent editing of certain pages in translations namespace $hooks['getUserPermissionsErrorsExpensive'][] = [ Hooks::class, 'onGetUserPermissionsErrorsExpensive' ]; // Prevent editing of translation pages directly $hooks['getUserPermissionsErrorsExpensive'][] = [ Hooks::class, 'preventDirectEditing' ]; // Our custom header for translation pages $hooks['ArticleViewHeader'][] = [ Hooks::class, 'translatablePageHeader' ]; // Edit notice shown on translatable pages $hooks['TitleGetEditNotices'][] = [ Hooks::class, 'onTitleGetEditNotices' ]; // Custom move page that can move all the associated pages too $hooks['SpecialPage_initList'][] = [ Hooks::class, 'replaceMovePage' ]; // Locking during page moves $hooks['getUserPermissionsErrorsExpensive'][] = [ Hooks::class, 'lockedPagesCheck' ]; // Disable action=delete $hooks['ArticleConfirmDelete'][] = [ Hooks::class, 'disableDelete' ]; // Replace subpage logic behavior $hooks['SkinSubPageSubtitle'][] = [ Hooks::class, 'replaceSubtitle' ]; // Replaced edit tab with translation tab for translation pages $hooks['SkinTemplateNavigation::Universal'][] = [ Hooks::class, 'translateTab' ]; // Update translated page when translation unit is moved $hooks['PageMoveComplete'][] = [ Hooks::class, 'onMovePageTranslationUnits' ]; // Update translated page when translation unit is deleted $hooks['ArticleDeleteComplete'][] = [ Hooks::class, 'onDeleteTranslationUnit' ]; } global $wgTranslateUseSandbox; if ( $wgTranslateUseSandbox ) { global $wgSpecialPages, $wgAvailableRights, $wgDefaultUserOptions; $wgSpecialPages['ManageTranslatorSandbox'] = [ 'class' => ManageTranslatorSandboxSpecialPage::class, 'services' => [ 'Translate:TranslationStashReader', 'UserOptionsLookup' ], 'args' => [ static function () { return new ServiceOptions( ManageTranslatorSandboxSpecialPage::CONSTRUCTOR_OPTIONS, MediaWikiServices::getInstance()->getMainConfig() ); } ] ]; $wgSpecialPages['TranslationStash'] = [ 'class' => TranslationStashSpecialPage::class, 'services' => [ 'LanguageNameUtils', 'Translate:TranslationStashReader', 'UserOptionsLookup', 'LanguageFactory', ], 'args' => [ static function () { return new ServiceOptions( TranslationStashSpecialPage::CONSTRUCTOR_OPTIONS, MediaWikiServices::getInstance()->getMainConfig() ); } ] ]; $wgDefaultUserOptions['translate-sandbox'] = ''; // right-translate-sandboxmanage action-translate-sandboxmanage $wgAvailableRights[] = 'translate-sandboxmanage'; $hooks['GetPreferences'][] = [ TranslateSandbox::class, 'onGetPreferences' ]; $hooks['UserGetRights'][] = [ TranslateSandbox::class, 'enforcePermissions' ]; $hooks['ApiCheckCanExecute'][] = [ TranslateSandbox::class, 'onApiCheckCanExecute' ]; global $wgLogTypes, $wgLogActionsHandlers; // log-name-translatorsandbox log-description-translatorsandbox $wgLogTypes[] = 'translatorsandbox'; // logentry-translatorsandbox-promoted logentry-translatorsandbox-rejected $wgLogActionsHandlers['translatorsandbox/promoted'] = 'TranslateLogFormatter'; $wgLogActionsHandlers['translatorsandbox/rejected'] = 'TranslateLogFormatter'; // This is no longer used for new entries since 2016.07. // logentry-newusers-tsbpromoted $wgLogActionsHandlers['newusers/tsbpromoted'] = 'LogFormatter'; global $wgJobClasses; $wgJobClasses['TranslateSandboxEmailJob'] = 'TranslateSandboxEmailJob'; global $wgAPIModules; $wgAPIModules['translationstash'] = [ 'class' => TranslationStashActionApi::class, 'services' => [ 'DBLoadBalancer', 'UserFactory' ] ]; $wgAPIModules['translatesandbox'] = [ 'class' => TranslatorSandboxActionApi::class, 'services' => [ 'UserFactory', 'UserNameUtils', 'UserOptionsManager', 'WikiPageFactory', 'UserOptionsLookup' ], 'args' => [ static function () { return new ServiceOptions( TranslatorSandboxActionApi::CONSTRUCTOR_OPTIONS, MediaWikiServices::getInstance()->getMainConfig() ); } ] ]; } global $wgNamespaceRobotPolicies; $wgNamespaceRobotPolicies[NS_TRANSLATIONS] = 'noindex'; // If no service has been configured, we use a built-in fallback. global $wgTranslateTranslationDefaultService, $wgTranslateTranslationServices; if ( $wgTranslateTranslationDefaultService === true ) { $wgTranslateTranslationDefaultService = 'TTMServer'; if ( !isset( $wgTranslateTranslationServices['TTMServer'] ) ) { $wgTranslateTranslationServices['TTMServer'] = [ 'database' => false, // Passed to wfGetDB 'cutoff' => 0.75, 'type' => 'ttmserver', 'public' => false, ]; } } $hooks['SidebarBeforeOutput'][] = [ TranslateToolbox::class, 'toolboxAllTranslations' ]; static::registerHookHandlers( $hooks ); } private static function registerHookHandlers( array $hooks ): void { if ( defined( 'MW_PHPUNIT_TEST' ) && MediaWikiServices::hasInstance() ) { // When called from a test case's setUp() method, // we can use HookContainer, but we cannot use SettingsBuilder. $hookContainer = MediaWikiServices::getInstance()->getHookContainer(); foreach ( $hooks as $name => $handlers ) { foreach ( $handlers as $h ) { $hookContainer->register( $name, $h ); } } } elseif ( method_exists( SettingsBuilder::class, 'registerHookHandlers' ) ) { // Since 1.40: Use SettingsBuilder to register hooks during initialization. // HookContainer is not available at this time. $settingsBuilder = SettingsBuilder::getInstance(); $settingsBuilder->registerHookHandlers( $hooks ); } else { // For MW < 1.40: Directly manipulate $wgHooks during initialization. foreach ( $hooks as $name => $handlers ) { $GLOBALS['wgHooks'][$name] = array_merge( $GLOBALS['wgHooks'][$name] ?? [], $handlers ); } } } /** * Hook: UserGetReservedNames * Prevents anyone from registering or logging in as FuzzyBot */ public static function onUserGetReservedNames( array &$names ): void { $names[] = FuzzyBot::getName(); $names[] = TranslateUserManager::getName(); } /** Used for setting an AbuseFilter variable. */ public static function onAbuseFilterAlterVariables( VariableHolder &$vars, Title $title, User $user ): void { $handle = new MessageHandle( $title ); // Only set this variable if we are in a proper namespace to avoid // unnecessary overhead in non-translation pages if ( $handle->isMessageNamespace() ) { $vars->setLazyLoadVar( 'translate_source_text', 'translate-get-source', [ 'handle' => $handle ] ); $vars->setLazyLoadVar( 'translate_target_language', 'translate-get-target-language', [ 'handle' => $handle ] ); } } /** Computes the translate_source_text and translate_target_language AbuseFilter variables */ public static function onAbuseFilterComputeVariable( string $method, VariableHolder $vars, array $parameters, ?string &$result ): bool { if ( $method !== 'translate-get-source' && $method !== 'translate-get-target-language' ) { return true; } $handle = $parameters['handle']; $value = ''; if ( $handle->isValid() ) { if ( $method === 'translate-get-source' ) { $group = $handle->getGroup(); $value = $group->getMessage( $handle->getKey(), $group->getSourceLanguage() ); } else { $value = $handle->getCode(); } } $result = $value; return false; } /** Register AbuseFilter variables provided by Translate. */ public static function onAbuseFilterBuilder( array &$builderValues ): void { // Uses: 'abusefilter-edit-builder-vars-translate-source-text' // and 'abusefilter-edit-builder-vars-translate-target-language' $builderValues['vars']['translate_source_text'] = 'translate-source-text'; $builderValues['vars']['translate_target_language'] = 'translate-target-language'; } /** * Hook: ParserFirstCallInit * Registers \ tag with the parser. */ public static function setupParserHooks( Parser $parser ): void { // For nice language list in-page $parser->setHook( 'languages', [ Hooks::class, 'languages' ] ); } /** Hook: LoadExtensionSchemaUpdates */ public static function schemaUpdates( DatabaseUpdater $updater ): void { $dir = dirname( __DIR__, 1 ) . '/sql'; $dbType = $updater->getDB()->getType(); if ( $dbType === 'mysql' || $dbType === 'sqlite' ) { $updater->addExtensionTable( 'translate_sections', "{$dir}/{$dbType}/translate_sections.sql" ); $updater->addExtensionTable( 'revtag', "{$dir}/{$dbType}/revtag.sql" ); $updater->addExtensionTable( 'translate_groupstats', "{$dir}/{$dbType}/translate_groupstats.sql" ); $updater->addExtensionTable( 'translate_reviews', "{$dir}/{$dbType}/translate_reviews.sql" ); $updater->addExtensionTable( 'translate_groupreviews', "{$dir}/{$dbType}/translate_groupreviews.sql" ); $updater->addExtensionTable( 'translate_tms', "{$dir}/{$dbType}/translate_tm.sql" ); $updater->addExtensionTable( 'translate_metadata', "{$dir}/{$dbType}/translate_metadata.sql" ); $updater->addExtensionTable( 'translate_messageindex', "{$dir}/{$dbType}/translate_messageindex.sql" ); $updater->addExtensionTable( 'translate_stash', "{$dir}/{$dbType}/translate_stash.sql" ); $updater->addExtensionTable( 'translate_translatable_bundles', "{$dir}/{$dbType}/translate_translatable_bundles.sql" ); // 1.32 - This also adds a PRIMARY KEY $updater->addExtensionUpdate( [ 'renameIndex', 'translate_reviews', 'trr_user_page_revision', 'PRIMARY', false, "$dir/translate_reviews-patch-01-primary-key.sql", true ] ); $updater->addExtensionTable( 'translate_cache', "{$dir}/{$dbType}/translate_cache.sql" ); if ( $dbType === 'mysql' ) { // 1.38 $updater->modifyExtensionField( 'translate_cache', 'tc_key', "{$dir}/{$dbType}/translate_cache-alter-varbinary.sql" ); } } elseif ( $dbType === 'postgres' ) { $updater->addExtensionTable( 'translate_sections', "{$dir}/{$dbType}/tables-generated.sql" ); $updater->addExtensionUpdate( [ 'changeField', 'translate_cache', 'tc_exptime', 'TIMESTAMPTZ', 'th_timestamp::timestamp with time zone' ] ); } // 1.39 $updater->dropExtensionIndex( 'translate_messageindex', 'tmi_key', "{$dir}/{$dbType}/patch-translate_messageindex-unique-to-pk.sql" ); $updater->dropExtensionIndex( 'translate_tmt', 'tms_sid_lang', "{$dir}/{$dbType}/patch-translate_tmt-unique-to-pk.sql" ); $updater->dropExtensionIndex( 'revtag', 'rt_type_page_revision', "{$dir}/{$dbType}/patch-revtag-unique-to-pk.sql" ); $updater->addPostDatabaseUpdateMaintenance( SyncTranslatableBundleStatusMaintenanceScript::class ); } /** Hook: ParserTestTables */ public static function parserTestTables( array &$tables ): void { $tables[] = 'revtag'; $tables[] = 'translate_groupstats'; $tables[] = 'translate_messageindex'; $tables[] = 'translate_stash'; } /** * Hook: PageContentLanguage * Set the correct page content language for translation units. * @param Title $title * @param Language|StubUserLang|string &$pageLang */ public static function onPageContentLanguage( Title $title, &$pageLang ): void { $handle = new MessageHandle( $title ); if ( $handle->isMessageNamespace() ) { $pageLang = $handle->getEffectiveLanguage(); } } /** * Hook: LanguageGetTranslatedLanguageNames * Hook: TranslateSupportedLanguages */ public static function translateMessageDocumentationLanguage( array &$names, ?string $code ): void { global $wgTranslateDocumentationLanguageCode; if ( $wgTranslateDocumentationLanguageCode ) { // Special case the autonyms if ( $wgTranslateDocumentationLanguageCode === $code || $code === null ) { $code = 'en'; } $names[$wgTranslateDocumentationLanguageCode] = wfMessage( 'translate-documentation-language' )->inLanguage( $code )->plain(); } } /** Hook: SpecialSearchProfiles */ public static function searchProfile( array &$profiles ): void { global $wgTranslateMessageNamespaces; $insert = []; $insert['translation'] = [ 'message' => 'translate-searchprofile', 'tooltip' => 'translate-searchprofile-tooltip', 'namespaces' => $wgTranslateMessageNamespaces, ]; // Insert translations before 'all' $index = array_search( 'all', array_keys( $profiles ) ); // Or just at the end if all is not found if ( $index === false ) { wfWarn( '"all" not found in search profiles' ); $index = count( $profiles ); } $profiles = array_merge( array_slice( $profiles, 0, $index ), $insert, array_slice( $profiles, $index ) ); } /** Hook: SpecialSearchProfileForm */ public static function searchProfileForm( SpecialSearch $search, string &$form, string $profile, string $term, array $opts ): bool { if ( $profile !== 'translation' ) { return true; } if ( Services::getInstance()->getTtmServerFactory()->getDefaultForQuerying() instanceof SearchableTtmServer ) { $href = SpecialPage::getTitleFor( 'SearchTranslations' ) ->getFullUrl( [ 'query' => $term ] ); $form = Html::successBox( $search->msg( 'translate-searchprofile-note', $href )->parse(), 'plainlinks' ); return false; } if ( !$search->getSearchEngine()->supports( 'title-suffix-filter' ) ) { return false; } $hidden = ''; foreach ( $opts as $key => $value ) { $hidden .= Html::hidden( $key, $value ); } $context = $search->getContext(); $code = $context->getLanguage()->getCode(); $selected = $context->getRequest()->getVal( 'languagefilter' ); $languages = Utilities::getLanguageNames( $code ); ksort( $languages ); $selector = new XmlSelect( 'languagefilter', 'languagefilter' ); $selector->setDefault( $selected ); $selector->addOption( wfMessage( 'translate-search-nofilter' )->text(), '-' ); foreach ( $languages as $code => $name ) { $selector->addOption( "$code - $name", $code ); } $selector = $selector->getHTML(); $label = Xml::label( wfMessage( 'translate-search-languagefilter' )->text(), 'languagefilter' ) . ' '; $params = [ 'id' => 'mw-searchoptions' ]; $form = Xml::fieldset( false, false, $params ) . $hidden . $label . $selector . Html::closeElement( 'fieldset' ); return false; } /** Hook: SpecialSearchSetupEngine */ public static function searchProfileSetupEngine( SpecialSearch $search, string $profile, SearchEngine $engine ): void { if ( $profile !== 'translation' ) { return; } $context = $search->getContext(); $selected = $context->getRequest()->getVal( 'languagefilter' ); if ( $selected !== '-' && $selected ) { $engine->setFeatureData( 'title-suffix-filter', "/$selected" ); $search->setExtraParam( 'languagefilter', $selected ); } } /** Hook: ParserAfterTidy */ public static function preventCategorization( Parser $parser, string &$html ): void { $pageReference = $parser->getPage(); if ( !$pageReference ) { return; } $linkTarget = TitleValue::newFromPage( $pageReference ); $handle = new MessageHandle( $linkTarget ); if ( $handle->isMessageNamespace() && !$handle->isDoc() ) { $parserOutput = $parser->getOutput(); // MW >= 1.40 if ( method_exists( $parserOutput, 'getCategorySortKey' ) ) { $names = $parserOutput->getCategoryNames(); $parserCategories = []; foreach ( $names as $name ) { $parserCategories[$name] = $parserOutput->getCategorySortKey( $name ); } $parserOutput->setExtensionData( 'translate-fake-categories', $parserCategories ); } else { $parserOutput->setExtensionData( 'translate-fake-categories', $parserOutput->getCategories() ); } $parserOutput->setCategories( [] ); } } /** Hook: OutputPageParserOutput */ public static function showFakeCategories( OutputPage $outputPage, ParserOutput $parserOutput ): void { $fakeCategories = $parserOutput->getExtensionData( 'translate-fake-categories' ); if ( $fakeCategories ) { $outputPage->setCategoryLinks( $fakeCategories ); } } /** * Hook: MakeGlobalVariablesScript * Adds $wgTranslateDocumentationLanguageCode to ResourceLoader configuration * when Special:Translate is shown. */ public static function addConfig( array &$vars, OutputPage $out ): void { $title = $out->getTitle(); [ $alias, ] = MediaWikiServices::getInstance() ->getSpecialPageFactory()->resolveAlias( $title->getText() ); if ( $title->isSpecialPage() && ( $alias === 'Translate' || $alias === 'TranslationStash' || $alias === 'SearchTranslations' ) ) { global $wgTranslateDocumentationLanguageCode, $wgTranslatePermissionUrl, $wgTranslateUseSandbox; $vars['TranslateRight'] = $out->getUser()->isAllowed( 'translate' ); $vars['TranslateMessageReviewRight'] = $out->getUser()->isAllowed( 'translate-messagereview' ); $vars['DeleteRight'] = $out->getUser()->isAllowed( 'delete' ); $vars['TranslateManageRight'] = $out->getUser()->isAllowed( 'translate-manage' ); $vars['wgTranslateDocumentationLanguageCode'] = $wgTranslateDocumentationLanguageCode; $vars['wgTranslatePermissionUrl'] = $wgTranslatePermissionUrl; $vars['wgTranslateUseSandbox'] = $wgTranslateUseSandbox; } } /** Hook: AdminLinks */ public static function onAdminLinks( ALTree $tree ): void { global $wgTranslateUseSandbox; if ( $wgTranslateUseSandbox ) { $sectionLabel = wfMessage( 'adminlinks_users' )->text(); $row = $tree->getSection( $sectionLabel )->getRow( 'main' ); $row->addItem( ALItem::newFromSpecialPage( 'TranslateSandbox' ) ); } } /** * Hook: MergeAccountFromTo * For UserMerge extension. */ public static function onMergeAccountFromTo( User $oldUser, User $newUser ): void { $dbw = MediawikiServices::getInstance()->getDBLoadBalancer()->getMaintenanceConnectionRef( DB_PRIMARY ); // Update the non-duplicate rows, we'll just delete // the duplicate ones later foreach ( self::USER_MERGE_TABLES as $table => $field ) { if ( $dbw->tableExists( $table, __METHOD__ ) ) { $dbw->update( $table, [ $field => $newUser->getId() ], [ $field => $oldUser->getId() ], __METHOD__, [ 'IGNORE' ] ); } } } /** * Hook: DeleteAccount * For UserMerge extension. */ public static function onDeleteAccount( User $oldUser ): void { $dbw = MediawikiServices::getInstance()->getDBLoadBalancer()->getMaintenanceConnectionRef( DB_PRIMARY ); // Delete any remaining rows that didn't get merged foreach ( self::USER_MERGE_TABLES as $table => $field ) { if ( $dbw->tableExists( $table, __METHOD__ ) ) { $dbw->delete( $table, [ $field => $oldUser->getId() ], __METHOD__ ); } } } /** Hook: AbortEmailNotification */ public static function onAbortEmailNotificationReview( User $editor, Title $title, RecentChange $rc ): bool { return $rc->getAttribute( 'rc_log_type' ) !== 'translationreview'; } /** * Hook: TitleIsAlwaysKnown * Make Special:MyLanguage links red if the target page doesn't exist. * A bit hacky because the core code is not so flexible. * @param Title $target Title object that is being checked * @param bool|null &$isKnown Whether MediaWiki currently thinks this page is known * @return bool True or no return value to continue or false to abort */ public static function onTitleIsAlwaysKnown( $target, &$isKnown ): bool { if ( !$target->inNamespace( NS_SPECIAL ) ) { return true; } [ $name, $subpage ] = MediaWikiServices::getInstance() ->getSpecialPageFactory()->resolveAlias( $target->getDBkey() ); if ( $name !== 'MyLanguage' ) { return true; } if ( (string)$subpage === '' ) { return true; } $realTarget = Title::newFromText( $subpage ); if ( !$realTarget || !$realTarget->exists() ) { $isKnown = false; return false; } return true; } /** Hook: ParserFirstCallInit */ public static function setupTranslateParserFunction( Parser $parser ): void { $parser->setFunctionHook( 'translation', [ self::class, 'translateRenderParserFunction' ] ); } public static function translateRenderParserFunction( Parser $parser ): string { $pageReference = $parser->getPage(); if ( !$pageReference ) { return ''; } $linkTarget = TitleValue::newFromPage( $pageReference ); $handle = new MessageHandle( $linkTarget ); $code = $handle->getCode(); if ( MediaWikiServices::getInstance()->getLanguageNameUtils()->isKnownLanguageTag( $code ) ) { return '/' . $code; } return ''; } /** * Runs the configured validator to ensure that the message meets the required criteria. * Hook: EditFilterMergedContent * @return bool true if message is valid, false otherwise. */ public static function validateMessage( IContextSource $context, Content $content, Status $status, string $summary, User $user ): bool { if ( !$content instanceof TextContent ) { // Not interested return true; } $text = $content->getText(); $title = $context->getTitle(); $handle = new MessageHandle( $title ); if ( !$handle->isValid() ) { return true; } // Don't bother validating if FuzzyBot or translation admin are saving. if ( $user->isAllowed( 'translate-manage' ) || $user->equals( FuzzyBot::getUser() ) ) { return true; } // Check the namespace, and perform validations for all messages excluding documentation. if ( $handle->isMessageNamespace() && !$handle->isDoc() ) { $group = $handle->getGroup(); if ( method_exists( $group, 'getMessageContent' ) ) { // @phan-suppress-next-line PhanUndeclaredMethod $definition = $group->getMessageContent( $handle ); } else { $definition = $group->getMessage( $handle->getKey(), $group->getSourceLanguage() ); } $message = new FatMessage( $handle->getKey(), $definition ); $message->setTranslation( $text ); $messageValidator = $group->getValidator(); if ( !$messageValidator ) { return true; } $validationResponse = $messageValidator->validateMessage( $message, $handle->getCode() ); if ( $validationResponse->hasErrors() ) { $status->fatal( new ApiRawMessage( $context->msg( 'translate-syntax-error' )->parse(), 'translate-validation-failed', [ 'validation' => [ 'errors' => $validationResponse->getDescriptiveErrors( $context ), 'warnings' => $validationResponse->getDescriptiveWarnings( $context ) ] ] ) ); return false; } } return true; } /** @inheritDoc */ public function onRevisionRecordInserted( $revisionRecord ): void { $parentId = $revisionRecord->getParentId(); if ( $parentId === 0 || $parentId === null ) { // No parent, bail out. return; } $prevRev = $this->revisionLookup->getRevisionById( $parentId ); if ( !$prevRev || !$revisionRecord->hasSameContent( $prevRev ) ) { // Not a null revision, bail out. return; } // List of tags that should be copied over when updating // tp:tag and tp:mark handling is in Hooks::updateTranstagOnNullRevisions. $tagsToCopy = [ RevTagStore::FUZZY_TAG, RevTagStore::TRANSVER_PROP ]; $db = $this->loadBalancer->getConnection( DB_PRIMARY ); $db->insertSelect( 'revtag', 'revtag', [ 'rt_type' => 'rt_type', 'rt_page' => 'rt_page', 'rt_revision' => $revisionRecord->getId(), 'rt_value' => 'rt_value', ], [ 'rt_type' => $tagsToCopy, 'rt_revision' => $parentId, ], __METHOD__ ); } /** @inheritDoc */ public function onListDefinedTags( &$tags ): void { $tags[] = 'translate-translation-pages'; } /** @inheritDoc */ public function onChangeTagsListActive( &$tags ): void { if ( $this->config->get( 'EnablePageTranslation' ) ) { $tags[] = 'translate-translation-pages'; } } } diff --git a/src/PageTranslation/Hooks.php b/src/PageTranslation/Hooks.php index bb46bb2b4..9443687d1 100644 --- a/src/PageTranslation/Hooks.php +++ b/src/PageTranslation/Hooks.php @@ -1,1681 +1,1712 @@ 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; } 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 and add styles $text = $parserOutput->sourcePageTextForRendering( $wikitextParser->getTargetLanguage() ); $wikitextParser->getOutput()->addModuleStyles( [ 'ext.translate', ] ); } catch ( ParsingFailure $e ) { wfDebug( 'ParsingFailure caught; expected' ); } } else { // If the text doesn't contain markup, it can still contain 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; } 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 * @return true */ 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' ); } if ( $isTranslation ) { // Source pages get this module via , but for translation // pages we need to add it manually. $out->addModuleStyles( 'ext.translate' ); $out->addJsConfigVars( 'wgTranslatePageTranslation', 'translation' ); } else { $out->addJsConfigVars( 'wgTranslatePageTranslation', 'source' ); } } return true; } /** * 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 * @return true */ 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 true; } $group = $handle->getGroup(); if ( !$group instanceof WikiPageMessageGroup ) { return true; } // 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 ); } ); } return true; } 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': + $title = Title::castFromPageReference( $parser->getPage() ); + $pageStatus = self::getTranslatablePageStatus( $title ); + $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; } ); // Add a dummy language link that is removed in self::addLanguageLinks. if ( $wgPageTranslationLanguageList === 'sidebar-fallback' ) { $parser->getOutput()->addLanguageLink( 'x-pagetranslation-tag' ); } $currentTitle = $parser->getTitle(); $pageStatus = self::getTranslatablePageStatus( $currentTitle ); 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 ( $currentTitle->equals( $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' ) ->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' )->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', 'lang' => $userLang->getHtmlCode(), 'dir' => $userLang->getDir() ] ); $out .= Html::rawElement( 'div', [ 'class' => 'mw-pt-languages-label' ], wfMessage( 'tpt-languages-legend' )->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 < 20 ) { $classes[] = 'mw-pt-progress--stub'; } elseif ( $percent < 40 ) { $classes[] = 'mw-pt-progress--low'; } elseif ( $percent < 60 ) { $classes[] = 'mw-pt-progress--med'; } elseif ( $percent < 80 ) { $classes[] = 'mw-pt-progress--high'; } else { $classes[] = 'mw-pt-progress--complete'; } return $classes; } /** * Returns translatable page and language stats for given title. - * @param Title $title - * @return array|null Returns null if not a translatable page. + * @return array{page:TranslatablePage,languages:array}|null Returns null if not a translatable page. */ - private static function getTranslatablePageStatus( Title $title ) { + private static function getTranslatablePageStatus( ?Title $title ): ?array { + if ( $title === null ) { + return null; + } // 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; } // If priority languages have been set, always show those languages $priorityLangs = TranslateMetadata::get( $page->getMessageGroupId(), 'prioritylangs' ); $priorityForce = TranslateMetadata::get( $page->getMessageGroupId(), 'priorityforce' ); $filter = null; if ( (string)$priorityLangs !== '' ) { $filter = array_flip( explode( ',', $priorityLangs ) ); } if ( $filter !== null ) { // If translation is restricted to some languages, only show them if ( $priorityForce === 'on' ) { // Do not filter the source language link $filter[$page->getMessageGroup()->getSourceLanguage()] = true; $status = array_intersect_key( $status, $filter ); } foreach ( $filter as $langCode => $value ) { if ( !isset( $status[$langCode] ) ) { // We need to show all priority languages even if no translation started $status[$langCode] = 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; $hasLanguagesTag = false; foreach ( $languageLinks as $index => $name ) { if ( $name === 'x-pagetranslation-tag' ) { $hasLanguagesTag = true; unset( $languageLinks[ $index ] ); } } if ( $wgPageTranslationLanguageList === 'tag-only' ) { return; } if ( $wgPageTranslationLanguageList === 'sidebar-fallback' && $hasLanguagesTag ) { 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 ( substr( $link[ 'text' ], 0, 18 ) !== 'x-pagetranslation:' ) { return; } if ( !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 true */ 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 { // T163254: Ignore translation markup on non-wikitext pages if ( !$content instanceof WikitextContent || !$page ) { return null; } $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 * @return true */ public static function addTranstagAfterSave( WikiPage $wikiPage, UserIdentity $userIdentity, string $summary, int $flags, RevisionRecord $revisionRecord, EditResult $editResult ) { $content = $wikiPage->getContent(); // T163254: Disable page translation on non-wikitext pages if ( $content instanceof WikitextContent ) { $text = $content->getText(); } else { // Not applicable return true; } $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() ); return true; } /** * 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. * 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 ) { // 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 $statsdDataFactory = MediaWikiServices::getInstance()->getStatsdDataFactory(); $statsdDataFactory->increment( 'translate.slow_translatable_page_check' ); $translatablePage = self::checkTranslatablePageSlow( $title ); if ( $translatablePage ) { $groupId = $translatablePage->getMessageGroupId(); $statsdDataFactory->increment( 'translate.slow_translatable_page_check_valid' ); } 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 []; } // Check if anything is prevented for the group in the first place $force = TranslateMetadata::get( $groupId, 'priorityforce' ); if ( $force !== 'on' ) { return []; } // And finally check whether the language is in the inclusion list $languages = TranslateMetadata::get( $groupId, 'prioritylangs' ); $filter = array_flip( explode( ',', $languages ) ); if ( !isset( $filter[$handle->getCode()] ) ) { $reason = TranslateMetadata::get( $groupId, 'priorityreason' ); 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() ) { if ( $needsPageTranslationRight ) { $result = User::newFatalPermissionDeniedStatus( 'pagetranslation' )->getMessage(); return false; } [ , $code ] = Utilities::figureMessage( $title->getText() ); $mwService = MediaWikiServices::getInstance(); if ( method_exists( $mwService, 'getUrlUtils' ) ) { $translationUrl = $mwService->getUrlUtils()->expand( $page->getTranslationUrl( $code ), PROTO_RELATIVE ); } else { // < MW 1.39 $translationUrl = wfExpandUrl( $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 * @return bool */ public static function translatablePageHeader( $article, &$outputDone, &$pcache ) { if ( $article->getOldID() ) { return true; } $transPage = TranslatablePage::isTranslationPage( $article->getTitle() ); $context = $article->getContext(); if ( $transPage ) { self::translationPageHeader( $context, $transPage ); } else { // Check for pages that are tagged or marked self::sourcePageHeader( $context ); } return true; } 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' => '', ] ); } 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(); if ( method_exists( $mwService, 'getUrlUtils' ) ) { $translationUrl = $mwService->getUrlUtils()->expand( $page->getTranslationUrl( $code ), PROTO_RELATIVE ); } else { // < MW 1.39 $translationUrl = wfExpandUrl( $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() ], '$1' ); $output->wrapWikiMsg( $wrap, [ 'tpt-translation-intro-fuzzy' ] ); } } } /** * Hook: SpecialPage_initList * @param array &$list * @return true */ public static function replaceMovePage( &$list ) { $movePageSpec = $list['Movepage'] ?? null; // This should never happen, but apparently is happening? See: T296568 if ( $movePageSpec === null ) { return true; } $list['Movepage'] = [ 'class' => MoveTranslatableBundleSpecialPage::class, 'services' => [ 'ObjectFactory', 'PermissionManager', 'Translate:TranslatableBundleMover', 'Translate:TranslatableBundleFactory' ], 'args' => [ $movePageSpec ] ]; return true; } /** * 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 = ObjectCache::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(); if ( strpos( $ptext, '/' ) !== false ) { $links = explode( '/', $ptext ); array_pop( $links ); if ( $isTranslationPage ) { // Also remove language code page array_pop( $links ); } $c = 0; $growinglink = ''; $display = ''; $lang = $skin->getLanguage(); foreach ( $links as $link ) { $growinglink .= $link; $display .= $link; $linkObj = Title::newFromText( $growinglink ); if ( is_object( $linkObj ) && $linkObj->isKnown() ) { $getlink = $linker->makeKnownLink( SpecialPage::getTitleFor( 'MyLanguage', $growinglink ), $display ); $c++; if ( $c > 1 ) { $subpages .= $lang->getDirMarkEntity() . $skin->msg( 'pipe-separator' )->escaped(); } else { $subpages .= '< '; } $subpages .= $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 * @return true */ public static function translateTab( Skin $skin, array &$tabs ) { $title = $skin->getTitle(); $handle = new MessageHandle( $title ); $code = $handle->getCode(); $page = TranslatablePage::isTranslationPage( $title ); if ( !$page ) { return true; } // The source language has a subpage too, but cannot be translated if ( $page->getSourceLanguageCode() === $code ) { return true; } if ( isset( $tabs['views']['edit'] ) ) { $tabs['views']['edit']['text'] = $skin->msg( 'tpt-tab-translate' )->text(); $tabs['views']['edit']['href'] = $page->getTranslationUrl( $code ); } else { $canTranslate = MediaWikiServices::getInstance()->getPermissionManager()->userCan( 'translate', $skin->getUser(), $page->getTitle() ); if ( $canTranslate ) { $tabs['views']['translate']['text'] = $skin->msg( 'tpt-tab-translate' )->text(); $tabs['views']['translate']['href'] = $page->getTranslationUrl( $code ); } } return true; } /** * 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 ); if ( !$handle->isValid() ) { continue; } // Documentation pages are never translation pages if ( $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 ( (string)$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 ) { // Do the update. In case job queue is doing the work, the update is not done here if ( self::$jobQueueRunning ) { return; } $title = $unit->getTitle(); $handle = new MessageHandle( $title ); if ( !$handle->isValid() ) { return; } $group = $handle->getGroup(); if ( !$group instanceof WikiPageMessageGroup ) { return; } $target = $group->getTitle(); $langCode = $handle->getCode(); $fname = __METHOD__; $dbw = MediaWikiServices::getInstance()->getDBLoadBalancer()->getConnection( DB_PRIMARY ); $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( Title::READ_LATEST ) ) { return; } $dbw->startAtomic( $fname ); $page = TranslatablePage::newFromTitle( $target ); MessageGroupStats::forItem( $page->getMessageGroupId(), $langCode, MessageGroupStats::FLAG_NO_CACHE ); 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__ ); } } diff --git a/tests/phpunit/PageTranslation/HooksTest.php b/tests/phpunit/PageTranslation/HooksTest.php index 30b05b095..fd680793e 100644 --- a/tests/phpunit/PageTranslation/HooksTest.php +++ b/tests/phpunit/PageTranslation/HooksTest.php @@ -1,232 +1,261 @@ setMwGlobals( [ 'wgHooks' => [], 'wgEnablePageTranslation' => true, 'wgTranslateTranslationServices' => [], 'wgTranslateMessageNamespaces' => [ NS_MEDIAWIKI ], 'wgGroupPermissions' => [ 'sysop' => [ 'translate-manage' => true, ], ], ] ); HookHandler::setupTranslate(); $this->setTemporaryHook( 'TranslateInitGroupLoaders', [ 'TranslatablePageMessageGroupStore::registerLoader' ] ); $this->setTemporaryHook( 'TranslatePostInitGroups', [ $this, 'getTestGroups' ] ); $mg = MessageGroups::singleton(); $mg->setCache( new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ) ); $mg->recache(); $hashIndex = new HashMessageIndex(); MessageIndex::setInstance( $hashIndex ); $hashIndex->rebuild(); } public function getTestGroups( array &$groups, array &$deps, array &$autoload ) { $messages = [ 'translated' => 'bunny', 'untranslated' => 'fanny', ]; $groups['test-group'] = new MockWikiValidationMessageGroup( 'test-group', $messages ); return false; } public function testRenderTagPage() { // Setup objects - $superUser = $this->getTestSysop()->getUser(); $translatablePageTitle = Title::newFromText( 'Vuosaari' ); - $pageUpdater = $this->getServiceContainer() - ->getWikiPageFactory() - ->newFromTitle( $translatablePageTitle ) - ->newPageUpdater( $superUser ); $text = 'pupu'; - $content = ContentHandler::makeContent( $text, $translatablePageTitle ); $translatablePage = TranslatablePage::newFromTitle( $translatablePageTitle ); $parser = $this->getServiceContainer()->getParserFactory()->getInstance(); - $options = ParserOptions::newFromUser( $superUser ); + $options = ParserOptions::newFromAnon(); $messageGroups = MessageGroups::singleton(); // Create the page - $commentStoreComment = CommentStoreComment::newUnsavedComment( __METHOD__ ); - $pageUpdater->setContent( SlotRecord::MAIN, $content ); - $pageUpdater->saveRevision( $commentStoreComment ); - $editStatus = $pageUpdater->getStatus(); - + $latestRevisionId = $this->editPage( $translatablePageTitle, $text )->getNewRevision()->getId(); $messageGroups->recache(); // Check that we don't interfere with non-translatable pages at all $parserOutput = $parser->parse( $text, $translatablePageTitle, $options ); $actual = $parserOutput->getExtensionData( 'translate-translation-page' ); $expected = null; $this->assertSame( $expected, $actual, 'Extension data is not set on unmarked source page' ); // Mark the page for translation - $latestRevisionId = $editStatus->value['revision-record']->getId(); $translatablePage->addMarkedTag( $latestRevisionId ); $messageGroups->recache(); $translationPageTitle = Title::newFromText( 'Vuosaari/fi' ); RenderTranslationPageJob::newJob( $translationPageTitle )->run(); // Check that we don't add data to translatable pages $parserOutput = $parser->parse( $text, $translatablePageTitle, $options ); $actual = $parserOutput->getExtensionData( 'translate-translation-page' ); $expected = null; $this->assertSame( $expected, $actual, 'Extension data is not set on marked source page' ); // Check that our code works for translation pages $parserOutput = $parser->parse( 'fi-pupu', $translationPageTitle, $options ); $actual = $parserOutput->getExtensionData( 'translate-translation-page' ); $this->assertIsArray( $actual, 'Extension data is set on marked page' ); $actualTitle = Title::makeTitle( $actual[ 'sourcepagetitle' ][ 'namespace' ], $actual[ 'sourcepagetitle' ][ 'dbkey' ] ); $this->assertSame( 'Vuosaari', $actualTitle->getPrefixedText(), 'Source page title is correct' ); $this->assertSame( 'fi', $actual[ 'languagecode' ], 'Language code is correct' ); $this->assertSame( 'page-Vuosaari', $actual[ 'messagegroupid' ], 'Message group id is correct' ); } + public function testTRANSLATABLEPAGE() { + // Setup objects + $translatablePageTitle = Title::newFromText( 'TRANSLATABLEPAGE test' ); + $text = 'pupu'; + $translatablePage = TranslatablePage::newFromTitle( $translatablePageTitle ); + $parser = $this->getServiceContainer()->getParserFactory()->getInstance(); + $options = ParserOptions::newFromAnon(); + $messageGroups = MessageGroups::singleton(); + + // Create the page + $latestRevisionId = $this->editPage( $translatablePageTitle, $text )->getNewRevision()->getId(); + $messageGroups->recache(); + + // Check unmarked source page + $this->assertSame( + '', + $parser->preprocess( '{{TRANSLATABLEPAGE}}', $translatablePageTitle, $options ), + 'TRANSLATABLEPAGE returns empty string on unmarked source page' + ); + + // Mark the page for translation + $translatablePage->addMarkedTag( $latestRevisionId ); + $messageGroups->recache(); + $translationPageTitle = Title::newFromText( 'TRANSLATABLEPAGE test/fi' ); + RenderTranslationPageJob::newJob( $translationPageTitle )->run(); + + // Check marked source page + $this->assertSame( + 'TRANSLATABLEPAGE test', + $parser->preprocess( '{{TRANSLATABLEPAGE}}', $translatablePageTitle, $options ), + 'TRANSLATABLEPAGE returns the page title on marked source page' + ); + + // Check translation page + $this->assertSame( + 'TRANSLATABLEPAGE test', + $parser->preprocess( '{{TRANSLATABLEPAGE}}', $translationPageTitle, $options ), + 'TRANSLATABLEPAGE returns the source page title on translation page' + ); + } + public function testValidateMessagePermission() { $plainUser = $this->getMutableTestUser()->getUser(); $title = Title::newFromText( 'MediaWiki:translated/fi' ); $content = ContentHandler::makeContent( 'pupuliini', $title ); $status = new Status(); $requestContext = new RequestContext(); $requestContext->setLanguage( 'en-gb' ); $requestContext->setTitle( $title ); HookHandler::validateMessage( $requestContext, $content, $status, '', $plainUser ); $this->assertFalse( $status->isOK(), 'translation with errors is not saved if a normal user is translating.' ); $this->assertGreaterThan( 0, $status->getErrors(), 'errors are specified when translation fails validation.' ); $newStatus = new Status(); $superUser = $this->getTestSysop()->getUser(); HookHandler::validateMessage( $requestContext, $content, $newStatus, '', $superUser ); $this->assertTrue( $newStatus->isOK(), "translation with errors is saved if user with 'translate-manage' permission is translating." ); } /** @covers MediaWiki\Extension\Translate\PageTranslation\Hooks::updateTranstagOnNullRevisions */ public function testTagNullRevision() { $title = Title::newFromText( 'translated' ); $status = $this->editPage( $title->getPrefixedDBkey(), 'Test text' ); $this->assertTrue( $status->isGood(), 'Sanity: must create revision 1' ); /** @var RevisionRecord $rev1 */ $rev1 = $status->getValue()['revision-record']; $translatablePage = TranslatablePage::newFromTitle( $title ); $this->assertEquals( $rev1->getId(), $translatablePage->getReadyTag(), 'Sanity: must tag revision 1 ready for translate' ); $wikiPage = $this->getServiceContainer()->getWikiPageFactory()->newFromID( $title->getArticleID() ); if ( method_exists( MediaWikiServices::class, 'getProtectPageFactory' ) ) { // Planned future change (T292683) $protectPage = TestingAccessWrapper::newFromObject( $this->getServiceContainer()->getProtectPageFactory() ->newProtectPage( $wikiPage, $this->getTestUser()->getUser() ) ); $nullRev = $protectPage->insertNullProtectionRevision( 'test comment', [ 'edit' => 'sysop' ], [ 'edit' => '20200101040404' ], false, 'Testing', $this->getTestUser()->getUser() ); } else { $nullRev = $wikiPage->insertNullProtectionRevision( 'test comment', [ 'edit' => 'sysop' ], [ 'edit' => '20200101040404' ], false, 'Testing', $this->getTestUser()->getUser() ); } $this->assertNotNull( $nullRev, 'Sanity: must create null revision' ); $this->assertEquals( $translatablePage->getReadyTag(), $nullRev->getId(), 'Must update ready tag for null revision' ); $status = $this->editPage( $title->getPrefixedDBkey(), 'Modified test text' ); $this->assertTrue( $status->isGood(), 'Sanity: must create revision 2' ); $this->assertEquals( $translatablePage->getReadyTag(), $nullRev->getId(), 'Must not update ready tag for non-null revision' ); } }