diff options
Diffstat (limited to 'includes')
38 files changed, 515 insertions, 454 deletions
diff --git a/includes/DomainEvent/DomainEvent.php b/includes/DomainEvent/DomainEvent.php index ec8e5399b002..cb70bd604c12 100644 --- a/includes/DomainEvent/DomainEvent.php +++ b/includes/DomainEvent/DomainEvent.php @@ -26,7 +26,6 @@ use Wikimedia\Timestamp\ConvertibleTimestamp; * @note Subclasses must call declareEventType() in their constructor! * * @since 1.44 - * @unstable until 1.45, should become stable to extend */ abstract class DomainEvent { diff --git a/includes/DomainEvent/DomainEventDispatcher.php b/includes/DomainEvent/DomainEventDispatcher.php index 9bae3d17f807..7a8a77b8fd12 100644 --- a/includes/DomainEvent/DomainEventDispatcher.php +++ b/includes/DomainEvent/DomainEventDispatcher.php @@ -7,7 +7,6 @@ use Wikimedia\Rdbms\IConnectionProvider; * Service for sending domain events to registered listeners. * * @since 1.44 - * @unstable until 1.45 */ interface DomainEventDispatcher { diff --git a/includes/DomainEvent/DomainEventIngress.php b/includes/DomainEvent/DomainEventIngress.php index 5d44552d14bd..f069914e8d99 100644 --- a/includes/DomainEvent/DomainEventIngress.php +++ b/includes/DomainEvent/DomainEventIngress.php @@ -37,7 +37,6 @@ use LogicException; * in extension.json. * * @since 1.44 - * @unstable until 1.45, should become stable to extend */ abstract class DomainEventIngress implements InitializableDomainEventSubscriber { diff --git a/includes/DomainEvent/DomainEventSource.php b/includes/DomainEvent/DomainEventSource.php index d440705f528f..2b8b2e5a2a8f 100644 --- a/includes/DomainEvent/DomainEventSource.php +++ b/includes/DomainEvent/DomainEventSource.php @@ -5,7 +5,6 @@ namespace MediaWiki\DomainEvent; * Service object for registering listeners for domain events. * * @since 1.44 - * @unstable until 1.45 */ interface DomainEventSource { diff --git a/includes/DomainEvent/DomainEventSubscriber.php b/includes/DomainEvent/DomainEventSubscriber.php index e4c4d69b1473..6009bcee9b04 100644 --- a/includes/DomainEvent/DomainEventSubscriber.php +++ b/includes/DomainEvent/DomainEventSubscriber.php @@ -7,7 +7,6 @@ namespace MediaWiki\DomainEvent; * related event listeners. * * @since 1.44 - * @stable to type * @note Extensions should not implement this interface directly but should * extend DomainEventIngress. */ diff --git a/includes/DomainEvent/InitializableDomainEventSubscriber.php b/includes/DomainEvent/InitializableDomainEventSubscriber.php index 64fdff11d0ba..17f09f662fd9 100644 --- a/includes/DomainEvent/InitializableDomainEventSubscriber.php +++ b/includes/DomainEvent/InitializableDomainEventSubscriber.php @@ -8,7 +8,6 @@ namespace MediaWiki\DomainEvent; * * This is useful when constructing an DomainEventSubscriber from an object spec. * - * @since 1.44 * @internal for use by DomainEventSubscriber */ interface InitializableDomainEventSubscriber extends DomainEventSubscriber { diff --git a/includes/Output/OutputPage.php b/includes/Output/OutputPage.php index 0a33bb608ccd..80fd513cc61b 100644 --- a/includes/Output/OutputPage.php +++ b/includes/Output/OutputPage.php @@ -2102,6 +2102,7 @@ class OutputPage extends ContextSource { * @deprecated since 1.44, use ::getMetadata()->setRevisionTimestamp(...) */ public function setRevisionTimestamp( $timestamp ) { + wfDeprecated( __METHOD__, '1.44' ); $previousValue = $this->metadata->getRevisionTimestamp(); $this->metadata->setRevisionTimestamp( $timestamp ); return $previousValue; diff --git a/includes/Rest/Handler/Helper/HtmlOutputRendererHelper.php b/includes/Rest/Handler/Helper/HtmlOutputRendererHelper.php index bd0c794e142c..9a5f844d92d1 100644 --- a/includes/Rest/Handler/Helper/HtmlOutputRendererHelper.php +++ b/includes/Rest/Handler/Helper/HtmlOutputRendererHelper.php @@ -294,18 +294,6 @@ class HtmlOutputRendererHelper implements HtmlOutputHelper { } /** - * Controls how the parser cache is used. - * - * @param bool $read Whether we should look for cached output before parsing - * @param bool $write Whether we should cache output after parsing - */ - public function setUseParserCache( bool $read, bool $write ) { - $this->parserOutputAccessOptions = - ( $read ? 0 : ParserOutputAccess::OPT_FORCE_PARSE ) | - ( $write ? 0 : ParserOutputAccess::OPT_NO_UPDATE_CACHE ); - } - - /** * Determine whether stashing should be applied. * * @param bool $stash diff --git a/includes/Rest/i18n/gl.json b/includes/Rest/i18n/gl.json index 54c07b691cca..ee2bdc9ab2eb 100644 --- a/includes/Rest/i18n/gl.json +++ b/includes/Rest/i18n/gl.json @@ -87,6 +87,14 @@ "rest-unsupported-language-conversion": "Conversión de lingua non compatible: $1 a $2", "rest-unknown-content-model": "Modelo de contido descoñecido: $1", "rest-page-bundle-validation-error": "PageBundle non coincide con contentVersion: $1", + "rest-module": "Módulo", + "rest-module-default": "Módulo predeterminado", + "rest-module-extra-routes-title": "API REST de MediaWiki", + "rest-module-extra-routes-desc": "Extremos REST non asociados a ningún módulo", + "rest-module-specs.v0-title": "Especificacións", + "rest-module-specs.v0-desc": "Módulo de autodocumentación que proporciona descubrimento, especificacións e esquemas para todos os módulos dispoñibles.", + "rest-module-content.v1-title": "Contido da páxina", + "rest-module-content.v1-desc": "Ofrece acceso ao contido e metadatos das páxinas e revisións", "rest-param-desc-revision-id": "Identificador da revisión", "rest-param-desc-source": "Contido da páxina no formato especificado pola propiedade content_model" } diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php index f82bb3598935..68ec604820dd 100644 --- a/includes/ServiceWiring.php +++ b/includes/ServiceWiring.php @@ -2793,9 +2793,6 @@ return [ ), LoggerFactory::getProvider(), - // UserBlockConstraint - $services->getPermissionManager(), - // EditFilterMergedContentHookConstraint $services->getHookContainer(), @@ -2805,7 +2802,7 @@ return [ // SpamRegexConstraint $services->getSpamChecker(), - // UserRateLimitConstraint + // LinkPurgeRateLimitConstraint $services->getRateLimiter() ); }, diff --git a/includes/api/ApiMessageTrait.php b/includes/api/ApiMessageTrait.php index 8f796101ce2b..9c2bd7575117 100644 --- a/includes/api/ApiMessageTrait.php +++ b/includes/api/ApiMessageTrait.php @@ -67,6 +67,7 @@ trait ApiMessageTrait { 'importuploaderrorpartial' => 'partialupload', 'importuploaderrorsize' => 'filetoobig', 'importuploaderrortemp' => 'notempdir', + 'ipb-block-not-found' => 'alreadyblocked', 'ipb_already_blocked' => 'alreadyblocked', 'ipb_blocked_as_range' => 'blockedasrange', 'ipb_cant_unblock' => 'cantunblock', diff --git a/includes/api/i18n/ja.json b/includes/api/i18n/ja.json index 7a7b7cca92f3..35e9fadef31c 100644 --- a/includes/api/i18n/ja.json +++ b/includes/api/i18n/ja.json @@ -27,12 +27,14 @@ "Yamagata Yusuke", "Yusuke1109", "Yuukin0248", + "もなー(偽物)", "ネイ" ] }, "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|ドキュメンテーション]]\n* [[mw:Special:MyLanguage/API:Etiquette|エチケットと使用ガイドライン]]\n* [[mw:Special:MyLanguage/API:FAQ|よくある質問]]\n* [https://lists.wikimedia.org/postorius/lists/mediawiki-api.lists.wikimedia.org/ メーリングリスト]\n* [https://lists.wikimedia.org/postorius/lists/mediawiki-api-announce.lists.wikimedia.org/ API 告知]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R バグと要望]\n</div>\n<strong>状態:</strong> MediaWiki APIは、サポートおよび更新が恒常的に続けられている、安定した多機能インターフェースです。時折大きな仕様変更が適用されることもありますが、[https://lists.wikimedia.org/hyperkitty/list/mediawiki-api-announce@lists.wikimedia.org/ the mediawiki-api-announce メーリングリスト]を購読すると適時にアップデート通知を受け取ることができます。\n\n<strong>リクエストエラー:</strong> 非適合形式でAPIにリクエストに送られた場合、\"MediaWiki-API-Error\"のキーを含むHTTPヘッダーが返され、レスポンスのヘッダー値とエラーコード値が同値になります。詳細は、[[mw:Special:MyLanguage/API:Errors_and_warnings|API:エラーと警告]]をご覧ください。\n\n<p class=\"mw-apisandbox-link\"><strong>テスト:</strong> [[Special:ApiSandbox|APIサンドボックス]]を使用すると、容易にAPIリクエストのテストが可能です。</p>", "apihelp-main-param-action": "実行する操作。", "apihelp-main-param-format": "出力フォーマット。", + "apihelp-main-param-maxlag": "最大ラグは、MediaWiki がデータベース複製クラスターにインストールされている場合に使用できます。サイトの複製ラグをさらに引き起こすアクションを防ぐために、このパラメータを使用すると、複製ラグが指定値より小さくなるまでクライアントを待機させることができます。ラグが大きすぎる場合は、エラー コード<samp>maxlag</samp>が返され、 <samp>Waiting for $host: $lag seconds lagged</samp>のようなメッセージが表示されます。<br />詳細については、[[mw:Special:MyLanguage/Manual:Maxlag_parameter|マニュアル: Maxlag パラメータ]] を参照してください。", "apihelp-main-param-smaxage": "<code>s-maxage</code>のHTTPキャッシュコントロールヘッダーをこの秒数に指定します。エラーはキャッシュされません。", "apihelp-main-param-maxage": "<code>max-age</code>のHTTPキャッシュコントロールヘッダーをこの秒数に指定します。エラーはキャッシュされません。", "apihelp-main-param-assert": "<kbd>user</kbd> に設定した場合は利用者がログインしていること(臨時利用者としてログインしている場合も含む)を、 <kbd>anon</kbd> に設定した場合はログインして<em>いない</em>ことを、<kbd>bot</kbd> の場合はボット利用者権限があることを検証します。", @@ -52,6 +54,7 @@ "apihelp-main-param-errorlang": "警告およびエラーで使用する言語。 <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo&siprop=languages]]</kbd> は言語コードの一覧を返します。 <kbd>content</kbd> を指定するとこのウィキのコンテンツ言語、<kbd>uselang</kbd> を指定すると <var>uselang</var> パラメーターと同じ値を使用します。", "apihelp-main-param-errorsuselocal": "指定された場合、エラーテキストは{{ns:MediaWiki}}名前空間からローカルにカスタマイズされたメッセージを使用します。", "apihelp-block-summary": "利用者をブロックします。", + "apihelp-block-param-id": "変更するブロックID。", "apihelp-block-param-user": "ブロックする利用者。", "apihelp-block-param-userid": "代わりに <kbd>$1user=#<var>ID</var></kbd> を指定してください。", "apihelp-block-param-expiry": "有効期限。相対的 (例: <kbd>5 months</kbd> または <kbd>2 weeks</kbd>) または絶対的 (e.g. <kbd>2014-09-18T12:34:56Z</kbd>) どちらでも構いません。<kbd>infinite</kbd>, <kbd>indefinite</kbd>, もしくは <kbd>never</kbd> と設定した場合, 無期限ブロックとなります。", @@ -63,18 +66,26 @@ "apihelp-block-param-hidename": "ブロック記録から利用者名を秘匿します。(<code>hideuser</code> 権限が必要です)", "apihelp-block-param-allowusertalk": "自身のトークページの編集を許可する (<var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var> に依存)。", "apihelp-block-param-reblock": "その利用者がすでにブロックされている場合、ブロックを上書きします。", + "apihelp-block-param-newblock": "利用者がすでにブロックされている場合でも、別のブロックを追加します。", "apihelp-block-param-watchuser": "その利用者またはIPアドレスの利用者ページとトークページをウォッチします。", "apihelp-block-param-watchlistexpiry": "ウォッチリストの有効期限のタイムスタンプ。現在の有効期限を変更せずそのままにするには、このパラメーターを完全に省略します。", "apihelp-block-param-tags": "ブロック記録の項目に適用する変更タグ。", "apihelp-block-param-partial": "サイト全体ではなく特定のページまたは名前空間での編集をブロックします。", "apihelp-block-param-pagerestrictions": "利用者が編集できないようにするページのタイトルのリスト。<var>partial</var> に true が設定されている場合のみ適用します。", "apihelp-block-param-namespacerestrictions": "利用者が編集できないようにする名前空間のID。<var>partial</var> に true が設定されている場合のみ適用します。", + "apihelp-block-param-actionrestrictions": "利用者に実行させない操作のリスト。<var>partial</var> が true に設定されている場合のみ適用されます。", "apihelp-block-example-ip-simple": "IPアドレス <kbd>192.0.2.5</kbd> を何らかの理由で3日ブロックする。", "apihelp-block-example-user-complex": "利用者 <kbd>Vandal</kbd> を何らかの理由で無期限ブロックし、新たなアカウント作成とメールの送信を禁止する。", "apihelp-changeauthenticationdata-summary": "現在の利用者の認証データを変更します。", "apihelp-changeauthenticationdata-example-password": "現在の利用者のパスワードを <kbd>ExamplePassword</kbd> に変更する。", "apihelp-changecontentmodel-summary": "ページのコンテンツモデルを変更します。", + "apihelp-changecontentmodel-param-title": "コンテンツモデルを変更するページ名。<var>$1pageid</var>と併用することはできません。", + "apihelp-changecontentmodel-param-pageid": "コンテンツモデルを変更するページID。<var>$1title</var> とは併用できません。", + "apihelp-changecontentmodel-param-summary": "編集の概要と記録エントリの理由", + "apihelp-changecontentmodel-param-tags": "記録エントリに適用するタグを変更して編集します。", "apihelp-changecontentmodel-param-model": "新しいコンテンツのコンテンツモデル。", + "apihelp-changecontentmodel-param-bot": "コンテンツモデルの変更をボット フラグでマークします。", + "apihelp-changecontentmodel-example": "メインページが<code>text</code>コンテンツモデルを持つよう変更する", "apihelp-checktoken-summary": "<kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd> のトークンの妥当性を確認します。", "apihelp-checktoken-param-type": "調べるトークンの種類。", "apihelp-checktoken-param-token": "調べるトークン。", diff --git a/includes/block/BlockUser.php b/includes/block/BlockUser.php index aa9ac9269fd4..3de0388d1611 100644 --- a/includes/block/BlockUser.php +++ b/includes/block/BlockUser.php @@ -587,7 +587,6 @@ class BlockUser { } $expectedTargetCount = 0; - $priorBlock = null; $priorBlocks = $this->getPriorBlocksForTarget(); if ( $this->blockToUpdate !== null ) { @@ -601,7 +600,7 @@ class BlockUser { } elseif ( $conflictMode === self::CONFLICT_NEW && $this->options->get( MainConfigNames::EnableMultiBlocks ) ) { - foreach ( $this->getPriorBlocksForTarget() as $priorBlock ) { + foreach ( $priorBlocks as $priorBlock ) { if ( $block->equals( $priorBlock ) ) { // Block settings are equal => user is already blocked $this->logger->debug( 'placeBlockInternal: ' . @@ -610,8 +609,10 @@ class BlockUser { } } $expectedTargetCount = null; + $priorBlock = null; $update = false; } elseif ( !$priorBlocks ) { + $priorBlock = null; $update = false; } else { // Reblock only if the caller wants so diff --git a/includes/block/DatabaseBlockStore.php b/includes/block/DatabaseBlockStore.php index c33157b58157..20f662e044d5 100644 --- a/includes/block/DatabaseBlockStore.php +++ b/includes/block/DatabaseBlockStore.php @@ -1044,6 +1044,7 @@ class DatabaseBlockStore { $targetUserName = (string)$target; $targetUserId = $target->getUserIdentity()->getId( $this->wikiId ); $targetConds = [ 'bt_user' => $targetUserId ]; + $targetLockKey = $dbw->getDomainID() . ':block:u:' . $targetUserId; } else { $targetAddress = (string)$target; $targetUserName = null; @@ -1052,6 +1053,8 @@ class DatabaseBlockStore { 'bt_address' => $targetAddress, 'bt_auto' => $isAuto, ]; + $targetLockKey = $dbw->getDomainID() . ':block:' . + ( $isAuto ? 'a' : 'i' ) . ':' . $targetAddress; } $condsWithCount = $targetConds; @@ -1059,6 +1062,15 @@ class DatabaseBlockStore { $condsWithCount['bt_count'] = $expectedTargetCount; } + $dbw->lock( $targetLockKey, __METHOD__ ); + $func = __METHOD__; + $dbw->onTransactionCommitOrIdle( + static function () use ( $dbw, $targetLockKey, $func ) { + $dbw->unlock( $targetLockKey, "$func.closure" ); + }, + __METHOD__ + ); + // This query locks the index gap when the target doesn't exist yet, // so there is a risk of throttling adjacent block insertions, // especially on small wikis which have larger gaps. If this proves to @@ -1076,6 +1088,7 @@ class DatabaseBlockStore { ->select( [ 'bt_id', 'bt_count' ] ) ->from( 'block_target' ) ->where( $targetConds ) + ->forUpdate() ->caller( __METHOD__ ) ->fetchResultSet(); if ( $res->numRows() > 1 ) { diff --git a/includes/composer/PhpUnitSplitter/SuiteSplittingException.php b/includes/composer/PhpUnitSplitter/SuiteSplittingException.php new file mode 100644 index 000000000000..91182dd483b6 --- /dev/null +++ b/includes/composer/PhpUnitSplitter/SuiteSplittingException.php @@ -0,0 +1,12 @@ +<?php + +declare( strict_types = 1 ); + +namespace MediaWiki\Composer\PhpUnitSplitter; + +/** + * @license GPL-2.0-or-later + */ +class SuiteSplittingException extends \RuntimeException { + +} diff --git a/includes/composer/PhpUnitSplitter/TestSuiteBuilder.php b/includes/composer/PhpUnitSplitter/TestSuiteBuilder.php index b9da8c651cab..a59673916a96 100644 --- a/includes/composer/PhpUnitSplitter/TestSuiteBuilder.php +++ b/includes/composer/PhpUnitSplitter/TestSuiteBuilder.php @@ -14,7 +14,7 @@ class TestSuiteBuilder { } /** - * Try to build balanced groups (split_groups / buckets) of tests. We have a couple of + * Try to build balanced groups (split_groups) of tests. We have a couple of * objectives here: * - the groups should contain a stable ordering of tests so that we reduce the amount * of random test failures due to test re-ordering @@ -24,80 +24,223 @@ class TestSuiteBuilder { * - the groups should have a similar test execution time * * Information about test duration may be completely absent (if no test cache information is - * supplied), or partially absent (if the test has not been seen before). Since we neither - * want to ignore the duration information nor rely on it, we compromise by filling the buckets - * until we have reached a maximum by test count *or* by duration. This has the consequence - * that tests with a duration of zero will be treated somewhat like tests with an average - * duration. + * supplied), or partially absent (if the test has not been seen before). We attempt to create + * similar-duration split-groups using the information we have available, and if anything goes + * wrong we fall back to just creating split-groups with the same number of tests in them. * * @param array $testDescriptors the list of tests that we want to sort into split_groups * @param int $groups the number of split_groups we are targetting + * @param ?int $chunkSize optionally override the size of the 'chunks' into which tests + * are grouped. If not supplied, the chunk size will depend on the total number + * of tests. * @return array a structured array of the resulting split_groups */ - public function buildSuites( array $testDescriptors, int $groups ): array { + public function buildSuites( array $testDescriptors, int $groups, ?int $chunkSize = null ): array { $suites = array_fill( 0, $groups, [ "list" => [], "time" => 0 ] ); + // If there are no test descriptors, we just return empty suites + if ( $testDescriptors === [] ) { + return $suites; + } - // Sort the tests alphabetically so that tests in the same extension (folder) stay - // together in the same split_group + // Sort the tests by name so that we run tests of the same extension together and in a predictable order usort( $testDescriptors, [ self::class, "sortByNameAscending" ] ); - // Count the total number of tests (with valid filenames) and set the max number - // of tests per bucket - $testCount = array_reduce( - $testDescriptors, - static fn ( $acc, $descriptor ) => ( $descriptor->getFilename() ? $acc + 1 : $acc ), - 0 - ); - $bucketTestCount = ceil( $testCount / $groups ); - - // Count the total duration of tests (with duration information) and set the max - // duration per bucket - $totalDuration = array_reduce( - $testDescriptors, - static fn ( $acc, $descriptor ) => $acc + $descriptor->getDuration(), - 0 - ); - $maxBucketDuration = ceil( $totalDuration / $groups ); - - // Counters for current bucket and cumulative counters for total progress - $currentTestIndex = 0; - $currentBucketDuration = 0; - $currentBucketIndex = 0; - $cumulativeTestCount = 0; - $cumulativeDuration = 0; - foreach ( $testDescriptors as $testDescriptor ) { - if ( !$testDescriptor->getFilename() ) { - // We didn't resolve a matching file for this test, so we skip it - // from the suite here. This only happens for "known" missing test - // classes (see PhpUnitXmlManager::EXPECTED_MISSING_CLASSES) - in - // all other cases a missing test file will throw an exception during - // suite building. - continue; - } - $suites[$currentBucketIndex]["list"][] = $testDescriptor->getFilename(); - $suites[$currentBucketIndex]["time"] += $testDescriptor->getDuration(); - $currentTestIndex += 1; - $cumulativeTestCount += 1; - $currentBucketDuration += $testDescriptor->getDuration(); - $cumulativeDuration += $testDescriptor->getDuration(); - - // Advance to the next bucket if we either have reached the limit in number of tests or the - // limit in test duration - if ( $currentTestIndex >= $bucketTestCount || $currentBucketDuration > $maxBucketDuration ) { - // Don't advance past the last bucket. If we reached the last bucket, just dump - // everything in there. - if ( $currentBucketIndex < $groups - 1 ) { - $currentBucketIndex++; - } - $currentTestIndex = 0; - $currentBucketDuration = 0; + $descriptorCount = count( $testDescriptors ); + if ( $chunkSize === null ) { + // The algorithm is CPU intensive - make sure we run with at most 200 'chunks' of tests to group + $chunkSize = intval( ceil( $descriptorCount / 200 ) ); + } + + // Skip over any leading zero-time tests, and add them back to the first group at the end + // Without this adjustment, the dynamic-sizing algorithm can end up with a zero-size split-group + // which would cause PHPUnit to error. + $startIndex = 0; + while ( $startIndex < $descriptorCount && $testDescriptors[$startIndex]->getDuration() == 0 ) { + $startIndex++; + } + + if ( $startIndex === 0 ) { + $testDescriptorsWithoutLeadingZeros = $testDescriptors; + $leadingZeros = []; + } elseif ( $startIndex < $descriptorCount ) { + $leadingZeros = array_map( + static fn ( $testDescriptor ) => $testDescriptor->getFilename(), + array_slice( $testDescriptors, 0, $startIndex ) + ); + $testDescriptorsWithoutLeadingZeros = array_slice( $testDescriptors, $startIndex ); + } else { + // if we never encounter a test with duration information, fall back to splitting + // tests into split-groups with the same number of test classes. + return $this->buildSuitesNoDurationInformation( $testDescriptors, $groups ); + } + + try { + $this->buildSuitesWithDurationInformationWithoutLeadingEmptyTests( + $testDescriptorsWithoutLeadingZeros, $suites, $groups, $chunkSize + ); + } catch ( SuiteSplittingException $se ) { + return $this->buildSuitesNoDurationInformation( $testDescriptors, $groups ); + } - // Rebalance the bucket targets - $remainingBuckets will be at least 1 - $remainingBuckets = $groups - $currentBucketIndex; - $bucketTestCount = ceil( ( $testCount - $cumulativeTestCount ) / $remainingBuckets ); - $maxBucketDuration = ceil( ( $totalDuration - $cumulativeDuration ) / $remainingBuckets ); + // Add any zero-length tests that we sliced away before splitting back to the first group + if ( $leadingZeros !== [] ) { + $suites[0]["list"] = array_merge( $leadingZeros, $suites[0]["list"] ); + } + + return $suites; + } + + /** + * @throws SuiteSplittingException + */ + private function buildSuitesWithDurationInformationWithoutLeadingEmptyTests( + array $testDescriptorsWithoutLeadingZeros, + array &$suites, + int $numGroups, + int $chunkSize + ): void { + $n = count( $testDescriptorsWithoutLeadingZeros ); + if ( $n == 0 ) { + return; + } + + $chunks = $this->createChunksOfTests( $n, $testDescriptorsWithoutLeadingZeros, $chunkSize ); + + $numChunks = count( $chunks ); + $durations = array_column( $chunks, "time" ); + + // Build an array of cumulative test duration (or 'prefix sum') - sum(0..i){x.duration} + $prefixSum = $this->calculatePrefixSum( $numChunks, $durations ); + + // Generate backtracking table + $backtrack = $this->generateBacktrackingTable( $numChunks, $numGroups, $prefixSum ); + + $this->constructSplitGroups( $numGroups, $numChunks, $chunks, $backtrack, $suites ); + } + + /** + * Called as a fallback for the case where not duration information is available. + * Simply split the tests into $groups equally-sized split-groups. + * + * @param TestDescriptor[] $testDescriptors + * @param int $groups + * @return array + */ + private function buildSuitesNoDurationInformation( array $testDescriptors, int $groups ): array { + $suites = array_fill( 0, $groups, [ "list" => [], "time" => 0 ] ); + $testCount = count( $testDescriptors ); + $splitGroupSize = ceil( $testCount / $groups ); + $bucketIndex = 0; + $testIndex = 0; + for ( $currentGroup = 0; $currentGroup < $groups, $testIndex < $testCount; $testIndex++, $bucketIndex++ ) { + if ( $bucketIndex >= $splitGroupSize ) { + $bucketIndex = 0; + $currentGroup++; + if ( $currentGroup === $groups ) { + break; + } } + $suites[$currentGroup]["list"][] = $testDescriptors[$testIndex]->getFilename(); + $suites[$currentGroup]["time"] += $testDescriptors[$testIndex]->getDuration(); } + return $suites; } + + private function createChunksOfTests( int $n, array $testDescriptors, int $chunkSize ): array { + $chunks = []; + for ( $i = 0; $i < $n; $i += $chunkSize ) { + $chunk = array_slice( $testDescriptors, $i, min( $chunkSize, $n - $i ) ); + $chunks[] = [ + "list" => $chunk, + "time" => array_reduce( $chunk, static fn ( $sum, $test ) => $sum + $test->getDuration(), 0 ) + ]; + } + return $chunks; + } + + private function calculatePrefixSum( int $numChunks, array $durations ): array { + $prefixSum = array_fill( 0, $numChunks + 1, 0 ); + for ( $i = 1; $i <= $numChunks; $i++ ) { + $prefixSum[$i] = $prefixSum[$i - 1] + $durations[$i - 1]; + } + return $prefixSum; + } + + /** + * Construct the split groups from the backtracking table. + * @throws SuiteSplittingException + */ + private function constructSplitGroups( + int $numGroups, + int $numChunks, + array $chunks, + array $backtrack, + array &$suites + ) { + for ( $splitGroupId = $numGroups, $i = $numChunks; $splitGroupId > 0; --$splitGroupId ) { + $startIndex = $backtrack[$i][$splitGroupId]; + if ( $startIndex === -1 ) { + throw new SuiteSplittingException( "Invalid backtracking index building group " . $splitGroupId ); + } + $suites[$splitGroupId - 1]["list"] = array_merge( + ...array_map( static fn ( $chunk ) => array_map( + static fn ( $test ) => $test->getFilename(), + $chunk["list"] + ), + array_slice( $chunks, $startIndex, $i - $startIndex ) + ) + ); + $suites[$splitGroupId - 1]["time"] = array_reduce( + array_slice( $chunks, $startIndex, $i - $startIndex ), + static fn ( $acc, $chunk ) => $acc + $chunk["time"], + 0 + ); + $i = $startIndex; + } + } + + /** + * Find the distribution of group split points that minimises the duration of the largest split_group + * and thereby minimises the duration of the CI job. + */ + private function generateBacktrackingTable( int $numChunks, int $numGroups, array $prefixSum ): array { + // $minimumLargestBucket[x][y] is the minimum possible largest split_group duration when splitting + // the first x chunks into y groups + $minimumLargestBucket = array_fill( 0, $numChunks + 1, array_fill( 0, $numGroups + 1, PHP_INT_MAX ) ); + // The backtracking table keeps track of the end point of the last group of the optimal distribution + $backtrack = array_fill( 0, $numChunks + 1, array_fill( 0, $numGroups + 1, -1 ) ); + + $minimumLargestBucket[0][0] = 0; + + // We work inductively, starting with distributing 1 chunk into 1 split_group + // and progressively distributing more tests until all chunks are in a split_group + for ( $i = 1; $i <= $numChunks; $i++ ) { + // For $i chunks, split them into up to $numGroups groups by identifying the optimal split points + for ( $j = 1; $j <= min( $numGroups, $i ); $j++ ) { + // For each split group $j find a split point $k, somewhere before the current chunk + for ( $k = 0; $k < $i; $k++ ) { + // the difference of the prefix sums is the combined duration of chunks $k through $i + $currentSplitGroupDuration = $prefixSum[$i] - $prefixSum[$k]; + // Compare the duration of the proposed split group with the minimum duration we found so far + // for splitting $k tests into $j-1 groups. + $newBestCaseMinimumLargestBucket = max( + $minimumLargestBucket[$k][$j - 1], $currentSplitGroupDuration + ); + // If our current duration is smaller, we know that we can split $k tests into $j groups without + // increasing the minimum duration. If it is greater, we know that putting a split point at $k would + // make the minimum duration of any group at least $currentSplitGroupDuration. + if ( $newBestCaseMinimumLargestBucket < $minimumLargestBucket[$i][$j] ) { + // If the new duration is less than the existing minimum for splitting $i tests into $j groups, + // we update the minimum duration. + $minimumLargestBucket[$i][$j] = $newBestCaseMinimumLargestBucket; + // Set the backtrack reference so that we know where the split point was for this minimal value. + $backtrack[$i][$j] = $k; + } + } + } + } + return $backtrack; + } + } diff --git a/includes/deferred/LinksUpdate/CategoryLinksTable.php b/includes/deferred/LinksUpdate/CategoryLinksTable.php index 151566c9f408..bee5b26518ef 100644 --- a/includes/deferred/LinksUpdate/CategoryLinksTable.php +++ b/includes/deferred/LinksUpdate/CategoryLinksTable.php @@ -14,6 +14,7 @@ use MediaWiki\MainConfigNames; use MediaWiki\Page\PageReferenceValue; use MediaWiki\Page\WikiPageFactory; use MediaWiki\Parser\ParserOutput; +use MediaWiki\Parser\ParserOutputLinkTypes; use MediaWiki\Parser\Sanitizer; use MediaWiki\Storage\NameTableStore; use MediaWiki\Title\NamespaceInfo; @@ -142,8 +143,10 @@ class CategoryLinksTable extends TitleLinksTable { $this->newLinks = []; $sourceTitle = Title::castFromPageIdentity( $this->getSourcePage() ); $sortKeyInputs = []; - foreach ( $parserOutput->getCategoryNames() as $name ) { - $sortKey = $parserOutput->getCategorySortKey( $name ); + foreach ( + $parserOutput->getLinkList( ParserOutputLinkTypes::CATEGORY ) + as [ 'link' => $targetTitle, 'sort' => $sortKey ] + ) { '@phan-var string $sortKey'; // sort key will never be null if ( $sortKey == '' ) { @@ -160,7 +163,8 @@ class CategoryLinksTable extends TitleLinksTable { // categories, causing T27254. $sortKeyPrefix = mb_strcut( $sortKey, 0, 255 ); - $targetTitle = Title::makeTitle( NS_CATEGORY, $name ); + $name = $targetTitle->getDBkey(); + $targetTitle = Title::castFromLinkTarget( $targetTitle ); $this->languageConverter->findVariantLink( $name, $targetTitle, true ); // Ignore the returned text, DB key should be used for links (T328477). $name = $targetTitle->getDBKey(); diff --git a/includes/diff/DifferenceEngine.php b/includes/diff/DifferenceEngine.php index faca5c529263..e5a3b1428525 100644 --- a/includes/diff/DifferenceEngine.php +++ b/includes/diff/DifferenceEngine.php @@ -1238,7 +1238,7 @@ class DifferenceEngine extends ContextSource { $out->setRevisionId( $this->mNewid ); $out->setRevisionIsCurrent( $this->mNewRevisionRecord->isCurrent() ); - $out->setRevisionTimestamp( $this->mNewRevisionRecord->getTimestamp() ); + $out->getMetadata()->setRevisionTimestamp( $this->mNewRevisionRecord->getTimestamp() ); $out->setArticleFlag( true ); if ( !$this->hookRunner->onArticleRevisionViewCustom( diff --git a/includes/editpage/Constraint/AuthorizationConstraint.php b/includes/editpage/Constraint/AuthorizationConstraint.php new file mode 100644 index 000000000000..73247160fa7b --- /dev/null +++ b/includes/editpage/Constraint/AuthorizationConstraint.php @@ -0,0 +1,88 @@ +<?php +/** + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + */ + +namespace MediaWiki\EditPage\Constraint; + +use MediaWiki\Page\PageIdentity; +use MediaWiki\Permissions\Authority; +use MediaWiki\Permissions\PermissionStatus; +use StatusValue; + +/** + * Verify authorization to edit the page (user rights, rate limits, blocks). + * + * @since 1.44 + * @internal + */ +class AuthorizationConstraint implements IEditConstraint { + + private PermissionStatus $status; + + private Authority $performer; + private PageIdentity $target; + private bool $new; + + public function __construct( + Authority $performer, + PageIdentity $target, + bool $new + ) { + $this->performer = $performer; + $this->target = $target; + $this->new = $new; + } + + public function checkConstraint(): string { + $this->status = PermissionStatus::newEmpty(); + + if ( $this->new && !$this->performer->authorizeWrite( 'create', $this->target, $this->status ) ) { + return self::CONSTRAINT_FAILED; + } + + if ( !$this->performer->authorizeWrite( 'edit', $this->target, $this->status ) ) { + return self::CONSTRAINT_FAILED; + } + + return self::CONSTRAINT_PASSED; + } + + public function getLegacyStatus(): StatusValue { + $statusValue = StatusValue::newGood(); + + if ( !$this->status->isGood() ) { + // Report the most specific errors first + if ( $this->status->isBlocked() ) { + $statusValue->setResult( false, self::AS_BLOCKED_PAGE_FOR_USER ); + } elseif ( $this->status->isRateLimitExceeded() ) { + $statusValue->setResult( false, self::AS_RATE_LIMITED ); + } elseif ( $this->status->getPermission() === 'create' ) { + $statusValue->setResult( false, self::AS_NO_CREATE_PERMISSION ); + } elseif ( !$this->performer->isRegistered() ) { + $statusValue->setResult( false, self::AS_READ_ONLY_PAGE_ANON ); + } else { + $statusValue->setResult( false, self::AS_READ_ONLY_PAGE_LOGGED ); + } + } + + // TODO: Use error messages from the PermissionStatus ($this->status) here - T384399 + return $statusValue; + } + +} diff --git a/includes/editpage/Constraint/ContentModelChangeConstraint.php b/includes/editpage/Constraint/ContentModelChangeConstraint.php index 8ed2e56debde..2581ebab3c14 100644 --- a/includes/editpage/Constraint/ContentModelChangeConstraint.php +++ b/includes/editpage/Constraint/ContentModelChangeConstraint.php @@ -21,6 +21,7 @@ namespace MediaWiki\EditPage\Constraint; use MediaWiki\Permissions\Authority; +use MediaWiki\Permissions\PermissionStatus; use MediaWiki\Title\Title; use StatusValue; @@ -28,6 +29,7 @@ use StatusValue; * Verify user permissions if changing content model: * Must have editcontentmodel rights * Must be able to edit under the new content model + * Must not have exceeded the rate limit * * @since 1.36 * @internal @@ -35,10 +37,11 @@ use StatusValue; */ class ContentModelChangeConstraint implements IEditConstraint { + private PermissionStatus $status; + private Authority $performer; private Title $title; private string $newContentModel; - private string $result; /** * @param Authority $performer @@ -56,45 +59,43 @@ class ContentModelChangeConstraint implements IEditConstraint { } public function checkConstraint(): string { + $this->status = PermissionStatus::newEmpty(); + if ( $this->newContentModel === $this->title->getContentModel() ) { // No change - $this->result = self::CONSTRAINT_PASSED; return self::CONSTRAINT_PASSED; } - if ( !$this->performer->isAllowed( 'editcontentmodel' ) ) { - $this->result = self::CONSTRAINT_FAILED; + if ( !$this->performer->authorizeWrite( 'editcontentmodel', $this->title, $this->status ) ) { return self::CONSTRAINT_FAILED; } - // Make sure the user can edit the page under the new content model too + // Make sure the user can edit the page under the new content model too. + // We rely on caching in UserAuthority to avoid bumping the rate limit counter twice. $titleWithNewContentModel = clone $this->title; $titleWithNewContentModel->setContentModel( $this->newContentModel ); - - $canEditModel = $this->performer->authorizeWrite( - 'editcontentmodel', - $titleWithNewContentModel - ); - if ( - !$canEditModel - || !$this->performer->authorizeWrite( 'edit', $titleWithNewContentModel ) + !$this->performer->authorizeWrite( 'editcontentmodel', $titleWithNewContentModel, $this->status ) + || !$this->performer->authorizeWrite( 'edit', $titleWithNewContentModel, $this->status ) ) { - $this->result = self::CONSTRAINT_FAILED; return self::CONSTRAINT_FAILED; } - $this->result = self::CONSTRAINT_PASSED; return self::CONSTRAINT_PASSED; } public function getLegacyStatus(): StatusValue { $statusValue = StatusValue::newGood(); - if ( $this->result === self::CONSTRAINT_FAILED ) { - $statusValue->setResult( false, self::AS_NO_CHANGE_CONTENT_MODEL ); + if ( !$this->status->isGood() ) { + if ( $this->status->isRateLimitExceeded() ) { + $statusValue->setResult( false, self::AS_RATE_LIMITED ); + } else { + $statusValue->setResult( false, self::AS_NO_CHANGE_CONTENT_MODEL ); + } } + // TODO: Use error messages from the PermissionStatus ($this->status) here - T384399 return $statusValue; } diff --git a/includes/editpage/Constraint/EditConstraintFactory.php b/includes/editpage/Constraint/EditConstraintFactory.php index 750c977d2312..e9fede9f256c 100644 --- a/includes/editpage/Constraint/EditConstraintFactory.php +++ b/includes/editpage/Constraint/EditConstraintFactory.php @@ -26,10 +26,8 @@ use MediaWiki\Context\IContextSource; use MediaWiki\EditPage\SpamChecker; use MediaWiki\HookContainer\HookContainer; use MediaWiki\Language\Language; -use MediaWiki\Linker\LinkTarget; use MediaWiki\Logger\Spi; use MediaWiki\MainConfigNames; -use MediaWiki\Permissions\PermissionManager; use MediaWiki\Permissions\RateLimiter; use MediaWiki\Permissions\RateLimitSubject; use MediaWiki\Title\Title; @@ -54,7 +52,6 @@ class EditConstraintFactory { private ServiceOptions $options; private Spi $loggerFactory; - private PermissionManager $permissionManager; private HookContainer $hookContainer; private ReadOnlyMode $readOnlyMode; private SpamChecker $spamRegexChecker; @@ -74,7 +71,6 @@ class EditConstraintFactory { * * @param ServiceOptions $options * @param Spi $loggerFactory - * @param PermissionManager $permissionManager * @param HookContainer $hookContainer * @param ReadOnlyMode $readOnlyMode * @param SpamChecker $spamRegexChecker @@ -83,7 +79,6 @@ class EditConstraintFactory { public function __construct( ServiceOptions $options, Spi $loggerFactory, - PermissionManager $permissionManager, HookContainer $hookContainer, ReadOnlyMode $readOnlyMode, SpamChecker $spamRegexChecker, @@ -95,9 +90,6 @@ class EditConstraintFactory { $this->options = $options; $this->loggerFactory = $loggerFactory; - // UserBlockConstraint - $this->permissionManager = $permissionManager; - // EditFilterMergedContentHookConstraint $this->hookContainer = $hookContainer; @@ -107,7 +99,7 @@ class EditConstraintFactory { // SpamRegexConstraint $this->spamRegexChecker = $spamRegexChecker; - // UserRateLimitConstraint + // LinkPurgeRateLimitConstraint $this->rateLimiter = $rateLimiter; } @@ -163,21 +155,15 @@ class EditConstraintFactory { /** * @param RateLimitSubject $subject - * @param string $oldModel - * @param string $newModel * - * @return UserRateLimitConstraint + * @return LinkPurgeRateLimitConstraint */ - public function newUserRateLimitConstraint( - RateLimitSubject $subject, - string $oldModel, - string $newModel - ): UserRateLimitConstraint { - return new UserRateLimitConstraint( + public function newLinkPurgeRateLimitConstraint( + RateLimitSubject $subject + ): LinkPurgeRateLimitConstraint { + return new LinkPurgeRateLimitConstraint( $this->rateLimiter, - $subject, - $oldModel, - $newModel + $subject ); } @@ -226,39 +212,4 @@ class EditConstraintFactory { ); } - /** - * @param LinkTarget $title - * @param User $user - * @return UserBlockConstraint - */ - public function newUserBlockConstraint( - LinkTarget $title, - User $user - ): UserBlockConstraint { - return new UserBlockConstraint( - $this->permissionManager, - $title, - $user - ); - } - - /** - * @param User $performer - * @param Title $title - * @param bool $new - * @return EditRightConstraint - */ - public function newEditRightConstraint( - User $performer, - Title $title, - bool $new - ): EditRightConstraint { - return new EditRightConstraint( - $performer, - $this->permissionManager, - $title, - $new - ); - } - } diff --git a/includes/editpage/Constraint/EditRightConstraint.php b/includes/editpage/Constraint/EditRightConstraint.php deleted file mode 100644 index 47be037e4dad..000000000000 --- a/includes/editpage/Constraint/EditRightConstraint.php +++ /dev/null @@ -1,105 +0,0 @@ -<?php -/** - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 2 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, write to the Free Software Foundation, Inc., - * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - * http://www.gnu.org/copyleft/gpl.html - * - * @file - */ - -namespace MediaWiki\EditPage\Constraint; - -use MediaWiki\Permissions\PermissionManager; -use MediaWiki\Title\Title; -use MediaWiki\User\User; -use StatusValue; - -/** - * Verify user permissions: - * Must have edit rights - * - * @since 1.36 - * @internal - * @author DannyS712 - */ -class EditRightConstraint implements IEditConstraint { - - private User $performer; - private PermissionManager $permManager; - private Title $title; - private string $result; - private bool $new; - - /** - * @param User $performer - * @param PermissionManager $permManager - * @param Title $title - * @param bool $new - */ - public function __construct( - User $performer, - PermissionManager $permManager, - Title $title, - bool $new - ) { - $this->performer = $performer; - $this->permManager = $permManager; - $this->title = $title; - $this->new = $new; - } - - public function checkConstraint(): string { - if ( $this->new ) { - // Check isn't simple enough to just repeat when getting the status - if ( !$this->performer->authorizeWrite( 'create', $this->title ) ) { - $this->result = (string)self::AS_NO_CREATE_PERMISSION; - return self::CONSTRAINT_FAILED; - } - } - - // Check isn't simple enough to just repeat when getting the status - // Prior to 1.41 this checked if the user had edit rights in general - // instead of for the specific page in question. - if ( !$this->permManager->userCan( - 'edit', - $this->performer, - $this->title - ) ) { - $this->result = self::CONSTRAINT_FAILED; - return self::CONSTRAINT_FAILED; - } - - $this->result = self::CONSTRAINT_PASSED; - return self::CONSTRAINT_PASSED; - } - - public function getLegacyStatus(): StatusValue { - $statusValue = StatusValue::newGood(); - - if ( $this->result === self::CONSTRAINT_FAILED ) { - if ( !$this->performer->isRegistered() ) { - $statusValue->setResult( false, self::AS_READ_ONLY_PAGE_ANON ); - } else { - $statusValue->fatal( 'readonlytext' ); - $statusValue->value = self::AS_READ_ONLY_PAGE_LOGGED; - } - } elseif ( $this->result === (string)self::AS_NO_CREATE_PERMISSION ) { - $statusValue->fatal( 'nocreatetext' ); - $statusValue->value = self::AS_NO_CREATE_PERMISSION; - } - - return $statusValue; - } - -} diff --git a/includes/editpage/Constraint/UserRateLimitConstraint.php b/includes/editpage/Constraint/LinkPurgeRateLimitConstraint.php index 8f35dd5879d0..d25cf9336d88 100644 --- a/includes/editpage/Constraint/UserRateLimitConstraint.php +++ b/includes/editpage/Constraint/LinkPurgeRateLimitConstraint.php @@ -25,31 +25,26 @@ use MediaWiki\Permissions\RateLimitSubject; use StatusValue; /** - * Verify user doesn't exceed rate limits + * Verify that the user doesn't exceed 'linkpurge' limits, which are weird and special. + * Other rate limits have been integrated into their respective permission checks. * - * @since 1.36 + * @since 1.44 * @internal * @author DannyS712 */ -class UserRateLimitConstraint implements IEditConstraint { +class LinkPurgeRateLimitConstraint implements IEditConstraint { private RateLimitSubject $subject; - private string $oldContentModel; - private string $newContentModel; private RateLimiter $limiter; private string $result; public function __construct( RateLimiter $limiter, - RateLimitSubject $subject, - string $oldContentModel, - string $newContentModel + RateLimitSubject $subject ) { $this->limiter = $limiter; $this->subject = $subject; - $this->oldContentModel = $oldContentModel; - $this->newContentModel = $newContentModel; } private function limit( string $action, int $inc = 1 ): bool { @@ -57,16 +52,10 @@ class UserRateLimitConstraint implements IEditConstraint { } public function checkConstraint(): string { - // Need to check for rate limits on `editcontentmodel` if it is changing - $contentModelChange = ( $this->newContentModel !== $this->oldContentModel ); - // TODO inject and use a ThrottleStore once available, see T261744 // Checking if the user is rate limited increments the counts, so we cannot perform // the check again when getting the status; thus, store the result - if ( $this->limit( 'edit' ) - || $this->limit( 'linkpurge', 0 ) // only counted after the fact - || ( $contentModelChange && $this->limit( 'editcontentmodel' ) ) - ) { + if ( $this->limit( 'linkpurge', /* only counted after the fact */ 0 ) ) { $this->result = self::CONSTRAINT_FAILED; } else { $this->result = self::CONSTRAINT_PASSED; diff --git a/includes/editpage/Constraint/UserBlockConstraint.php b/includes/editpage/Constraint/UserBlockConstraint.php deleted file mode 100644 index bdd33262d434..000000000000 --- a/includes/editpage/Constraint/UserBlockConstraint.php +++ /dev/null @@ -1,79 +0,0 @@ -<?php -/** - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 2 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, write to the Free Software Foundation, Inc., - * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - * http://www.gnu.org/copyleft/gpl.html - * - * @file - */ - -namespace MediaWiki\EditPage\Constraint; - -use MediaWiki\Linker\LinkTarget; -use MediaWiki\Permissions\PermissionManager; -use MediaWiki\User\User; -use StatusValue; - -/** - * Verify user permissions: - * Must not be blocked from the page - * - * @since 1.36 - * @internal - * @author DannyS712 - */ -class UserBlockConstraint implements IEditConstraint { - - private PermissionManager $permissionManager; - private LinkTarget $title; - private User $user; - private string $result; - - /** - * @param PermissionManager $permissionManager - * @param LinkTarget $title - * @param User $user - */ - public function __construct( - PermissionManager $permissionManager, - LinkTarget $title, - User $user - ) { - $this->permissionManager = $permissionManager; - $this->title = $title; - $this->user = $user; - } - - public function checkConstraint(): string { - // Check isn't simple enough to just repeat when getting the status - if ( $this->permissionManager->isBlockedFrom( $this->user, $this->title ) ) { - $this->result = self::CONSTRAINT_FAILED; - return self::CONSTRAINT_FAILED; - } - - $this->result = self::CONSTRAINT_PASSED; - return self::CONSTRAINT_PASSED; - } - - public function getLegacyStatus(): StatusValue { - $statusValue = StatusValue::newGood(); - - if ( $this->result === self::CONSTRAINT_FAILED ) { - $statusValue->setResult( false, self::AS_BLOCKED_PAGE_FOR_USER ); - } - - return $statusValue; - } - -} diff --git a/includes/editpage/EditPage.php b/includes/editpage/EditPage.php index 93a3ad5a28e8..f08196894d88 100644 --- a/includes/editpage/EditPage.php +++ b/includes/editpage/EditPage.php @@ -37,6 +37,7 @@ use MediaWiki\Context\IContextSource; use MediaWiki\Debug\DeprecationHelper; use MediaWiki\Deferred\DeferredUpdates; use MediaWiki\EditPage\Constraint\AccidentalRecreationConstraint; +use MediaWiki\EditPage\Constraint\AuthorizationConstraint; use MediaWiki\EditPage\Constraint\BrokenRedirectConstraint; use MediaWiki\EditPage\Constraint\ChangeTagsConstraint; use MediaWiki\EditPage\Constraint\ContentModelChangeConstraint; @@ -54,7 +55,6 @@ use MediaWiki\EditPage\Constraint\PageSizeConstraint; use MediaWiki\EditPage\Constraint\SelfRedirectConstraint; use MediaWiki\EditPage\Constraint\SpamRegexConstraint; use MediaWiki\EditPage\Constraint\UnicodeConstraint; -use MediaWiki\EditPage\Constraint\UserBlockConstraint; use MediaWiki\Exception\ErrorPageError; use MediaWiki\Exception\MWContentSerializationException; use MediaWiki\Exception\MWException; @@ -2189,24 +2189,31 @@ class EditPage implements IEditObject { ) ); $constraintRunner->addConstraint( - $constraintFactory->newUserBlockConstraint( $this->mTitle, $requestUser ) + $constraintFactory->newReadOnlyConstraint() ); + + // Load the page data from the primary DB. If anything changes in the meantime, + // we detect it by using page_latest like a token in a 1 try compare-and-swap. + $this->page->loadPageData( IDBAccessObject::READ_LATEST ); + $new = !$this->page->exists(); + $constraintRunner->addConstraint( - new ContentModelChangeConstraint( + new AuthorizationConstraint( $authority, $this->mTitle, - $this->contentModel + $new ) ); - $constraintRunner->addConstraint( - $constraintFactory->newReadOnlyConstraint() + new ContentModelChangeConstraint( + $authority, + $this->mTitle, + $this->contentModel + ) ); $constraintRunner->addConstraint( - $constraintFactory->newUserRateLimitConstraint( - $requestUser->toRateLimitSubject(), - $this->mTitle->getContentModel(), - $this->contentModel + $constraintFactory->newLinkPurgeRateLimitConstraint( + $requestUser->toRateLimitSubject() ) ); $constraintRunner->addConstraint( @@ -2230,16 +2237,6 @@ class EditPage implements IEditObject { ) ); - // Load the page data from the primary DB. If anything changes in the meantime, - // we detect it by using page_latest like a token in a 1 try compare-and-swap. - $this->page->loadPageData( IDBAccessObject::READ_LATEST ); - $new = !$this->page->exists(); - - // We do this last, as some of the other constraints are more specific - $constraintRunner->addConstraint( - $constraintFactory->newEditRightConstraint( $this->getUserForPermissions(), $this->mTitle, $new ) - ); - // Check the constraints if ( !$constraintRunner->checkConstraints() ) { $failed = $constraintRunner->getFailedConstraint(); @@ -2625,9 +2622,12 @@ class EditPage implements IEditObject { * result from the backend. */ private function handleFailedConstraint( IEditConstraint $failed ): void { - if ( $failed instanceof UserBlockConstraint ) { + if ( $failed instanceof AuthorizationConstraint ) { // Auto-block user's IP if the account was "hard" blocked - if ( !MediaWikiServices::getInstance()->getReadOnlyMode()->isReadOnly() ) { + if ( + !MediaWikiServices::getInstance()->getReadOnlyMode()->isReadOnly() + && $failed->getLegacyStatus()->value === self::AS_BLOCKED_PAGE_FOR_USER + ) { $this->context->getUser()->spreadAnyEditBlock(); } } elseif ( $failed instanceof DefaultTextConstraint ) { diff --git a/includes/installer/i18n/nan-hant.json b/includes/installer/i18n/nan-hant.json index 7ca51f8702fc..59cbdd5709e9 100644 --- a/includes/installer/i18n/nan-hant.json +++ b/includes/installer/i18n/nan-hant.json @@ -15,7 +15,7 @@ "config-no-session": "你連線的資料已經無去矣,看你的php.ini,並且確定<code>session.save_path</code>是正確的目錄。", "config-your-language": "你的語言:", "config-your-language-help": "選一个安裝過程時欲用的話語", - "config-wiki-language": "Wiki話語", + "config-wiki-language": "wiki語言:", "config-wiki-language-help": "選一个Wiki大部份用的話", "config-back": "← 倒退", "config-continue": "繼續 →", @@ -42,5 +42,6 @@ "config-header-postgres": "PostgreSQL設定", "config-header-sqlite": "SQLite設定", "config-project-namespace": "專案名空間:", - "config-ns-generic": "專案" + "config-ns-generic": "專案", + "mainpagedocfooter": "請查看[https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents 用者說明書]的資料通使用wiki 軟體\n\n== 入門 ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings 配置的設定]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki時常問答]\n* [https://lists.wikimedia.org/postorius/lists/mediawiki-announce.lists.wikimedia.org/ MediaWiki的公布列單]" } diff --git a/includes/installer/i18n/nan-latn-pehoeji.json b/includes/installer/i18n/nan-latn-pehoeji.json index e3437e4d4805..8430b8f9272a 100644 --- a/includes/installer/i18n/nan-latn-pehoeji.json +++ b/includes/installer/i18n/nan-latn-pehoeji.json @@ -22,13 +22,10 @@ "config-session-error": "連線開始了的錯誤:$1", "config-session-expired": "你連線資料已經過時矣,連線的使用期限是設做$1。你會使改共php.ini的<code>session.gc_maxlifetime</code>改較長,並且重新安裝動作。", "config-no-session": "你連線的資料已經無去矣,看你的php.ini,並且確定<code>session.save_path</code>是正確的目錄。", - "config-your-language": "你的話語:", - "config-your-language-help": "選一个安裝過程時欲用的話語", - "config-wiki-language": "Wiki話語", - "config-wiki-language-help": "選一个Wiki大部份用的話", + "config-wiki-language": "Wiki gí-giân:", "config-back": "← 倒退", "config-continue": "繼續 →", - "config-page-language": "話語", + "config-page-language": "Gí-giân", "config-page-welcome": "歡迎來MediaWiki!", "config-page-dbconnect": "連接去資料庫", "config-page-upgrade": "共這馬的安裝升級", @@ -53,6 +50,5 @@ "config-no-db": "揣無適合的資料庫驅動程式!你需要安裝 PHP 資料庫驅動程式。\n這馬支援下跤類型的資料庫: $1 。\n\n若你是家己編譯 PHP,你需要重新設定並且開資料庫客戶端,譬如:用 <code>./configure --with-mysqli</code> 指令參數。\n如你是用 Debian 或 Ubuntu 的套件安裝,你著需要閣另外安裝,例:<code>php-mysql</code> 套件。", "config-outdated-sqlite": "<strong>Kéng-kò:</strong> Lí í-keng an-chng SQLite $1, m̄-koh i--ê pán-pún pí thang-chng--ê pán-pún $2 khah kū. Só͘-í lí bô-hoat-tō͘ ēng SQLite.", "config-no-fts3": "<strong>Kéng-kò: </strong> SQLite tī pian-e̍k--ê sî-chūn bô pau-koat [//sqlite.org/fts3.html FTS3 module], āu-tâi chhiau-chhoē kong-lêng tiō ē bô-hoat-tō͘ iōng.", - "mainpagetext": "'''MediaWiki已經裝好矣。'''", - "mainpagedocfooter": "請查看[https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents 用者說明書]的資料通使用wiki 軟體\n\n== 入門 ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings 配置的設定]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki時常問答]\n* [https://lists.wikimedia.org/postorius/lists/mediawiki-announce.lists.wikimedia.org/ MediaWiki的公布列單]" + "mainpagetext": "'''MediaWiki已經裝好矣。'''" } diff --git a/includes/libs/Stats/StatsFactory.php b/includes/libs/Stats/StatsFactory.php index 0307a8e237c1..18bc8118cf1d 100644 --- a/includes/libs/Stats/StatsFactory.php +++ b/includes/libs/Stats/StatsFactory.php @@ -177,13 +177,13 @@ class StatsFactory { } /** - * Returns an instance of StatsFactory as a NULL value object - * as a default for consumer code to fall back to. This can also - * be used in tests environment where we don't need the full - * UDP emitter object. + * Create a no-op StatsFactory. * - * @since 1.42 + * Use this as the default in a service that takes an optional StatsFactory, + * or as null implementation in PHPUnit tests, where we don't need to send + * output to an actual network service. * + * @since 1.42 * @return self */ public static function newNull(): self { @@ -191,14 +191,17 @@ class StatsFactory { } /** - * Returns an instance of UnitTestingHelper. + * Create a stats helper for use in PHPUnit tests. * * Example: + * * ```php - * $unitTestingHelper = StatsFactory::newUnitTestingHelper(); - * $statsFactory = $unitTestingHelper->getStatsFactory() - * MyClass( $statsFactory )->execute(); - * $this->assertEquals( 1, $unitTestingHelper->count( 'example_executions_total{fooLabel="bar"}' ) ); + * $statsHelper = StatsFactory::newUnitTestingHelper(); + * + * $x = new MySubject( $statsHelper->getStatsFactory() ); + * $x->execute(); + * + * $this->assertEquals( 1, $statsHelper->count( 'example_executions_total{fooLabel="bar"}' ) ); * ``` * * @since 1.44 diff --git a/includes/libs/Stats/UnitTestingHelper.php b/includes/libs/Stats/UnitTestingHelper.php index 619dea7391ba..58ef56588e42 100644 --- a/includes/libs/Stats/UnitTestingHelper.php +++ b/includes/libs/Stats/UnitTestingHelper.php @@ -46,14 +46,17 @@ class UnitTestingHelper { private string $component = ''; private LoggerInterface $logger; + /** + * @internal Use StatsFactory::newUnitTestingHelper() instead. + */ public function __construct() { $this->cache = new StatsCache(); - $this->logger = LoggerFactory::getInstance( 'Stats::UnitTestingHelper' ); + $this->logger = LoggerFactory::getInstance( 'Stats' ); $this->factory = new StatsFactory( $this->cache, new NullEmitter(), $this->logger ); } /** - * Sets the component for the StatsFactory instance + * Set a component on the underlying StatsFactory */ public function withComponent( string $component ): self { $this->factory = $this->factory->withComponent( $component ); @@ -62,14 +65,14 @@ class UnitTestingHelper { } /** - * Returns the testing StatsFactory instance. + * Get the underlying StatsFactory, to pass to your subject under test. */ public function getStatsFactory(): StatsFactory { return $this->factory; } /** - * Returns a count of the number of samples for a given metric. + * How many samples were observed for a given metric. * * Example: * ```php @@ -81,7 +84,7 @@ class UnitTestingHelper { } /** - * Returns the last recorded sample value for a given metric. + * The last recorded sample value for a given metric. * * Example: * ```php @@ -94,7 +97,7 @@ class UnitTestingHelper { } /** - * Returns the sum of all sample values for a given metric. + * The sum of all sample values for a given metric. * * Example: * ```php @@ -110,7 +113,7 @@ class UnitTestingHelper { } /** - * Returns the max of all sample values for a given metric. + * The max of all sample values for a given metric. * * Example: * ```php @@ -128,8 +131,7 @@ class UnitTestingHelper { } /** - * Returns the median of all sample values for a given metric. - * + * The median of all sample values for a given metric. * * Example: * ```php @@ -141,8 +143,7 @@ class UnitTestingHelper { } /** - * Returns the min of all sample values for a given metric. - * + * The min of all sample values for a given metric. * * Example: * ```php @@ -159,9 +160,6 @@ class UnitTestingHelper { return $output; } - /** - * Fetches the metric instance from cache. - */ private function getMetricFromSelector( string $selector ): MetricInterface { $key = StatsCache::cacheKey( $this->component, $this->getName( $selector ) ); $metric = $this->cache->getAllMetrics()[$key] ?? null; @@ -178,9 +176,6 @@ class UnitTestingHelper { return $metric; } - /** - * Returns a subset of samples according to provided filters. - */ private function getFilteredSamples( string $selector ): array { $metric = $this->getMetricFromSelector( $selector ); $filters = $this->getFilters( $selector ); @@ -203,9 +198,6 @@ class UnitTestingHelper { return $left; } - /** - * Returns the metric name from a selector. - */ private function getName( string $selector ): string { $selector = preg_replace( '/\'/', '"', $selector ); if ( str_contains( $selector, '{' ) ) { @@ -217,9 +209,6 @@ class UnitTestingHelper { return $selector; } - /** - * Returns the filter set from a selector. - */ private function getFilters( string $selector ): array { $selector = preg_replace( '/\'/', '"', $selector ); if ( !str_contains( $selector, '{' ) && !str_contains( $selector, ',' ) ) { @@ -234,9 +223,6 @@ class UnitTestingHelper { return $output; } - /** - * Returns the components of a filter expression. - */ private function getFilterComponents( string $filter ): array { $key = null; $value = null; @@ -270,7 +256,7 @@ class UnitTestingHelper { } /** - * Returns the boolean result of stored and expected values according to the operator. + * Return the boolean result of stored and expected values according to the operator. */ private function matches( string $stored, string $expected, string $operator ): bool { if ( $operator === self::NOT_EQUALS ) { diff --git a/includes/logging/ManualLogEntry.php b/includes/logging/ManualLogEntry.php index 46a29a9133c0..d2ea485c1072 100644 --- a/includes/logging/ManualLogEntry.php +++ b/includes/logging/ManualLogEntry.php @@ -438,14 +438,17 @@ class ManualLogEntry extends LogEntryBase implements Taggable { $canAddTags = false; } - DeferredUpdates::addCallableUpdate( - function () use ( $newId, $to, $canAddTags ) { - $log = new LogPage( $this->getType() ); - if ( !$log->isRestricted() ) { - ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) ) - ->onManualLogEntryBeforePublish( $this ); - $rc = $this->getRecentChange( $newId ); - + $log = new LogPage( $this->getType() ); + if ( !$log->isRestricted() ) { + // We need to generate a RecentChanges object now so that we can have the rc_bot attribute set based + // on any temporary user rights assigned to the user as part of the creation of this log entry. + // We do not attempt to save it to the DB until POSTSEND to avoid writes blocking a response (T127852). + ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) ) + ->onManualLogEntryBeforePublish( $this ); + $rc = $this->getRecentChange( $newId ); + + DeferredUpdates::addCallableUpdate( + function () use ( $newId, $to, $canAddTags, $rc ) { if ( $to === 'rc' || $to === 'rcandudp' ) { // save RC, passing tags so they are applied there $rc->addTags( $this->getTags() ); @@ -466,11 +469,11 @@ class ManualLogEntry extends LogEntryBase implements Taggable { if ( $to === 'udp' || $to === 'rcandudp' ) { $rc->notifyRCFeeds(); } - } - }, - DeferredUpdates::POSTSEND, - MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase() - ); + }, + DeferredUpdates::POSTSEND, + MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase() + ); + } } /** diff --git a/includes/page/Article.php b/includes/page/Article.php index c6ff94f24503..962990012952 100644 --- a/includes/page/Article.php +++ b/includes/page/Article.php @@ -784,7 +784,7 @@ class Article implements Page { $outputPage->setRevisionId( $this->getRevIdFetched() ); $outputPage->setRevisionIsCurrent( $rev->isCurrent() ); # Preload timestamp to avoid a DB hit - $outputPage->setRevisionTimestamp( $rev->getTimestamp() ); + $outputPage->getMetadata()->setRevisionTimestamp( $rev->getTimestamp() ); # Pages containing custom CSS or JavaScript get special treatment if ( $this->getTitle()->isSiteConfigPage() || $this->getTitle()->isUserConfigPage() ) { @@ -929,7 +929,7 @@ class Article implements Page { # Preload timestamp to avoid a DB hit $cachedTimestamp = $pOutput->getRevisionTimestamp(); if ( $cachedTimestamp !== null ) { - $outputPage->setRevisionTimestamp( $cachedTimestamp ); + $outputPage->getMetadata()->setRevisionTimestamp( $cachedTimestamp ); $this->mPage->setTimestamp( $cachedTimestamp ); } } @@ -972,7 +972,7 @@ class Article implements Page { $cachedId = $pOutput->getCacheRevisionId(); if ( $cachedId !== null ) { $outputPage->setRevisionId( $cachedId ); - $outputPage->setRevisionTimestamp( $pOutput->getTimestamp() ); + $outputPage->getMetadata()->setRevisionTimestamp( $pOutput->getTimestamp() ); } } diff --git a/includes/page/Event/PageDeletedListener.php b/includes/page/Event/PageDeletedListener.php index 93ad81570fe1..9b3eaa2af8b5 100644 --- a/includes/page/Event/PageDeletedListener.php +++ b/includes/page/Event/PageDeletedListener.php @@ -9,6 +9,7 @@ namespace MediaWiki\Page\Event; * event type 'PageDeleted', see PageDeletedEvent::TYPE. * * @see PageDeletedEvent + * @unstable until 1.45, should become stable to implement */ interface PageDeletedListener { diff --git a/includes/page/Event/PageMovedListener.php b/includes/page/Event/PageMovedListener.php index 041812c26574..28dc060c9b2c 100644 --- a/includes/page/Event/PageMovedListener.php +++ b/includes/page/Event/PageMovedListener.php @@ -9,6 +9,7 @@ namespace MediaWiki\Page\Event; * event type 'PageMoved', see PageMovedEvent::TYPE. * * @see PageMovedEvent + * @unstable until 1.45, should become stable to implement */ interface PageMovedListener { diff --git a/includes/page/Event/PageRevisionUpdatedListener.php b/includes/page/Event/PageRevisionUpdatedListener.php index 598da5cb848d..d2ed812d712a 100644 --- a/includes/page/Event/PageRevisionUpdatedListener.php +++ b/includes/page/Event/PageRevisionUpdatedListener.php @@ -9,6 +9,7 @@ namespace MediaWiki\Page\Event; * event type 'PageRevisionUpdated', see PageRevisionUpdatedEvent::TYPE. * * @see PageRevisionUpdatedEvent + * @unstable until 1.45, should become stable to implement */ interface PageRevisionUpdatedListener { diff --git a/includes/page/Event/PageStateListener.php b/includes/page/Event/PageStateListener.php index 3ff3c1427ef2..6629bae38ba0 100644 --- a/includes/page/Event/PageStateListener.php +++ b/includes/page/Event/PageStateListener.php @@ -9,6 +9,7 @@ namespace MediaWiki\Page\Event; * event type 'PageState', see PageStateEvent::TYPE. * * @see PageStateEvent + * @unstable until 1.45, should become stable to implement */ interface PageStateListener { diff --git a/includes/pager/ContributionsPager.php b/includes/pager/ContributionsPager.php index ebd1efa87869..fdebeb41f92e 100644 --- a/includes/pager/ContributionsPager.php +++ b/includes/pager/ContributionsPager.php @@ -920,12 +920,14 @@ abstract class ContributionsPager extends RangeChronologicalPager { $revUserId = $revUser ? $revUser->getId() : 0; $revUserText = $revUser ? $revUser->getName() : ''; if ( $this->target !== $revUserText ) { - $userlink = ' <span class="mw-changeslist-separator"></span> ' - . Html::rawElement( 'bdi', [ 'dir' => $dir ], - Linker::userLink( $revUserId, $revUserText ) ); - $userlink .= ' ' . $this->msg( 'parentheses' )->rawParams( - Linker::userTalkLink( $revUserId, $revUserText ) )->escaped() . ' '; + $userPageLink = Linker::userLink( $revUserId, $revUserText ); + $userTalkLink = Linker::userTalkLink( $revUserId, $revUserText ); + + $userlink = ' <span class="mw-changeslist-separator"></span> ' . + Html::rawElement( 'bdi', [ 'dir' => $dir ], $userPageLink ) . + Linker::renderUserToolLinksArray( [ $userTalkLink ], false ); } + return $userlink; } diff --git a/includes/parser/Parsoid/Config/SiteConfig.php b/includes/parser/Parsoid/Config/SiteConfig.php index 4e91f19aa96d..8a61b61cde11 100644 --- a/includes/parser/Parsoid/Config/SiteConfig.php +++ b/includes/parser/Parsoid/Config/SiteConfig.php @@ -822,6 +822,14 @@ class SiteConfig extends ISiteConfig { return $this->config->get( MainConfigNames::ExternalLinkTarget ); } + /** + * Return the localization key we should use for asynchronous + * fallback content. + */ + public function getAsyncFallbackMessageKey(): string { + return 'parsoid-async-not-ready-fallback'; + } + // MW-specific helper /** diff --git a/includes/specials/SpecialVersion.php b/includes/specials/SpecialVersion.php index 8584e96567dc..e1c9388f1884 100644 --- a/includes/specials/SpecialVersion.php +++ b/includes/specials/SpecialVersion.php @@ -225,6 +225,7 @@ class SpecialVersion extends SpecialPage { $this->getLibraries( $credits ), $this->getParserTags(), $this->getParserFunctionHooks(), + $this->getParsoidModules(), $this->getHooks(), $this->IPInfo(), ]; @@ -954,6 +955,45 @@ class SpecialVersion extends SpecialPage { } /** + * Obtains a list of installed Parsoid Modules and the associated H2 header + * + * @return string HTML output + */ + protected function getParsoidModules() { + $siteConfig = MediaWikiServices::getInstance()->getParsoidSiteConfig(); + $modules = $siteConfig->getExtensionModules(); + + if ( !$modules ) { + return ''; + } + + $this->addTocSection( 'version-parsoid-modules', 'mw-version-parsoid-modules' ); + + $out = Html::rawElement( + 'h2', + [ 'id' => 'mw-version-parsoid-modules' ], + Html::rawElement( + 'span', + [ 'class' => 'plainlinks' ], + $this->getLinkRenderer()->makeExternalLink( + 'https://www.mediawiki.org/wiki/Special:MyLanguage/Parsoid', + $this->msg( 'version-parsoid-modules' ), + $this->getFullTitle() + ) + ) + ); + + $moduleNames = array_map( + static fn ( $m )=>Html::element( 'code', [], $m->getConfig()['name'] ), + $modules + ); + + $out .= $this->getLanguage()->listToText( $moduleNames ); + + return $out; + } + + /** * Creates and returns the HTML for a single extension category. * * @since 1.17 |