Merge "jobqueue: Use explicit retry when refreshLinks can't get a lock"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Wed, 29 Aug 2018 21:49:35 +0000 (21:49 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Wed, 29 Aug 2018 21:49:35 +0000 (21:49 +0000)
56 files changed:
.gitignore
.phpcs.xml
RELEASE-NOTES-1.32
composer.json
includes/DefaultSettings.php
includes/OutputPage.php
includes/Setup.php
includes/api/ApiParse.php
includes/api/i18n/zh-hans.json
includes/api/i18n/zh-hant.json
includes/changetags/ChangeTags.php
includes/content/TextContent.php
includes/htmlform/HTMLForm.php
includes/htmlform/HTMLFormField.php
includes/json/FormatJson.php
includes/page/Article.php
includes/parser/Parser.php
includes/parser/ParserOutput.php
includes/registration/VersionChecker.php
includes/resourceloader/ResourceLoader.php
includes/resourceloader/ResourceLoaderModule.php
includes/resourceloader/ResourceLoaderSkinModule.php
includes/resourceloader/ResourceLoaderStartUpModule.php
includes/skins/Skin.php
languages/LanguageConverter.php
languages/i18n/ast.json
languages/i18n/be-tarask.json
languages/i18n/bg.json
languages/i18n/bn.json
languages/i18n/cs.json
languages/i18n/da.json
languages/i18n/de.json
languages/i18n/fa.json
languages/i18n/fi.json
languages/i18n/gcr.json
languages/i18n/my.json
languages/i18n/nl.json
languages/i18n/nn.json
languages/i18n/ru.json
languages/i18n/sr-ec.json
languages/i18n/tr.json
languages/i18n/tt-cyrl.json
languages/i18n/ur.json
languages/i18n/zh-hant.json
maintenance/Doxyfile
maintenance/generateSitemap.php
maintenance/resources/foreign-resources.yaml
maintenance/resources/manageForeignResources.php
resources/src/mediawiki.notification/notification.js
resources/src/startup/mediawiki.js
tests/phpunit/includes/OutputPageTest.php
tests/phpunit/includes/api/ApiParseTest.php
tests/phpunit/includes/page/ArticleViewTest.php [new file with mode: 0644]
tests/phpunit/includes/parser/ParserMethodsTest.php
tests/phpunit/includes/parser/ParserOutputTest.php
tests/phpunit/includes/resourceloader/ResourceLoaderSkinModuleTest.php

index d440e72..248931e 100644 (file)
@@ -43,7 +43,6 @@ sftp-config.json
 /maintenance/dev/data
 /AdminSettings.php
 /LocalSettings.php
-/StartProfiler.php
 
 # Building & testing
 npm-debug.log
index 65ddb73..40101cb 100644 (file)
        <exclude-pattern type="relative">^skins/</exclude-pattern>
        <exclude-pattern>AdminSettings\.php</exclude-pattern>
        <exclude-pattern>LocalSettings\.php</exclude-pattern>
-       <exclude-pattern>StartProfiler\.php</exclude-pattern>
 </ruleset>
index 1deca12..7ab2134 100644 (file)
@@ -278,10 +278,10 @@ because of Phabricator reports.
   a no-op function since 1.30.
 * SpecialPageFactory::resetList() is a no-op.  Call overrideMwServices()
   instead.
+* MediaWiki no longer supports a StartProfiler.php file.
+  Define $wgProfiler via LocalSettings.php instead.
 
 === Deprecations in 1.32 ===
-* Use of a StartProfiler.php file is deprecated in favour of placing
-  configuration in LocalSettings.php.
 * HTMLForm::setSubmitProgressive() is deprecated. No need to call it. Submit
   button is already marked as progressive.
 * Skin::setupSkinUserCss() is deprecated. Adding of modules to load
@@ -398,6 +398,10 @@ because of Phabricator reports.
   mw.user.getPageviewToken to better capture its function.
 * Passing Revision objects to ContentHandler::getUndoContent() is deprecated,
   Content object should be passed instead.
+* (T197179) Parameters 'notice', 'notice-messages', 'notice-message',
+  previously used by OOUI HTMLForm fields, are now deprecated. Use
+  'help', 'help-message', 'help-messages' instead.
+* (T197179) HTMLFormField::getNotices() is now deprecated.
 
 === Other changes in 1.32 ===
 * (T198811) The following tables have had their UNIQUE indexes turned into
index 454e27c..4284260 100644 (file)
@@ -57,6 +57,7 @@
        "require-dev": {
                "cache/integration-tests": "0.16.0",
                "composer/spdx-licenses": "1.4.0",
+               "giorgiosironi/eris": "^0.10.0",
                "hamcrest/hamcrest-php": "^2.0",
                "jakub-onderka/php-parallel-lint": "0.9.2",
                "jetbrains/phpstorm-stubs": "dev-master#38ff1a581b297f7901e961b8c923862ea80c3b96",
index 2499077..4ddc23e 100644 (file)
@@ -6348,8 +6348,6 @@ $wgDeprecationReleaseLimit = false;
  * Profiler configuration.
  *
  * To use a profiler, set $wgProfiler in LocalSetings.php.
- * For backwards-compatibility, it is also allowed to set the variable from
- * a separate file called StartProfiler.php, which MediaWiki will include.
  *
  * Example:
  *
index c8e18e0..3675e8a 100644 (file)
@@ -2364,10 +2364,6 @@ class OutputPage extends ContextSource {
 
                if ( !$this->mArticleBodyOnly ) {
                        $sk = $this->getSkin();
-
-                       if ( $sk->shouldPreloadLogo() ) {
-                               $this->addLogoPreloadLinkHeaders();
-                       }
                }
 
                $linkHeader = $this->getLinkHeader();
@@ -3915,80 +3911,6 @@ class OutputPage extends ContextSource {
                ] );
        }
 
-       /**
-        * Add Link headers for preloading the wiki's logo.
-        *
-        * @since 1.26
-        */
-       protected function addLogoPreloadLinkHeaders() {
-               $logo = ResourceLoaderSkinModule::getLogo( $this->getConfig() );
-
-               $tags = [];
-               $logosPerDppx = [];
-               $logos = [];
-
-               if ( !is_array( $logo ) ) {
-                       // No media queries required if we only have one variant
-                       $this->addLinkHeader( '<' . $logo . '>;rel=preload;as=image' );
-                       return;
-               }
-
-               if ( isset( $logo['svg'] ) ) {
-                       // No media queries required if we only have a 1x and svg variant
-                       // because all preload-capable browsers support SVGs
-                       $this->addLinkHeader( '<' . $logo['svg'] . '>;rel=preload;as=image' );
-                       return;
-               }
-
-               foreach ( $logo as $dppx => $src ) {
-                       // Keys are in this format: "1.5x"
-                       $dppx = substr( $dppx, 0, -1 );
-                       $logosPerDppx[$dppx] = $src;
-               }
-
-               // Because PHP can't have floats as array keys
-               uksort( $logosPerDppx, function ( $a , $b ) {
-                       $a = floatval( $a );
-                       $b = floatval( $b );
-                       // Sort from smallest to largest (e.g. 1x, 1.5x, 2x)
-                       return $a <=> $b;
-               } );
-
-               foreach ( $logosPerDppx as $dppx => $src ) {
-                       $logos[] = [ 'dppx' => $dppx, 'src' => $src ];
-               }
-
-               $logosCount = count( $logos );
-               // Logic must match ResourceLoaderSkinModule:
-               // - 1x applies to resolution < 1.5dppx
-               // - 1.5x applies to resolution >= 1.5dppx && < 2dppx
-               // - 2x applies to resolution >= 2dppx
-               // Note that min-resolution and max-resolution are both inclusive.
-               for ( $i = 0; $i < $logosCount; $i++ ) {
-                       if ( $i === 0 ) {
-                               // Smallest dppx
-                               // min-resolution is ">=" (larger than or equal to)
-                               // "not min-resolution" is essentially "<"
-                               $media_query = 'not all and (min-resolution: ' . $logos[ 1 ]['dppx'] . 'dppx)';
-                       } elseif ( $i !== $logosCount - 1 ) {
-                               // In between
-                               // Media query expressions can only apply "not" to the entire expression
-                               // (e.g. can't express ">= 1.5 and not >= 2).
-                               // Workaround: Use <= 1.9999 in place of < 2.
-                               $upper_bound = floatval( $logos[ $i + 1 ]['dppx'] ) - 0.000001;
-                               $media_query = '(min-resolution: ' . $logos[ $i ]['dppx'] .
-                                       'dppx) and (max-resolution: ' . $upper_bound . 'dppx)';
-                       } else {
-                               // Largest dppx
-                               $media_query = '(min-resolution: ' . $logos[ $i ]['dppx'] . 'dppx)';
-                       }
-
-                       $this->addLinkHeader(
-                               '<' . $logos[$i]['src'] . '>;rel=preload;as=image;media=' . $media_query
-                       );
-               }
-       }
-
        /**
         * Get (and set if not yet set) the CSP nonce.
         *
index c015eac..43bc2d8 100644 (file)
@@ -86,11 +86,6 @@ MediaWiki\HeaderCallback::register();
  * Load LocalSettings.php
  */
 
-if ( is_readable( "$IP/StartProfiler.php" ) ) {
-       // @deprecated since 1.32: Use LocalSettings.php instead.
-       require "$IP/StartProfiler.php";
-}
-
 if ( defined( 'MW_CONFIG_CALLBACK' ) ) {
        call_user_func( MW_CONFIG_CALLBACK );
 } else {
index 3a60471..5c25b5a 100644 (file)
@@ -341,7 +341,7 @@ class ApiParse extends ApiBase {
                        $result_array['text'] = $p_result->getText( [
                                'allowTOC' => !$params['disabletoc'],
                                'enableSectionEditLinks' => !$params['disableeditsection'],
-                               'unwrap' => $params['wrapoutputclass'] === '',
+                               'wrapperDivClass' => $params['wrapoutputclass'],
                                'deduplicateStyles' => !$params['disablestylededuplication'],
                        ] );
                        $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'text';
index 5cba292..60cf575 100644 (file)
@@ -25,7 +25,8 @@
                        "Umherirrender",
                        "NeverBehave",
                        "Wbxshiori",
-                       "Wxyveronica"
+                       "Wxyveronica",
+                       "WhitePhosphorus"
                ]
        },
        "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|文档]]\n* [[mw:Special:MyLanguage/API:FAQ|常见问题]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api 邮件列表]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API公告]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R 程序错误与功能请求]\n</div>\n<strong>状态信息:</strong>MediaWiki API是一个成熟稳定的,不断受到支持和改进的界面。尽管我们尽力避免,但偶尔也需要作出重大更新;请订阅[https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ mediawiki-api-announce 邮件列表]以便获得更新通知。\n\n<strong>错误请求:</strong>当API收到错误请求时,HTTP header将会返回一个包含\"MediaWiki-API-Error\"的值,随后header的值与error code将会送回并设置为相同的值。详细信息请参阅[[mw:Special:MyLanguage/API:Errors_and_warnings|API:错误与警告]]。\n\n<p class=\"mw-apisandbox-link\"><strong>测试中:</strong>测试API请求的易用性,请参见[[Special:ApiSandbox]]。</p>",
        "apierror-mustbeloggedin-linkaccounts": "您必须登录以链接账户。",
        "apierror-mustbeloggedin-removeauth": "您必须登录以移除身份验证数据。",
        "apierror-mustbeloggedin-uploadstash": "上传暂存功能只对已登录用户可用。",
-       "apierror-mustbeloggedin": "您必须登录$1。",
+       "apierror-mustbeloggedin": "您必须登录才能$1。",
        "apierror-mustbeposted": "<kbd>$1</kbd>模块需要POST请求。",
        "apierror-mustpostparams": "以下{{PLURAL:$2|参数}}在查询字符串中被找到,但必须在POST正文中:$1。",
        "apierror-noapiwrite": "通过API编辑此wiki已禁用。请确保<code>$wgEnableWriteAPI=true;</code>声明包含在wiki的<code>LocalSettings.php</code>文件中。",
index 293fac3..b6147dd 100644 (file)
@@ -16,7 +16,8 @@
                        "Wwycheuk",
                        "Wbxshiori",
                        "Sanmosa",
-                       "Kly"
+                       "Kly",
+                       "WhitePhosphorus"
                ]
        },
        "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|說明文件]]\n* [[mw:Special:MyLanguage/API:FAQ|常見問題]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api 郵寄清單]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API公告]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R 報告錯誤及請求功能]\n</div>\n<strong>狀態資訊:</strong>MediaWiki API 已是成熟、穩定,並積極支援以改善的介面。儘管我們儘可能避免,但仍偶有需要重大變更的情況,請訂閱[https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ 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>要簡化 API 請求的測試過程,請見 [[Special:ApiSandbox]]。</p>",
        "apierror-mustbeloggedin-generic": "您必須登入。",
        "apierror-mustbeloggedin-linkaccounts": "您必須登入到連結帳號。",
        "apierror-mustbeloggedin-removeauth": "必須登入,才能移除身分核對資取。",
-       "apierror-mustbeloggedin": "您必須登入$1。",
+       "apierror-mustbeloggedin": "您必須登入才能$1。",
        "apierror-nodeleteablefile": "沒有這樣檔案的舊版本。",
        "apierror-noedit-anon": "匿名使用者不可編輯頁面。",
        "apierror-noedit": "您沒有權限來編輯頁面。",
index dd29c10..0bb8484 100644 (file)
@@ -342,11 +342,10 @@ class ChangeTags {
                }
 
                // insert a row into change_tag for each new tag
+               $changeTagDefStore = MediaWikiServices::getInstance()->getChangeTagDefStore();
                if ( count( $tagsToAdd ) ) {
                        $changeTagMapping = [];
                        if ( $wgChangeTagsSchemaMigrationStage > MIGRATION_OLD ) {
-                               $changeTagDefStore = MediaWikiServices::getInstance()->getChangeTagDefStore();
-
                                foreach ( $tagsToAdd as $tag ) {
                                        $changeTagMapping[$tag] = $changeTagDefStore->acquireId( $tag );
                                }
@@ -361,13 +360,18 @@ class ChangeTags {
 
                        $tagsRows = [];
                        foreach ( $tagsToAdd as $tag ) {
+                               if ( $wgChangeTagsSchemaMigrationStage > MIGRATION_WRITE_BOTH ) {
+                                       $tagName = null;
+                               } else {
+                                       $tagName = $tag;
+                               }
                                // Filter so we don't insert NULLs as zero accidentally.
                                // Keep in mind that $rc_id === null means "I don't care/know about the
                                // rc_id, just delete $tag on this revision/log entry". It doesn't
                                // mean "only delete tags on this revision/log WHERE rc_id IS NULL".
                                $tagsRows[] = array_filter(
                                        [
-                                               'ct_tag' => $tag,
+                                               'ct_tag' => $tagName,
                                                'ct_rc_id' => $rc_id,
                                                'ct_log_id' => $log_id,
                                                'ct_rev_id' => $rev_id,
@@ -384,12 +388,20 @@ class ChangeTags {
                // delete from change_tag
                if ( count( $tagsToRemove ) ) {
                        foreach ( $tagsToRemove as $tag ) {
+                               if ( $wgChangeTagsSchemaMigrationStage > MIGRATION_WRITE_BOTH ) {
+                                       $tagName = null;
+                                       $tagId = $changeTagDefStore->getId( $tag );
+                               } else {
+                                       $tagName = $tag;
+                                       $tagId = null;
+                               }
                                $conds = array_filter(
                                        [
-                                               'ct_tag' => $tag,
+                                               'ct_tag' => $tagName,
                                                'ct_rc_id' => $rc_id,
                                                'ct_log_id' => $log_id,
-                                               'ct_rev_id' => $rev_id
+                                               'ct_rev_id' => $rev_id,
+                                               'ct_tag_id' => $tagId,
                                        ]
                                );
                                $dbw->delete( 'change_tag', $conds, __METHOD__ );
@@ -769,7 +781,7 @@ class ChangeTags {
        public static function modifyDisplayQuery( &$tables, &$fields, &$conds,
                &$join_conds, &$options, $filter_tag = ''
        ) {
-               global $wgUseTagFilter;
+               global $wgUseTagFilter, $wgChangeTagsSchemaMigrationStage;
 
                // Normalize to arrays
                $tables = (array)$tables;
@@ -790,8 +802,16 @@ class ChangeTags {
                        throw new MWException( 'Unable to determine appropriate JOIN condition for tagging.' );
                }
 
+               if ( $wgChangeTagsSchemaMigrationStage > MIGRATION_WRITE_BOTH ) {
+                       $tables[] = 'change_tag_def';
+                       $join_cond = [ $join_cond, 'ct_tag_id=ctd_id' ];
+                       $field = 'ctd_name';
+               } else {
+                       $field = 'ct_tag';
+               }
+
                $fields['ts_tags'] = wfGetDB( DB_REPLICA )->buildGroupConcatField(
-                       ',', 'change_tag', 'ct_tag', $join_cond
+                       ',', 'change_tag', $field, $join_cond
                );
 
                if ( $wgUseTagFilter && $filter_tag ) {
@@ -800,7 +820,14 @@ class ChangeTags {
 
                        $tables[] = 'change_tag';
                        $join_conds['change_tag'] = [ 'INNER JOIN', $join_cond ];
-                       $conds['ct_tag'] = $filter_tag;
+                       if ( $wgChangeTagsSchemaMigrationStage > MIGRATION_WRITE_BOTH ) {
+                               $tables[] = 'change_tag_def';
+                               $join_conds['change_tag_def'] = [ 'INNER JOIN', 'ct_tag_id=ctd_id' ];
+                               $conds['ctd_name'] = $filter_tag;
+                       } else {
+                               $conds['ct_tag'] = $filter_tag;
+                       }
+
                        if (
                                is_array( $filter_tag ) && count( $filter_tag ) > 1 &&
                                !in_array( 'DISTINCT', $options )
@@ -1236,10 +1263,17 @@ class ChangeTags {
                // delete from valid_tag and/or set ctd_user_defined = 0
                self::undefineTag( $tag );
 
+               if ( $wgChangeTagsSchemaMigrationStage > MIGRATION_WRITE_BOTH ) {
+                       $tagId = MediaWikiServices::getInstance()->getChangeTagDefStore()->getId( $tag );
+                       $conditions = [ 'ct_tag_id' => $tagId ];
+               } else {
+                       $conditions = [ 'ct_tag' => $tag ];
+               }
+
                // find out which revisions use this tag, so we can delete from tag_summary
                $result = $dbw->select( 'change_tag',
-                       [ 'ct_rc_id', 'ct_log_id', 'ct_rev_id', 'ct_tag' ],
-                       [ 'ct_tag' => $tag ],
+                       [ 'ct_rc_id', 'ct_log_id', 'ct_rev_id' ],
+                       $conditions,
                        __METHOD__ );
                foreach ( $result as $row ) {
                        // remove the tag from the relevant row of tag_summary
@@ -1250,7 +1284,12 @@ class ChangeTags {
                }
 
                // delete from change_tag
-               $dbw->delete( 'change_tag', [ 'ct_tag' => $tag ], __METHOD__ );
+               if ( $wgChangeTagsSchemaMigrationStage > MIGRATION_WRITE_BOTH ) {
+                       $tagId = MediaWikiServices::getInstance()->getChangeTagDefStore()->getId( $tag );
+                       $dbw->delete( 'change_tag', [ 'ct_tag_id' => $tagId ], __METHOD__ );
+               } else {
+                       $dbw->delete( 'change_tag', [ 'ct_tag' => $tag ], __METHOD__ );
+               }
 
                if ( $wgChangeTagsSchemaMigrationStage > MIGRATION_OLD ) {
                        $dbw->delete( 'change_tag_def', [ 'ctd_name' => $tag ], __METHOD__ );
index 20bce37..0198a0d 100644 (file)
@@ -253,6 +253,7 @@ class TextContent extends AbstractContent {
                        $html = '';
                }
 
+               $output->clearWrapperDivClass();
                $output->setText( $html );
        }
 
index 43c9ee0..8e78459 100644 (file)
@@ -81,11 +81,9 @@ use Wikimedia\ObjectFactory;
  *    'help-inline'         -- Whether help text (defined using options above) will be shown
  *                             inline after the input field, rather than in a popup.
  *                             Defaults to true. Only used by OOUI form fields.
- *    'notice'              -- message text for a message to use as a notice in the field.
- *                             Currently used by OOUI form fields only.
- *    'notice-messages'     -- array of message keys/objects to use for notice.
- *                             Overrides 'notice'.
- *    'notice-message'      -- message key or object to use as a notice.
+ *    'notice'              -- (deprecated, use 'help' instead)
+ *    'notice-messages'     -- (deprecated, use 'help-messages' instead)
+ *    'notice-message'      -- (deprecated, use 'help-message' instead)
  *    'required'            -- passed through to the object, indicating that it
  *                             is a required field.
  *    'size'                -- the length of text fields
index f5d6e8c..bd08da0 100644 (file)
@@ -462,6 +462,16 @@ abstract class HTMLFormField {
                if ( isset( $params['hide-if'] ) ) {
                        $this->mHideIf = $params['hide-if'];
                }
+
+               if ( isset( $this->mParams['notice-message'] ) ) {
+                       wfDeprecated( "'notice-message' parameter in HTMLForm", '1.32' );
+               }
+               if ( isset( $this->mParams['notice-messages'] ) ) {
+                       wfDeprecated( "'notice-messages' parameter in HTMLForm", '1.32' );
+               }
+               if ( isset( $this->mParams['notice'] ) ) {
+                       wfDeprecated( "'notice' parameter in HTMLForm", '1.32' );
+               }
        }
 
        /**
@@ -607,7 +617,7 @@ abstract class HTMLFormField {
                        $error = new OOUI\HtmlSnippet( $error );
                }
 
-               $notices = $this->getNotices();
+               $notices = $this->getNotices( 'skip deprecation' );
                foreach ( $notices as &$notice ) {
                        $notice = new OOUI\HtmlSnippet( $notice );
                }
@@ -915,9 +925,16 @@ abstract class HTMLFormField {
         * Determine notices to display for the field.
         *
         * @since 1.28
+        * @deprecated since 1.32
+        * @param string $skipDeprecation Pass 'skip deprecation' to avoid the deprecation
+        *   warning (since 1.32)
         * @return string[]
         */
-       public function getNotices() {
+       public function getNotices( $skipDeprecation = null ) {
+               if ( $skipDeprecation !== 'skip deprecation' ) {
+                       wfDeprecated( __METHOD__, '1.32' );
+               }
+
                $notices = [];
 
                if ( isset( $this->mParams['notice-message'] ) ) {
index 1ab17a0..fbcb3bd 100644 (file)
@@ -271,7 +271,7 @@ class FormatJson {
                                        $lookAhead = ( $idx + 1 < $maxLen ) ? $str[$idx + 1] : '';
                                        $lookBehind = ( $idx - 1 >= 0 ) ? $str[$idx - 1] : '';
                                        if ( $inString ) {
-                                               continue;
+                                               break;
 
                                        } elseif ( !$inComment &&
                                                ( $lookAhead === '/' || $lookAhead === '*' )
index 3a7b18e..e90334f 100644 (file)
@@ -33,23 +33,30 @@ use MediaWiki\MediaWikiServices;
  * moved to separate EditPage and HTMLFileCache classes.
  */
 class Article implements Page {
-       /** @var IContextSource The context this Article is executed in */
+       /**
+        * @var IContextSource|null The context this Article is executed in.
+        * If null, REquestContext::getMain() is used.
+        */
        protected $mContext;
 
        /** @var WikiPage The WikiPage object of this instance */
        protected $mPage;
 
-       /** @var ParserOptions ParserOptions object for $wgUser articles */
+       /**
+        * @var ParserOptions|null ParserOptions object for $wgUser articles.
+        * Initialized by getParserOptions by calling $this->mPage->makeParserOptions().
+        */
        public $mParserOptions;
 
        /**
-        * @var string Text of the revision we are working on
+        * @var string|null Text of the revision we are working on
         * @todo BC cruft
         */
        public $mContent;
 
        /**
-        * @var Content Content of the revision we are working on
+        * @var Content|null Content of the revision we are working on.
+        * Initialized by fetchContentObject().
         * @since 1.21
         */
        public $mContentObject;
@@ -60,7 +67,7 @@ class Article implements Page {
        /** @var int|null The oldid of the article that is to be shown, 0 for the current revision */
        public $mOldId;
 
-       /** @var Title Title from which we were redirected here */
+       /** @var Title|null Title from which we were redirected here, if any. */
        public $mRedirectedFrom = null;
 
        /** @var string|bool URL to redirect to or false if none */
@@ -69,10 +76,16 @@ class Article implements Page {
        /** @var int Revision ID of revision we are working on */
        public $mRevIdFetched = 0;
 
-       /** @var Revision Revision we are working on */
+       /**
+        * @var Revision|null Revision we are working on. Initialized by getOldIDFromRequest()
+        * or fetchContentObject().
+        */
        public $mRevision = null;
 
-       /** @var ParserOutput */
+       /**
+        * @var ParserOutput|null|false The ParserOutput generated for viewing the page,
+        * initialized by view(). If no ParserOutput could be generated, this is set to false.
+        */
        public $mParserOutput;
 
        /**
@@ -641,7 +654,7 @@ class Article implements Page {
                # Note that $this->mParserOutput is the *current*/oldid version output.
                $pOutput = ( $outputDone instanceof ParserOutput )
                        ? $outputDone // object fetched by hook
-                       : $this->mParserOutput;
+                       : $this->mParserOutput ?: null; // ParserOutput or null, avoid false
 
                # Adjust title for main page & pages with displaytitle
                if ( $pOutput ) {
index 78265e8..c1f86b6 100644 (file)
@@ -517,7 +517,7 @@ class Parser {
                # with CSS (T37247)
                $class = $this->mOptions->getWrapOutputClass();
                if ( $class !== false && !$this->mOptions->getInterfaceMessage() ) {
-                       $text = Html::rawElement( 'div', [ 'class' => $class ], $text );
+                       $this->mOutput->addWrapperDivClass( $class );
                }
 
                $this->mOutput->setText( $text );
index 182648a..fe9913d 100644 (file)
@@ -212,6 +212,11 @@ class ParserOutput extends CacheTime {
        /** @var int|null Assumed rev ID for {{REVISIONID}} if no revision is set */
        private $mSpeculativeRevId;
 
+       /** string CSS classes to use for the wrapping div, stored in the array keys.
+        * If no class is given, no wrapper is added.
+        */
+       private $mWrapperDivClasses = [];
+
        /** @var int Upper bound of expiry based on parse duration */
        private $mMaxAdaptiveExpiry = INF;
 
@@ -258,7 +263,12 @@ class ParserOutput extends CacheTime {
         *  - enableSectionEditLinks: (bool) Include section edit links, assuming
         *     section edit link tokens are present in the HTML. Default is true,
         *     but might be statefully overridden.
-        *  - unwrap: (bool) Remove a wrapping mw-parser-output div. Default is false.
+        *  - unwrap: (bool) Return text without a wrapper div. Default is false,
+        *    meaning a wrapper div will be added if getWrapperDivClass() returns
+        *    a non-empty string.
+        *  - wrapperDivClass: (string) Wrap the output in a div and apply the given
+        *    CSS class to that div. This overrides the output of getWrapperDivClass().
+        *    Setting this to an empty string has the same effect as 'unwrap' => true.
         *  - deduplicateStyles: (bool) When true, which is the default, `<style>`
         *    tags with the `data-mw-deduplicate` attribute set are deduplicated by
         *    value of the attribute: all but the first will be replaced by `<link
@@ -273,28 +283,14 @@ class ParserOutput extends CacheTime {
                        'enableSectionEditLinks' => true,
                        'unwrap' => false,
                        'deduplicateStyles' => true,
+                       'wrapperDivClass' => $this->getWrapperDivClass(),
                ];
                $text = $this->mText;
 
                Hooks::runWithoutAbort( 'ParserOutputPostCacheTransform', [ $this, &$text, &$options ] );
 
-               if ( $options['unwrap'] !== false ) {
-                       $start = Html::openElement( 'div', [
-                               'class' => 'mw-parser-output'
-                       ] );
-                       $startLen = strlen( $start );
-                       $end = Html::closeElement( 'div' );
-                       $endPos = strrpos( $text, $end );
-                       $endLen = strlen( $end );
-
-                       if ( substr( $text, 0, $startLen ) === $start && $endPos !== false
-                               // if the closing div is followed by real content, bail out of unwrapping
-                               && preg_match( '/^(?>\s*<!--.*?-->)*\s*$/s', substr( $text, $endPos + $endLen ) )
-                       ) {
-                               $text = substr( $text, $startLen );
-                               $text = substr( $text, 0, $endPos - $startLen )
-                                       . substr( $text, $endPos - $startLen + $endLen );
-                       }
+               if ( $options['wrapperDivClass'] !== '' && !$options['unwrap'] ) {
+                       $text = Html::rawElement( 'div', [ 'class' => $options['wrapperDivClass'] ], $text );
                }
 
                if ( $options['enableSectionEditLinks'] ) {
@@ -365,6 +361,34 @@ class ParserOutput extends CacheTime {
                return $text;
        }
 
+       /**
+        * Add a CSS class to use for the wrapping div. If no class is given, no wrapper is added.
+        *
+        * @param string $class
+        */
+       public function addWrapperDivClass( $class ) {
+               $this->mWrapperDivClasses[$class] = true;
+       }
+
+       /**
+        * Clears the CSS class to use for the wrapping div, effectively disabling the wrapper div
+        * until addWrapperDivClass() is called.
+        */
+       public function clearWrapperDivClass() {
+               $this->mWrapperDivClasses = [];
+       }
+
+       /**
+        * Returns the class (or classes) to be used with the wrapper div for this otuput.
+        * If there is no wrapper class given, no wrapper div should be added.
+        * The wrapper div is added automatically by getText().
+        *
+        * @return string
+        */
+       public function getWrapperDivClass() {
+               return implode( ' ', array_keys( $this->mWrapperDivClasses ) );
+       }
+
        /**
         * @param int $id
         * @since 1.28
index 59853b4..1569e08 100644 (file)
@@ -183,23 +183,19 @@ class VersionChecker {
                                'missing' => $dependencyName,
                        ];
                }
+               if ( $constraint === '*' ) {
+                       // short-circuit since any version is OK.
+                       return false;
+               }
                // Check if the dependency has specified a version
                if ( !isset( $this->loaded[$dependencyName]['version'] ) ) {
-                       // If we depend upon any version, and none is set, that's fine.
-                       if ( $constraint === '*' ) {
-                               wfDebug( "{$dependencyName} does not expose its version, but {$checkedExt}"
-                                       . " mentions it with constraint '*'. Assume it's ok so." );
-                               return false;
-                       } else {
-                               // Otherwise, mark it as incompatible.
-                               $msg = "{$dependencyName} does not expose its version, but {$checkedExt}"
-                                       . " requires: {$constraint}.";
-                               return [
-                                       'msg' => $msg,
-                                       'type' => "incompatible-$type",
-                                       'incompatible' => $checkedExt,
-                               ];
-                       }
+                       $msg = "{$dependencyName} does not expose its version, but {$checkedExt}"
+                               . " requires: {$constraint}.";
+                       return [
+                               'msg' => $msg,
+                               'type' => "incompatible-$type",
+                               'incompatible' => $checkedExt,
+                       ];
                } else {
                        // Try to get a constraint for the dependency version
                        try {
index 98bae24..fc0ca1d 100644 (file)
@@ -135,7 +135,7 @@ class ResourceLoader implements LoggerAwareInterface {
                        $module = $this->getModule( $row->md_module );
                        if ( $module ) {
                                $module->setFileDependencies( $context, ResourceLoaderModule::expandRelativePaths(
-                                       FormatJson::decode( $row->md_deps, true )
+                                       json_decode( $row->md_deps, true )
                                ) );
                                $modulesWithDeps[] = $row->md_module;
                        }
@@ -1163,9 +1163,9 @@ MESSAGE;
                                $out = $this->ensureNewline( $out ) . $stateScript;
                        }
                } else {
-                       if ( count( $states ) ) {
-                               $this->errors[] = 'Problematic modules: ' .
-                                       FormatJson::encode( $states, self::inDebugMode() );
+                       if ( $states ) {
+                               // Keep default escaping of slashes (e.g. "</script>") for ResourceLoaderClientHtml.
+                               $this->errors[] = 'Problematic modules: ' . json_encode( $states, JSON_PRETTY_PRINT );
                        }
                }
 
index 3bf309d..02d5802 100644 (file)
@@ -416,7 +416,7 @@ abstract class ResourceLoaderModule implements LoggerAwareInterface {
 
                        if ( !is_null( $deps ) ) {
                                $this->fileDeps[$vary] = self::expandRelativePaths(
-                                       (array)FormatJson::decode( $deps, true )
+                                       (array)json_decode( $deps, true )
                                );
                        } else {
                                $this->fileDeps[$vary] = [];
@@ -476,7 +476,9 @@ abstract class ResourceLoaderModule implements LoggerAwareInterface {
                                        return; // T124649; avoid write slams
                                }
 
-                               $deps = FormatJson::encode( $localPaths );
+                               // No needless escaping as this isn't HTML output.
+                               // Only stored in the database and parsed in PHP.
+                               $deps = json_encode( $localPaths, JSON_UNESCAPED_SLASHES );
                                $dbw = wfGetDB( DB_MASTER );
                                $dbw->upsert( 'module_deps',
                                        [
index de25d32..2455596 100644 (file)
@@ -76,6 +76,89 @@ class ResourceLoaderSkinModule extends ResourceLoaderFileModule {
                return $styles;
        }
 
+       /**
+        * @param ResourceLoaderContext $context
+        * @return array
+        */
+       public function getPreloadLinks( ResourceLoaderContext $context ) {
+               return $this->getLogoPreloadlinks();
+       }
+
+       /**
+        * Helper method for getPreloadLinks()
+        * @return array
+        */
+       private function getLogoPreloadlinks() {
+               $logo = $this->getLogoData( $this->getConfig() );
+
+               $tags = [];
+               $logosPerDppx = [];
+               $logos = [];
+
+               $preloadLinks = [];
+
+               if ( !is_array( $logo ) ) {
+                       // No media queries required if we only have one variant
+                       $preloadLinks[ $logo ] = [ 'as' => 'image' ];
+                       return $preloadLinks;
+               }
+
+               if ( isset( $logo['svg'] ) ) {
+                       // No media queries required if we only have a 1x and svg variant
+                       // because all preload-capable browsers support SVGs
+                       $preloadLinks [ $logo['svg'] ] = [ 'as' => 'image' ];
+                       return $preloadLinks;
+               }
+
+               foreach ( $logo as $dppx => $src ) {
+                       // Keys are in this format: "1.5x"
+                       $dppx = substr( $dppx, 0, -1 );
+                       $logosPerDppx[$dppx] = $src;
+               }
+
+               // Because PHP can't have floats as array keys
+               uksort( $logosPerDppx, function ( $a , $b ) {
+                       $a = floatval( $a );
+                       $b = floatval( $b );
+                       // Sort from smallest to largest (e.g. 1x, 1.5x, 2x)
+                       return $a <=> $b;
+               } );
+
+               foreach ( $logosPerDppx as $dppx => $src ) {
+                       $logos[] = [ 'dppx' => $dppx, 'src' => $src ];
+               }
+
+               $logosCount = count( $logos );
+               // Logic must match ResourceLoaderSkinModule:
+               // - 1x applies to resolution < 1.5dppx
+               // - 1.5x applies to resolution >= 1.5dppx && < 2dppx
+               // - 2x applies to resolution >= 2dppx
+               // Note that min-resolution and max-resolution are both inclusive.
+               for ( $i = 0; $i < $logosCount; $i++ ) {
+                       if ( $i === 0 ) {
+                               // Smallest dppx
+                               // min-resolution is ">=" (larger than or equal to)
+                               // "not min-resolution" is essentially "<"
+                               $media_query = 'not all and (min-resolution: ' . $logos[ 1 ]['dppx'] . 'dppx)';
+                       } elseif ( $i !== $logosCount - 1 ) {
+                               // In between
+                               // Media query expressions can only apply "not" to the entire expression
+                               // (e.g. can't express ">= 1.5 and not >= 2).
+                               // Workaround: Use <= 1.9999 in place of < 2.
+                               $upper_bound = floatval( $logos[ $i + 1 ]['dppx'] ) - 0.000001;
+                               $media_query = '(min-resolution: ' . $logos[ $i ]['dppx'] .
+                                       'dppx) and (max-resolution: ' . $upper_bound . 'dppx)';
+                       } else {
+                               // Largest dppx
+                               $media_query = '(min-resolution: ' . $logos[ $i ]['dppx'] . 'dppx)';
+                       }
+
+                       $preloadLinks[ $logos[$i]['src'] ] = [ 'as' => 'image', 'media' => $media_query ];
+               }
+
+               return $preloadLinks;
+       }
+
        /**
         * Ensure all media keys use array values.
         *
@@ -93,25 +176,14 @@ class ResourceLoaderSkinModule extends ResourceLoaderFileModule {
        }
 
        /**
-        * Non-static proxy to ::getLogo (for overloading in sub classes or tests).
-        *
-        * @codeCoverageIgnore
         * @since 1.31
-        * @param Config $conf
-        * @return string|array
-        */
-       protected function getLogoData( Config $conf ) {
-               return static::getLogo( $conf );
-       }
-
-       /**
         * @param Config $conf
         * @return string|array Single url if no variants are defined,
         *  or an array of logo urls keyed by dppx in form "<float>x".
         *  Key "1x" is always defined. Key "svg" may also be defined,
         *  in which case variants other than "1x" are omitted.
         */
-       public static function getLogo( Config $conf ) {
+       protected function getLogoData( Config $conf ) {
                $logo = $conf->get( 'Logo' );
                $logoHD = $conf->get( 'LogoHD' );
 
index 99ffcd2..18cc4d5 100644 (file)
@@ -392,16 +392,16 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule {
                        $mwLoaderCode .= file_get_contents( "$IP/resources/src/startup/profiler.js" );
                }
 
-               $mapToJson = function ( $value ) {
-                       $value = FormatJson::encode( $value, ResourceLoader::inDebugMode(), FormatJson::ALL_OK );
-                       // Fix indentation
-                       $value = str_replace( "\n", "\n\t", $value );
-                       return $value;
-               };
+               // Keep output as small as possible by disabling needless escapes that PHP uses by default.
+               // This is not HTML output, only used in a JS response.
+               $jsonFlags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
+               if ( ResourceLoader::inDebugMode() ) {
+                       $jsonFlags |= JSON_PRETTY_PRINT;
+               }
 
                // Perform replacements for mediawiki.js
                $mwLoaderPairs = [
-                       '$VARS.baseModules' => $mapToJson( $this->getBaseModules() ),
+                       '$VARS.baseModules' => json_encode( $this->getBaseModules(), $jsonFlags ),
                ];
                $profilerStubs = [
                        '$CODE.profileExecuteStart();' => 'mw.loader.profiler.onExecuteStart( module );',
@@ -418,18 +418,20 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule {
                }
                $mwLoaderCode = strtr( $mwLoaderCode, $mwLoaderPairs );
 
-               // Perform replacements for startup.js
-               $pairs = array_map( $mapToJson, [
-                       '$VARS.wgLegacyJavaScriptGlobals' => $this->getConfig()->get( 'LegacyJavaScriptGlobals' ),
-                       '$VARS.configuration' => $this->getConfigSettings( $context ),
-               ] );
-               // Raw JavaScript code (not for JSON)
-               $pairs['$CODE.registrations();'] = str_replace(
-                       "\n",
-                       "\n\t",
-                       trim( $this->getModuleRegistrations( $context ) )
-               );
-               $pairs['$CODE.defineLoader();'] = $mwLoaderCode;
+               // Perform string replacements for startup.js
+               $pairs = [
+                       '$VARS.wgLegacyJavaScriptGlobals' => json_encode(
+                               $this->getConfig()->get( 'LegacyJavaScriptGlobals' ),
+                               $jsonFlags
+                       ),
+                       '$VARS.configuration' => json_encode(
+                               $this->getConfigSettings( $context ),
+                               $jsonFlags
+                       ),
+                       // Raw JavaScript code (not JSON)
+                       '$CODE.registrations();' => trim( $this->getModuleRegistrations( $context ) ),
+                       '$CODE.defineLoader();' => $mwLoaderCode,
+               ];
                $startupCode = strtr( $startupCode, $pairs );
 
                return $startupCode;
index b05fb0b..2f5e0c8 100644 (file)
@@ -503,6 +503,10 @@ abstract class Skin extends ContextSource {
 
        /**
         * Whether the logo should be preloaded with an HTTP link header or not
+        *
+        * @deprecated since 1.32 Redundant. It now happens automatically based on whether
+        *  the skin loads a stylesheet based on ResourceLoaderSkinModule, which all
+        *  skins that use wgLogo in CSS do, and other's would not.
         * @since 1.29
         * @return bool
         */
index c098518..cb0f66f 100644 (file)
@@ -773,7 +773,7 @@ class LanguageConverter {
                                                        $warningDone = true;
                                                }
                                                $startPos += 2;
-                                               continue;
+                                               break;
                                        }
                                        // Recursively parse another rule
                                        $inner .= $this->recursiveConvertRule( $text, $variant, $startPos, $depth + 1 );
index aaea4c7..b1977cf 100644 (file)
        "confirm-unwatch-top": "¿Desaniciar esta páxina de la to llista de vixilancia?",
        "confirm-rollback-button": "Aceutar",
        "confirm-rollback-top": "¿Revertir les ediciones a esta páxina?",
+       "confirm-mcrundo-title": "Desfacer un cambéu",
+       "mcrundofailed": "Falló desfacer",
+       "mcrundo-missingparam": "Faltan parámetros riquíos na solicitú.",
+       "mcrundo-changed": "La páxina cambió desque visti les diferencies. Revisa'l cambiu nuevu.",
        "quotation-marks": "«$1»",
        "imgmultipageprev": "← páxina anterior",
        "imgmultipagenext": "páxina siguiente →",
index b26a8ba..501d6ed 100644 (file)
        "backend-fail-hashes": "Немагчыма атрымаць хэшы файлаў для параўнаньня.",
        "backend-fail-notsame": "Ужо існуе неідэнтычны файл «$1».",
        "backend-fail-invalidpath": "«$1» не зьяўляецца слушным шляхам да сховішча.",
-       "backend-fail-delete": "Немагчыма выдаліць файл $1.",
+       "backend-fail-delete": "Немагчыма выдаліць файл «$1».",
        "backend-fail-describe": "Не атрымалася зьмяніць мэтазьвесткі для файла «$1».",
        "backend-fail-alreadyexists": "Файл $1 ужо існуе.",
        "backend-fail-store": "Немагчыма захаваць файл $1 у $2.",
        "confirm-rollback-top": "Адкаціць праўкі на гэтай старонцы?",
        "confirm-mcrundo-title": "Адмяніць зьмену",
        "mcrundofailed": "Адмена не атрымалася",
+       "mcrundo-missingparam": "Адсутнічаюць абавязковыя парамэтры для запыту.",
+       "mcrundo-changed": "Гэтая старонка была зьмененая з моманту, калі вы праглядалі зьмены. Калі ласка, праглядзіце новую зьмену.",
        "quotation-marks": "«$1»",
        "imgmultipageprev": "← папярэдняя старонка",
        "imgmultipagenext": "наступная старонка →",
index 7d39af6..ee6699b 100644 (file)
        "ns-specialprotected": "Специалните страници не могат да бъдат редактирани.",
        "titleprotected": "Тази страница е била защитена срещу създаване от [[User:$1|$1]].\nПосочената причина е <em>$2</em>.",
        "filereadonlyerror": "Файлът „$1“ не може да бъде променен, тъй като файловото хранилище „$2“ е в режим само за четене.\n\nСистемният администратор, който го е заключил, е посочил следната причина: „$3“.",
+       "invalidtitle": "Невалидно заглавие",
        "invalidtitle-knownnamespace": "Невалидно заглавие с именно пространство „$2“ и текст „$3“",
        "invalidtitle-unknownnamespace": "Невалидно заглавие с неразпознато именно пространство номер $1 и текст „$2“",
        "exception-nologin": "Не сте влезли в системата",
        "rcfilters-group-results-by-page": "Групиране на резултатите по страница",
        "rcfilters-activefilters": "Активни филтри",
        "rcfilters-activefilters-hide": "Скриване",
+       "rcfilters-activefilters-show": "Показване",
        "rcfilters-advancedfilters": "Разширени филтри",
        "rcfilters-limit-title": "Резултати за показване",
        "rcfilters-limit-and-date-label": "$1 {{PLURAL:$1|промяна|промени}}, $2",
index 2e7eb73..17463ab 100644 (file)
        "feed-atom": "অ্যাটম",
        "red-link-title": "$1 (পাতার অস্তিত্ব নেই)",
        "sort-descending": "উল্টো বর্ণক্রমে সাজান",
-       "sort-ascending": "বরà§\8dণানুক্রমে সাজান",
+       "sort-ascending": "à¦\8aরà§\8dদà§\8dধানুক্রমে সাজান",
        "nstab-main": "পাতা",
        "nstab-user": "ব্যবহারকারীর পাতা",
        "nstab-media": "মিডিয়া পাতা",
index ba03773..c671f47 100644 (file)
        "edit-error-long": "Chyby:\n\n$1",
        "revid": "revize $1",
        "pageid": "Stránka s ID $1",
-       "interfaceadmin-info": "$1\n\nOprávnění editovat celoprojektové soubory s CSS/JS/JSON bylo nedávno odděleno od práva <code>editinterface</code>. Pokud nerozumíte tomu, proč vidíte tuto chybu, podívejte se na [[mw:MediaWiki_1.32/interface-admin]].",
+       "interfaceadmin-info": "Oprávnění editovat celoprojektové soubory s CSS/JS/JSON bylo nedávno omezeno na členy skupiny [[{{int:grouppage-interface-admin}}|{{int:group-interface-admin}}]]. Pro více informací viz [[m:Creation of separate user group for editing sitewide CSS/JS]].",
        "rawhtml-notallowed": "Značky &lt;html&gt; nelze používat mimo běžné stránky.",
        "gotointerwiki": "Opustit {{GRAMMAR:4sg|{{SITENAME}}}}",
        "gotointerwiki-invalid": "Zadaný název je neplatný.",
index 21cc02a..7c698d3 100644 (file)
@@ -69,7 +69,8 @@
                        "Kenn.jensen",
                        "Saederup92",
                        "Fitoschido",
-                       "Jorn Ari"
+                       "Jorn Ari",
+                       "Fnielsen"
                ]
        },
        "tog-underline": "Understreg henvisninger:",
        "right-editcontentmodel": "Redigere indholdsmodellen for en side",
        "right-editinterface": "Ændre brugergrænsefladens tekster",
        "right-editusercss": "Ændre andre brugeres CSS filer",
+       "right-edituserjson": "Redigér andre brugeres JSON-filter",
        "right-edituserjs": "Ændre andre brugeres JS filer",
        "right-editsitecss": "Rediger CSS for hele siden",
        "right-editsitejson": "Rediger JSON for hele siden",
        "right-editsitejs": "Rediger JavaScript for hele siden",
        "right-editmyusercss": "Redigere dine egne CSS-filer",
+       "right-editmyuserjson": "Redigér dine egne bruger-JSON-filer",
        "right-editmyuserjs": "Redigere dine egne JavaScript-filer",
        "right-viewmywatchlist": "Se din egen overvågningsliste",
        "right-editmywatchlist": "Redigere din egen overvågningsliste. Bemærk nogle handlinger tilføjer sider selv uden denne rettelse.",
        "rcfilters-advancedfilters": "Avancerede filtre",
        "rcfilters-limit-title": "Antal resultater som skal vises",
        "rcfilters-limit-and-date-label": "$1 {{PLURAL:$1|ændring|ændringer}}, $2",
+       "rcfilters-date-popup-title": "Tidsperiode at søge i",
        "rcfilters-days-title": "De sidste dage",
        "rcfilters-hours-title": "De sidste timer",
        "rcfilters-days-show-days": "$1 {{PLURAL:$1|dag|dage}}",
        "rcfilters-highlighted-filters-list": "Fremhævede: $1",
        "rcfilters-quickfilters": "Gemte filtre",
        "rcfilters-quickfilters-placeholder-title": "Ingen filtre gemt endnu",
+       "rcfilters-quickfilters-placeholder-description": "For at gemme filterindstillingerne og genbruge dem senere, klik på bogmærkeikonet i området Aktive Filtre herunder.",
        "rcfilters-savedqueries-defaultlabel": "Gemte filtre",
        "rcfilters-savedqueries-rename": "Omdøb",
        "rcfilters-savedqueries-setdefault": "Vælg som grundindstilling",
        "protectedtitles-submit": "Vis sidetitler",
        "listusers": "Brugerliste",
        "listusers-editsonly": "Vis kun brugere med redigeringer",
+       "listusers-temporarygroupsonly": "Vis kun brugere i midlertidige brugergrupper",
        "listusers-creationsort": "Sorter efter oprettelsesdato",
        "listusers-desc": "Sortér i faldende rækkefølge",
        "usereditcount": "{{PLURAL:$1|én redigering|$1 redigeringer}}",
        "apihelp": "API-hjælp",
        "apihelp-no-such-module": "Modul \"$1\" ikke fundet.",
        "apisandbox": "API-sandkassen",
+       "apisandbox-jsonly": "JavaScript kræves for at bruge API-sandkassen.",
        "apisandbox-api-disabled": "API er deaktiveret på dette websted.",
        "apisandbox-intro": "Brug denne side til at eksperimentere med '''MediaWiki web service API'''.\nVi henviser til [https://www.mediawiki.org/wiki/API:Main_page dokumentationen af API] for yderligere oplysninger om brug af API.  Eksempel: [https://www.mediawiki.org/wiki/API#A_simple_example få indholdet af en forside]. Vælg en handling at se flere eksempler.\n\nBemærk, at selv om dette er en sandkasse, vil handlinger du udfører på denne side redigere wikien.",
        "apisandbox-submit": "Lav forespørgsel",
index 6428414..96b50ec 100644 (file)
        "edit-error-long": "Fehler:\n\n$1",
        "revid": "Version $1",
        "pageid": "Seitenkennung $1",
-       "interfaceadmin-info": "$1\n\nBerechtigungen für das Bearbeiten von wikiweiten CSS/JS/JSON-Dateien wurden kürzlich von dem Recht <code>editinterface</code> getrennt. Falls du nicht verstehst, warum du diesen Fehler erhältst, siehe bitte [[mw:MediaWiki_1.32/interface-admin]].",
+       "interfaceadmin-info": "$1\n\nBerechtigungen für das Bearbeiten von wikiweiten CSS/JS/JSON-Dateien wurden kürzlich vom Recht <code>editinterface</code> getrennt. Falls du nicht verstehst, warum du diesen Fehler erhältst, siehe [[mw:MediaWiki_1.32/interface-admin]].",
        "rawhtml-notallowed": "&lt;html&gt;-Tags können nicht außerhalb von normalen Seiten verwendet werden.",
        "gotointerwiki": "{{SITENAME}} verlassen",
        "gotointerwiki-invalid": "Der angegebene Titel ist ungültig.",
index ea902bb..609f692 100644 (file)
        "activeusers-noresult": "کاربری پیدا نشد.",
        "activeusers-submit": "نمایش کاربران فعال",
        "listgrouprights": "اختیارات گروه‌های کاربری",
-       "listgrouprights-summary": "فهرست زیر شامل گروه‌های کاربری تعریف شده در این ویکی و اختیارات داده شده به آن‌ها است.\nاطلاعات بیشتر در مورد هر یک از اختیارات را در [[{{MediaWiki:Listgrouprights-helppage}}]] بیابید.",
+       "listgrouprights-summary": "فهرست زیر شامل گروه‌های کاربری تعریف شده در این ویکی و اختیارات داده شده به آن‌ها است.\nاطلاعات بیشتر در مورد هر کدام از آنها را در [[{{MediaWiki:Listgrouprights-helppage}}|اختیارات گروه‌های کاربری]] بیابید.",
        "listgrouprights-key": "* <span class=\"listgrouprights-granted\">اختیارات داده‌شده</span>\n* <span class=\"listgrouprights-revoked\">اختیارات گرفته‌شده</span>",
        "listgrouprights-group": "گروه",
        "listgrouprights-rights": "دسترسی‌ها",
index 8e6fcde..81b13e2 100644 (file)
        "pagedata-bad-title": "Virheellinen otsikko: $1.",
        "unregistered-user-config": "Turvallisuussyistä JavaScript-, CSS- ja JSON-käyttäjäalasivuja ei voi ladata rekisteröimättömiltä käyttäjiltä.",
        "passwordpolicies": "Salasanakäytännöt",
-       "passwordpolicies-summary": "Tämä on luettelo käytössä olevista salasanakäytännöistä tämän wikin käyttäjäryhmille.",
+       "passwordpolicies-summary": "Tämä on luettelo voimassa olevista salasanakäytännöistä tämän wikin käyttäjäryhmille.",
        "passwordpolicies-group": "Ryhmä",
        "passwordpolicies-policies": "Käytännöt",
-       "passwordpolicies-policy-minimalpasswordlength": "Salasanan on oltava ainakin $1 {{PLURAL:$1|merkki|merkkiä}} pitkä",
+       "passwordpolicies-policy-minimalpasswordlength": "Salasanan tulee olla vähintään {{PLURAL:$1|yhden merkin|$1 merkin}} pituinen",
        "passwordpolicies-policy-minimumpasswordlengthtologin": "Salasanassa on oltava vähintään $1 {{PLURAL:$1|merkki|merkkiä}} pystyäksesi kirjautumaan",
-       "passwordpolicies-policy-passwordcannotmatchusername": "Salasana ei voi olla sama kuin käyttäjänimi",
-       "passwordpolicies-policy-passwordcannotmatchblacklist": "Salasana ei voi vastata mustalla listalla olevia salasanoja",
-       "passwordpolicies-policy-maximalpasswordlength": "Salasanan on oltava vähemmän kuin $1 {{PLURAL:$1|merkki|merkkiä}} pitkä",
-       "passwordpolicies-policy-passwordcannotbepopular": "Salasana ei voi olla {{PLURAL:$1|suosittu salasana|$1 suositun salasanan listalla}}"
+       "passwordpolicies-policy-passwordcannotmatchusername": "Salasana ei saa olla sama kuin käyttäjänimi",
+       "passwordpolicies-policy-passwordcannotmatchblacklist": "Salasana ei saa olla mustalla listalla",
+       "passwordpolicies-policy-maximalpasswordlength": "Salasanan tulee olla lyhyempi kuin $1 {{PLURAL:$1|merkki|merkkiä}}",
+       "passwordpolicies-policy-passwordcannotbepopular": "Salasana ei saa olla {{PLURAL:$1|suosittu salasana|$1 suosituimman salasanan listalla}}"
 }
index 261420e..2889465 100644 (file)
        "userlogin-yourname-ph": "Antré zòt non di itilizatò",
        "createacct-another-username-ph": "Antré non-an di itilizatò",
        "yourpassword": "Mo di pas :",
-       "userlogin-yourpassword": "Mo di pas",
+       "userlogin-yourpassword": "Modipas",
        "userlogin-yourpassword-ph": "Antré zòt mo di pas",
        "createacct-yourpassword-ph": "Antré oun mo di pas",
        "yourpasswordagain": "Konfirmé mo di pas :",
-       "createacct-yourpasswordagain": "Konfirmé mo di pas",
+       "createacct-yourpasswordagain": "Konfirmen modipas-a",
        "createacct-yourpasswordagain-ph": "Antré òkò menm mo di pas",
        "userlogin-remembermypassword": "Gardé mo sésyon aktiv",
        "userlogin-signwithsecure": "Itilizé roun konnègsyon sékirizé",
index 45bb7b6..3cbb4a7 100644 (file)
        "sig_tip": "အချိန်ပါပြသော သင့်လက်မှတ်",
        "hr_tip": "မျဉ်းလဲ (စိစစ်သုံးရန်)",
        "summary": "အ​ကျဉ်း​ချုပ်​ -",
-       "subject": "အကြောင်းအရာ -",
+       "subject": "အကြောင်းအရာ:",
        "minoredit": "အရေးမကြီးသော ​ပြင်​ဆင်​မှု ​ဖြစ်​သည်​",
        "watchthis": "ဤစာမျက်နှာကို စောင့်ကြည့်ရန်",
        "savearticle": "ဤစာမျက်နှာကို သိမ်းရန်",
        "allmessages": "စနစ်၏ သတင်းများ",
        "allmessagesname": "အမည်",
        "allmessagesdefault": "ပုံမှန် အသိပေးချက် စာသား",
+       "allmessagescurrent": "လက်ရှိ မက်ဆေ့စာသား",
        "allmessages-filter-legend": "စစ်ထုတ်ခြင်း",
        "allmessages-filter-unmodified": "မပြုပြင်ထားသော",
        "allmessages-filter-all": "အားလုံး",
        "tags-title": "အမည်တွဲများ",
        "tags-tag": "အမည်တွဲ အမည်",
        "tags-description-header": "ဆိုလိုရင်းအဓိပ္ပာယ် အပြည့်အစုံ",
+       "tags-source-header": "ရင်းမြစ်",
        "tags-active-yes": "မှန်",
        "tags-active-no": "မလုပ်ပါ",
        "tags-source-extension": "ဆော့ဝဲလ်မှ သတ်မှတ်ထားသော",
        "htmlform-submit": "ထည့်သွင်းရန်",
        "htmlform-reset": "ပြောင်းလဲထားသည်များ မလုပ်တော့ရန်",
        "htmlform-selectorother-other": "အခြား",
+       "htmlform-no": "မလုပ်ပါ",
        "htmlform-chosen-placeholder": "လုပ်ဆောင်ချက်တစ်ခု ရွေးချယ်ရန်",
        "htmlform-cloner-create": "ပို၍ ထပ်ပေါင်းရန်",
        "htmlform-cloner-delete": "ဖယ်ရှားရန်",
index da1d16f..8716ae9 100644 (file)
        "confirm-rollback-top": "Bewerkingen op deze pagina ongedaan maken?",
        "confirm-mcrundo-title": "Een wijziging ongedaan maken",
        "mcrundofailed": "Ongedaan maken mislukt",
-       "mcrundo-missingparam": "Er ontbreken benodigde parameters in het verzoek.",
+       "mcrundo-missingparam": "Er ontbreken nodige parameters in het verzoek.",
        "quotation-marks": "\"$1\"",
        "imgmultipageprev": "← vorige pagina",
        "imgmultipagenext": "volgende pagina →",
index 154264a..c0eb191 100644 (file)
        "rcfilters-watchlist-markseen-button": "Merk alle endringar som sette",
        "rcfilters-watchlist-edit-watchlist-button": "Endra lista over sider du overvaker",
        "rcfilters-watchlist-showupdated": "Sider du ikkje har vitja sidan dei vart endra er viste med <strong>feit</strong> skrift.",
+       "rcfilters-filter-showlinkedfrom-label": "Vis endringar på sider det vert lenkja til på sida",
+       "rcfilters-filter-showlinkedfrom-option-label": "<strong>Sider som det vert lenkja til på</strong> den valde sida",
+       "rcfilters-filter-showlinkedto-label": "Vis endringar på sider som lenkjar til sida",
+       "rcfilters-filter-showlinkedto-option-label": "<strong>Sider som lenkjar til</strong> den valde sida",
        "rcnotefrom": "Nedanfor er endringane gjorde sidan <strong>$2</strong> viste (opp til <strong>$1</strong> stykke)",
        "rclistfromreset": "Nullstill datoval",
        "rclistfrom": "Vis nye endringar sidan $3 $2",
index 1f509d9..39bd60d 100644 (file)
                        "AttemptToCallNil",
                        "Stjn",
                        "Vlad5250",
-                       "Marshmallych"
+                       "Marshmallych",
+                       "Atsirlin"
                ]
        },
        "tog-underline": "Подчёркивание ссылок:",
        "jumpto": "Перейти к:",
        "jumptonavigation": "навигация",
        "jumptosearch": "поиск",
-       "view-pool-error": "Ð\98звиниÑ\82е, Ð² Ð½Ð°Ñ\81Ñ\82оÑ\8fÑ\89ий Ð¼Ð¾Ð¼ÐµÐ½Ñ\82 Ñ\81еÑ\80веÑ\80Ñ\8b Ð¿ÐµÑ\80егÑ\80Ñ\83женÑ\8b.\nСлиÑ\88ком Ð¼Ð½Ð¾Ð³Ð¾ Ñ\83Ñ\87аÑ\81Ñ\82ников Ð¿Ñ\8bÑ\82аÑ\8eÑ\82Ñ\81Ñ\8f ÐµÑ\91 Ð¿Ñ\80оÑ\81моÑ\82Ñ\80еÑ\82Ñ\8c.\nПожалуйста, подождите немного перед повторной попыткой обращения к этой странице.\n\n$1",
+       "view-pool-error": "Ð\98звиниÑ\82е, Ð² Ð½Ð°Ñ\81Ñ\82оÑ\8fÑ\89ий Ð¼Ð¾Ð¼ÐµÐ½Ñ\82 Ñ\81еÑ\80веÑ\80Ñ\8b Ð¿ÐµÑ\80егÑ\80Ñ\83женÑ\8b.\nСлиÑ\88ком Ð¼Ð½Ð¾Ð³Ð¾ Ñ\83Ñ\87аÑ\81Ñ\82ников Ð¿Ñ\8bÑ\82аÑ\8eÑ\82Ñ\81Ñ\8f Ð¿Ñ\80оÑ\81моÑ\82Ñ\80еÑ\82Ñ\8c ÐµÑ\91 Ð¾Ð´Ð½Ð¾Ð²Ñ\80еменно.\nПожалуйста, подождите немного перед повторной попыткой обращения к этой странице.\n\n$1",
        "generic-pool-error": "Извините, в настоящий момент серверы перегружены.\nСлишком много участников пытаются просмотреть этот ресурс.\nПожалуйста, подождите и повторите попытку обращения к нему позже.",
        "pool-timeout": "Истекло время ожидания блокировки",
        "pool-queuefull": "Пул запросов полон",
        "privacypage": "Project:Политика конфиденциальности",
        "badaccess": "Ошибка доступа",
        "badaccess-group0": "Вы не можете выполнить запрошенное действие.",
-       "badaccess-groups": "Ð\97апÑ\80оÑ\88енное Ð´ÐµÐ¹Ñ\81Ñ\82вие Ð¼Ð¾Ð³Ñ\83Ñ\82 Ð²Ñ\8bполнÑ\8fÑ\82Ñ\8c Ñ\82олÑ\8cко Ñ\83Ñ\87аÑ\81Ñ\82ники {{PLURAL:$2|1=из Ð³Ñ\80Ñ\83ппÑ\8b Â«$1»|одной Ð¸Ð· Ñ\81ледÑ\83Ñ\8eÑ\89иÑ\85 Ð³Ñ\80Ñ\83пп: $1}}",
+       "badaccess-groups": "Запрошенное действие могут выполнять участники {{PLURAL:$2|1=из группы «$1»|одной из следующих групп: $1}}",
        "versionrequired": "Требуется MediaWiki версии $1",
        "versionrequiredtext": "Для работы с этой страницей требуется MediaWiki версии $1. См. [[Special:Version|информацию о программном обеспечении]].",
        "ok": "OK",
        "perfcachedts": "Данные взяты из кэша; последний раз он обновлялся в $1. В кэше хранится не более {{PLURAL:$4|1=одной записи|$4 записи|$4 записей}}.",
        "querypage-no-updates": "Обновление этой страницы сейчас отключено.\nПредставленные здесь данные не будут обновляться.",
        "viewsource": "Просмотр кода",
-       "viewsource-title": "Ð\9fÑ\80оÑ\81моÑ\82Ñ\80 Ð¸Ñ\81Ñ\85одного Ñ\82екÑ\81Ñ\82а страницы $1",
+       "viewsource-title": "Ð\9fÑ\80оÑ\81моÑ\82Ñ\80 ÐºÐ¾Ð´а страницы $1",
        "actionthrottled": "Ограничение по скорости",
        "actionthrottledtext": "Вы исчерпали установленное для борьбы со спамом ограничение на максимальное количество попыток выполнения запрошенного действия в короткий промежуток времени.\nПожалуйста, повторите попытку через несколько минут.",
        "protectedpagetext": "Эта страница защищена для предотвращения её редактирования или совершений других действий.",
        "virus-scanfailed": "ошибка сканирования (код $1)",
        "virus-unknownscanner": "неизвестный антивирус:",
        "logouttext": "<strong>Вы завершили сеанс работы.</strong>\n\nНекоторые страницы могут продолжить отображаться так, как будто вы все ещё не завершили сеанс, пока вы не обновите кэш браузера.",
-       "cannotlogoutnow-title": "Невозможно выйти прямо сейчас",
-       "cannotlogoutnow-text": "Нельзя выйти во время использования $1.",
+       "cannotlogoutnow-title": "Ð\9dевозможно Ð²Ñ\8bйÑ\82и Ð¸Ð· Ñ\81иÑ\81Ñ\82емÑ\8b Ð¿Ñ\80Ñ\8fмо Ñ\81ейÑ\87аÑ\81",
+       "cannotlogoutnow-text": "Ð\9dелÑ\8cзÑ\8f Ð²Ñ\8bйÑ\82и Ð¸Ð· Ñ\81иÑ\81Ñ\82емÑ\8b Ð²Ð¾ Ð²Ñ\80емÑ\8f Ð¸Ñ\81полÑ\8cзованиÑ\8f $1.",
        "welcomeuser": "Добро пожаловать, $1!",
-       "welcomecreation-msg": "Ваша учётная запись успешно создана.\nТеперь вы также можете провести  [[Special:Preferences|персональную настройку]] сайта {{SITENAME}}.",
+       "welcomecreation-msg": "Ваша учётная запись была создана.\nТеперь вы также можете изменить [[Special:Preferences|персональные настройки]] для сайта {{SITENAME}}, если вы желаете.",
        "yourname": "Имя учётной записи:",
        "userlogin-yourname": "Имя учётной записи",
        "userlogin-yourname-ph": "Введите имя вашей учётной записи",
        "userlogin-signwithsecure": "Защищённое соединение",
        "cannotlogin-title": "Невозможно войти",
        "cannotlogin-text": "Вход в систему невозможен.",
-       "cannotloginnow-title": "Невозможно войти прямо сейчас",
+       "cannotloginnow-title": "Ð\9dевозможно Ð²Ð¾Ð¹Ñ\82и Ð² Ñ\81иÑ\81Ñ\82емÑ\83 Ð¿Ñ\80Ñ\8fмо Ñ\81ейÑ\87аÑ\81",
        "cannotloginnow-text": "Нельзя войти во время использования $1.",
        "cannotcreateaccount-title": "Невозможно создать учётные записи",
        "cannotcreateaccount-text": "Прямое создание учетных записей не включено в этой вики.",
        "userlogout": "Завершение сеанса",
        "notloggedin": "Вы не представились системе",
        "userlogin-noaccount": "Нет учётной записи?",
-       "userlogin-joinproject": "Присоединиться к проекту",
+       "userlogin-joinproject": "Присоединиться к проекту {{SITENAME}}.",
        "createaccount": "Создать учётную запись",
        "userlogin-resetpassword-link": "Сбросить ваш пароль?",
        "userlogin-helplink2": "Помощь по входу",
        "createacct-error": "Ошибка создания учётной записи",
        "createaccounterror": "Невозможно создать учётную запись: $1",
        "nocookiesnew": "Участник зарегистрирован, но не представлен. {{SITENAME}} использует «cookies» для представления участников. У вас «cookies» запрещены. Пожалуйста, разрешите их, а затем представьтесь со своиим новым именем участника и паролем.",
-       "nocookieslogin": "{{SITENAME}} использует «cookies» для представления участников. Вы их отключили. Пожалуйста, включите их и попробуйте снова.",
+       "nocookieslogin": "{{SITENAME}} использует «cookies»-файлы для представления участников. Вы отключили использование «cookies»-файлов. Пожалуйста, включите использование «cookies»-файлов и попробуйте снова.",
        "nocookiesfornew": "Учётная запись участника не была создана из-за невозможности проверить её источник. \nУбедитесь, что включены «cookies», обновите страницу и попробуйте ещё раз.",
        "nocookiesforlogin": "{{int:nocookieslogin}}",
        "createacct-loginerror": "Учётная запись была успешно создана, но вы не смогли войти в систему автоматически. Пожалуйста, [[Special:UserLogin|авторизуйтесь вручную]].",
        "createacct-another-realname-tip": "Настоящее имя (необязательное поле).\nЕсли вы укажете его, то оно будет использовано для того, чтобы показать, кем была внесена правка страницы.",
        "pt-login": "Войти",
        "pt-login-button": "Войти",
-       "pt-login-continue-button": "Продолжить процедуру входа",
+       "pt-login-continue-button": "Продолжить процедуру входа в систему",
        "pt-createaccount": "Создать учётную запись",
        "pt-userlogout": "Выйти",
        "php-mail-error-unknown": "Неизвестная ошибка в PHP-функции mail()",
        "undo-main-slot-only": "Правка не может быть отменена, поскольку оно включает контент вне основного слота.",
        "undo-norev": "Правка не может быть отменена, так как её не существует или она была удалена.",
        "undo-nochange": "Правка, похоже, уже была отменена.",
-       "undo-summary": "Отмена правки $1, сделанной {{gender:$2|участником|участницей}} [[Special:Contributions/$2|$2]] ([[User talk:$2|обсуждение]])",
+       "undo-summary": "Отмена правки $1, сделанной [[Special:Contributions/$2|$2]] ([[User talk:$2|обсуждение]])",
        "undo-summary-username-hidden": "Отмена правки $1, сделанной участником, чьё имя скрыто",
        "cantcreateaccount-text": "Создание учётных записей с этого IP-адреса (<strong>$1</strong>) было заблокировано {{GENDER:$3|участником|участницей|}} [[User:$3|$3]].\n\n$3 {{GENDER:$3|указал|указала}} следующую причину: <em>$2</em>.",
        "cantcreateaccount-range-text": "{{GENDER:$3|Участник|Участница}} [[User:$3|$3]] {{GENDER:$3|установил|установила}} запрет на создание учётных записей для диапазона IP-адресов <strong>$1</strong>, включающего ваш IP-адрес (<strong>$4</strong>). \n\nБыла указана следующая причина: <em>$2</em>.",
        "last": "пред.",
        "page_first": "первая",
        "page_last": "последняя",
-       "histlegend": "Выбор версий: отметьте версии страницы, которые вы хотите сравнить, и нажмите <strong>{{int:compare-submit}}/strong>.<br />\nПояснения: <strong>({{int:cur}})/strong> — отличия от текущей версии; <strong>({{int:last}})/strong> — отличия от предшествующей версии; <strong>{{int:minoreditletter}}/strong> — незначительные изменения.",
+       "histlegend": "Выбор версий: отметьте версии страницы, которые вы хотите сравнить, и нажмите <strong>{{int:compare-submit}}</strong>.<br />\nПояснения: <strong>({{int:cur}})</strong> — отличия от текущей версии; <strong>({{int:last}})</strong> — отличия от предшествующей версии; <strong>{{int:minoreditletter}}</strong> — незначительные изменения.",
        "history-fieldset-title": "Поиск правок",
        "history-show-deleted": "Только удалённые правки",
        "histfirst": "старейшие",
        "rev-showdeleted": "показать",
        "revisiondelete": "Удалить/восстановить версии страницы",
        "revdelete-nooldid-title": "Не задана целевая версия",
-       "revdelete-nooldid-text": "Ð\92Ñ\8b Ð½Ðµ Ð·Ð°Ð´Ð°Ð»Ð¸ Ð²ÐµÑ\80Ñ\81иÑ\8e (веÑ\80Ñ\81ии), Ð¸Ð»Ð¸ Ñ\83казаннаÑ\8f Ð²ÐµÑ\80Ñ\81иÑ\8f Ð½Ðµ Ñ\81Ñ\83Ñ\89еÑ\81Ñ\82вÑ\83еÑ\82, или же вы пытаетесь скрыть текущую версию.",
+       "revdelete-nooldid-text": "ЦелеваÑ\8f Ð²ÐµÑ\80Ñ\81иÑ\8f Ð½Ðµ Ð·Ð°Ð´Ð°Ð½Ñ\8b, Ñ\83казаннаÑ\8f Ð²ÐµÑ\80Ñ\81иÑ\8f Ð½Ðµ Ñ\81Ñ\83Ñ\89еÑ\81Ñ\82вÑ\83еÑ\82 или же вы пытаетесь скрыть текущую версию.",
        "revdelete-no-file": "Указанный файл не существует.",
        "revdelete-show-file-confirm": "Вы уверены, что вы хотите просмотреть удалённую версию файла «<nowiki>$1</nowiki>» от $2, $3?",
        "revdelete-show-file-submit": "Да",
        "default": "по умолчанию",
        "prefs-files": "Файлы",
        "prefs-custom-css": "Собственный CSS",
-       "prefs-custom-json": "Ð\9fолÑ\8cзоваÑ\82елÑ\8cÑ\81кий JSON",
+       "prefs-custom-json": "СобÑ\81Ñ\82веннÑ\8bй JSON",
        "prefs-custom-js": "Собственный JS",
        "prefs-common-config": "Общие CSS/JSON/JavaScript для всех тем оформления:",
        "prefs-reset-intro": "Эта страница может быть использована для сброса ваших настроек на стандартные.\nУчтите, что это действие невозможно отменить.",
        "userrights-groupsmember": "Состоит в группах:",
        "userrights-groupsmember-auto": "Неявно состоит в группах:",
        "userrights-groupsmember-type": "$1",
-       "userrights-groups-help": "Вы можете изменить группы, в которые входит {{GENDER:$1|этот участник|эта участница}}.\n* Если около названия группы стоит отметка — {{GENDER:$1|участник|участница}} входит в эту группу.\n* Если отметка не стоит — {{GENDER:$1|участник|участница}} не входит в эту группу.\n* Символ * указывает на то, что вы не сможете удалить {{GENDER:$1|участника|участницу}} из группы, если добавите {{GENDER:$1|его|её}} в неё (или наоборот).\n* Символ # указывает на то, что вы можете только отложить время истечения членства в этой группы, вы не можете перенести его на более ранний срок.",
+       "userrights-groups-help": "Вы можете изменить группы, в которые входит {{GENDER:$1|этот участник|эта участница}}.\n* Если около названия группы стоит отметка — {{GENDER:$1|участник|участница}} входит в эту группу.\n* Если отметка не стоит — {{GENDER:$1|участник|участница}} не входит в эту группу.\n* Символ * указывает на то, что вы не сможете удалить {{GENDER:$1|участника|участницу}} из группы, если добавите {{GENDER:$1|его|её}} в неё (или наоборот).\n* Символ # указывает на то, что вы можете только отложить, но не перенести время истечения членства в этой группе на более ранний срок.",
        "userrights-reason": "Причина:",
        "userrights-no-interwiki": "У вас нет разрешения изменять права участников в других вики.",
        "userrights-nodatabase": "База данных $1 не существует или расположена не локально.",
        "block": "Блокировка участника",
        "unblock": "Разблокировка участника",
        "blockip": "Заблокировать {{GENDER:$1|участника|участницу}}",
-       "blockiptext": "Используйте форму ниже, чтобы заблокировать возможность записи с определённого IP-адреса или имени участника.\nЭто может быть сделано только для предотвращения вандализма и только в соответствии с [[{{MediaWiki:Policy-url}}|правилами]].\nНиже укажите конкретную причину (к примеру, процитируйте некоторые страницы с признаками вандализма).\nВы можете заблокировать диапазоны IP-адресов, используя [https://ru.wikipedia.org/wiki/Бесклассовая_адресация CIDR]-синтаксис. Максимально допустимый диапазон — /$1 для протокола IPv4 и /$2 для протокола IPv6.",
+       "blockiptext": "Используйте форму ниже, чтобы заблокировать возможность редактирования с определённого IP-адреса или имени участника.\nЭтот инструмент следует использовать для предотвращения вандализма и только в соответствии с [[{{MediaWiki:Policy-url}}|правилами]].\nНиже укажите конкретную причину (к примеру, процитируйте некоторые страницы с признаками вандализма).\nВы можете заблокировать диапазоны IP-адресов, используя [https://ru.wikipedia.org/wiki/Бесклассовая_адресация CIDR]-синтаксис. Максимально допустимый диапазон — /$1 для протокола IPv4 и /$2 для протокола IPv6.",
        "ipaddressorusername": "IP-адрес или имя участника:",
        "ipbexpiry": "Закончится через:",
        "ipbreason": "Причина:",
        "ipboptions": "2 часа:2 hours,1 день:1 day,3 дня:3 days,1 неделя:1 week,2 недели:2 weeks,1 месяц:1 month,3 месяца:3 months,6 месяцев:6 months,1 год:1 year,бессрочно:infinite",
        "ipbhidename": "Скрыть имя участника из правок и списков",
        "ipbwatchuser": "Добавить в список наблюдения личную страницу участника и его страницу обсуждения",
-       "ipb-disableusertalk": "Запретить этому участнику редактировать свою страницу обсуждения во время блокировки",
+       "ipb-disableusertalk": "Запретить этому участнику редактировать свою страницу обсуждения",
        "ipb-change-block": "Переблокировать участника с этими настройками",
        "ipb-confirm": "Подтвердить блокировку",
        "badipaddress": "IP-адрес записан в неправильном формате, или участника с таким именем не существует.",
        "autoblocklist-submit": "Найти",
        "autoblocklist-legend": "Список автоблокировок",
        "autoblocklist-localblocks": "{{PLURAL:$1|Локальная автоблокировка|Локальные автоблокировки}}",
-       "autoblocklist-total-autoblocks": "Ð\92Ñ\81его Ð°Ð²Ñ\82облоков: $1",
+       "autoblocklist-total-autoblocks": "Ð\92Ñ\81его Ð°Ð²Ñ\82облокиÑ\80овок: $1",
        "autoblocklist-empty": "Список автоблокировок пуст.",
        "autoblocklist-otherblocks": "{{PLURAL:$1|Другая автоблокировка|Другие автоблокировки}}",
        "ipblocklist": "Заблокированные участники",
        "moveuserpage-warning": "<strong>Внимание:</strong> вы собираетесь переименовать страницу участника. Пожалуйста, обратите внимание, что переименована будет только страница, участник <strong>не</strong> будет переименован.",
        "movecategorypage-warning": "<strong>Предупреждение:</strong> Вы собираетесь переименовать страницу категории. Пожалуйста, обратите внимание, что будет переименована только эта страница, а все страницы старой категории <em>не</em> будут перекатегоризованы в новую.",
        "movenologintext": "Вы должны [[Special:UserLogin|представиться системе]],\nчтобы иметь возможность переименовать страницы.",
-       "movenotallowed": "У вас нет разрешения переименовывать страницы.",
-       "movenotallowedfile": "У вас нет разрешения переименовывать файлы.",
-       "cant-move-user-page": "У вас нет разрешения переименовывать основные страницы участников.",
+       "movenotallowed": "У вас нет прав на переименовывание страниц.",
+       "movenotallowedfile": "У вас нет прав на переименовывание файлов.",
+       "cant-move-user-page": "У вас нет прав на переименовывание основных страниц участников.",
        "cant-move-to-user-page": "У вас нет прав переименовывать страницу в страницу участника (можно переименовать в подстраницу).",
-       "cant-move-category-page": "У вас нет разрешения переименовывать страницы категорий.",
-       "cant-move-to-category-page": "У вас нет разрешения переименовывать страницы в страницу категории.",
-       "cant-move-subpages": "У вас нет разрешения переименовывать подстраницы.",
+       "cant-move-category-page": "У вас нет прав на переименовывание страниц категорий.",
+       "cant-move-to-category-page": "У вас нет прав на переименовывание страницы в страницу категории.",
+       "cant-move-subpages": "У вас нет прав на переименовывание подстраниц.",
        "namespace-nosubpages": "Пространство имён «$1» не разрешает создание страниц.",
        "newtitle": "Новое название:",
        "move-watch": "Добавить в список наблюдения исходную и целевую страницы",
        "movenosubpage": "У этой страницы нет подстраниц.",
        "movereason": "Причина:",
        "revertmove": "возврат",
-       "delete_and_move_text": "ЦелеваÑ\8f Ñ\81траница с именем «[[:$1]]» уже существует. \nХотите удалить её, чтобы сделать возможным переименование?",
+       "delete_and_move_text": "Страница с именем «[[:$1]]» уже существует. \nХотите удалить её, чтобы сделать возможным переименование?",
        "delete_and_move_confirm": "Да, удалить эту страницу",
        "delete_and_move_reason": "Удалено для возможности переименования «[[$1]]»",
        "selfmove": "Невозможно переименовать страницу: исходное и новое имя страницы совпадают.",
        "immobile-target-namespace-iw": "Ссылка интервики не может быть использована для переименования.",
        "immobile-source-page": "Эту страницу нельзя переименовать.",
        "immobile-target-page": "Нельзя присвоить странице это имя.",
-       "bad-target-model": "Невозможно преобразовать $1 в $2: несовместимые модели данных.",
+       "bad-target-model": "Невозможно преобразовать $1 в $2. У страниц несовместимые модели содержимого.",
        "imagenocrossnamespace": "Невозможно дать файлу имя из другого пространства имён",
-       "nonfile-cannot-move-to-file": "Невозможно переименовывать страницы в файлы",
+       "nonfile-cannot-move-to-file": "Невозможно переименовывать не-файловые страницы в файлы",
        "imagetypemismatch": "Новое расширение файла не соответствует его типу",
        "imageinvalidfilename": "Целевое имя файла ошибочно",
        "fix-double-redirects": "Исправить перенаправления, указывающие на прежнее название",
        "filedelete-archive-read-only": "Архивная директория «$1» не доступна для записи веб-серверу.",
        "previousdiff": "← Предыдущая правка",
        "nextdiff": "Следующая правка →",
-       "mediawarning": "'''Внимание'''. Этот тип файла может содержать вредоносный программный код.\nПри его запуске ваша система может быть заражена.",
+       "mediawarning": "<strong>Внимание</strong>. Этот тип файла может содержать вредоносный программный код.\nПри его запуске ваша система может быть заражена.",
        "imagemaxsize": "Ограничение на размер изображения:<br />''(для страницы описания файла)''",
        "thumbsize": "Размер уменьшенной версии изображения:",
        "widthheight": "$1 × $2",
        "edit-error-long": "Ошибки:\n\n$1",
        "revid": "версия $1",
        "pageid": "ID страницы $1",
-       "interfaceadmin-info": "$1\n\nПрава на редактирование общесайтных CSS/JS/JSON были недавно вынесены из права <code>editinterface</code>. Если вы не понимаете, почему вы наткнулись на эту ошибку, см. [[mw:MediaWiki_1.32/interface-admin]].",
+       "interfaceadmin-info": "$1\n\nПрава на редактирование общесайтных CSS/JS/JSON-файлов были недавно вынесены из права <code>editinterface</code>. Если вы не понимаете, почему вы наткнулись на эту ошибку, см. [[mw:MediaWiki_1.32/interface-admin]].",
        "rawhtml-notallowed": "&lt;html&gt; теги могут быть использованы только в пределах обычных страниц.",
        "gotointerwiki": "Покидаем {{grammar:accusative|{{SITENAME}}}}...",
        "gotointerwiki-invalid": "Указан некорректный заголовок.",
index 32399b9..9ad3fb2 100644 (file)
        "php-uploaddisabledtext": "Отпремање датотека је онемогућено у PHP-у.\nПроверите подешавања file_uploads.",
        "uploadscripted": "Датотека садржи HTML или скриптни код који може бити погрешно протумачен од стране прегледача.",
        "upload-scripted-pi-callback": "Датотека која садржи инструкције за обраду XML стилског облика се не може отпремити.",
-       "upload-scripted-dtd": "Ð\9dе Ð¼Ð¾Ð³Ñ\83 Ð´Ð° Ð¾Ñ\82пÑ\80емим SVG Ð´Ð°Ñ\82оÑ\82еке које садрже нестандардну DTD декларацију.",
+       "upload-scripted-dtd": "Ð\9dиÑ\98е Ð¼Ð¾Ð³Ñ\83Ñ\9bе Ð¾Ñ\82пÑ\80емаÑ\9aе SVG Ð´Ð°Ñ\82оÑ\82ека које садрже нестандардну DTD декларацију.",
        "uploaded-script-svg": "Пронађен скриптни елеменат „$1“ у постављеној SVG датотеци.",
        "uploaded-hostile-svg": "Пронађен небезбедан CSS у стилском елементу постављене SVG датотеке.",
        "uploaded-event-handler-on-svg": "Није дозвољено постављање атрибута који контролишу догађаје <code>$1=\"$2\"</code> у SVG датотекама.",
index ad7a331..415a27a 100644 (file)
        "log": "Günlükler",
        "logeventslist-submit": "Göster",
        "logeventslist-more-filters": "Daha fazla süzgeç:",
+       "logeventslist-patrol-log": "Devriye günlüğü",
+       "logeventslist-tag-log": "Etiket günlüğü",
        "all-logs-page": "Tüm genel günlükler",
        "alllogstext": "{{SITENAME}} için mevcut tüm günlüklerin birleşik gösterimi.\nGünlük tipini, kullanıcı adını (büyük-küçük harf duyarlı), ya da etkilenen sayfayı (yine büyük-küçük harf duyarlı) seçerek görünümü daraltabilirsiniz.",
        "logempty": "Kayıtlarda eşleşen bilgi yok.",
        "dellogpage": "Silme günlüğü",
        "dellogpagetext": "Aşağıda en son silme işlemlerinin bir listesi bulunmaktadır.",
        "deletionlog": "silme günlüğü",
+       "logentry-create-create": "$1, $3 adlı sayfayı {{GENDER:$2|oluşturdu}}",
        "reverted": "Önceki sürüm geri getirildi",
        "deletecomment": "Neden:",
        "deleteotherreason": "Diğer/ilave neden:",
index 2330ae4..789dc16 100644 (file)
        "compare-rev1": "Беренче юрама",
        "compare-rev2": "Икенче юрама",
        "compare-submit": "Чагыштыр",
+       "permanentlink": "Даими сылтама",
        "dberr-problems": "Гафу итегез! Сайтта техник кыенлыклар чыкты.",
        "dberr-again": "Сәхифәне берничә минуттан соң яңартып карагыз.",
        "dberr-info": "(Мәгълүматлар базасы серверы белән тоташырга мөмкин түгел: $1)",
index ca421c9..8ed22c4 100644 (file)
        "move-page-legend": "منتقلئ صفحہ",
        "movepagetext": "درج ذیل فارم کے ذریعہ صفحہ کو نیا نام دیا جاسکتا ہے، اس کے ساتھ صفحہ کا تاریخچہ بھی منتقل ہو جائے گا اور نئے عنوان کے جانب قدیم عنوان کو رجوع مکرر کردیا جائے گا۔\n\nاس بات کا یقین کر لیں کہ [[Special:DoubleRedirects|دوہرے]] یا [[Special:BrokenRedirects|شکستہ رجوع مکررات]] موجود نہ ہوں۔\n\nنیز آپ اس بات کے بھی ذمہ دار ہیں کہ روابط انہیں جگہوں سے مربوط رہیں جہاں سے ابھی ہیں۔\n\nخیال رہے کہ اگر نئے عنوان سے کوئی صفحہ پہلے سے موجود ہو تو یہ صفحہ منتقل '''نہیں''' ہوگا، ہاں اگر صفحہ خالی ہو اور اس کا گذشتہ ترمیمی تاریخچہ موجود نہ ہو تو منتقل کیا جا سکتا ہے۔\nاس کا مطلب یہ ہے کہ آپ سے اگر غلطی ہوجائے تو آپ صفحہ کو اسی جگہ لوٹا سکتے ہیں، تاہم موجود صفحہ پر برتحریر (overwrite) نہیں کرسکتے۔\n\n<strong>اطلاع:</strong>\nکسی اہم اور مقبول صفحہ کی منتقلی، غیرمتوقع اور پریشان کن بھی ہی ہوسکتی ہے اس لیے منتقلی سے قبل یقین کرلیں کہ آپ اس کے منطقی نتائج سے باخبر ہیں۔",
        "movepagetext-noredirectfixer": "درج ذیل فارم کے ذریعہ صفحہ کو نیا نام دیا جاسکتا ہے، اس کے ساتھ صفحہ کا تاریخچہ بھی منتقل ہو جائے گا اور نئے عنوان کے جانب قدیم عنوان کو رجوع مکرر کردیا جائے گا۔\n\nاس بات کا یقین کر لیں کہ [[Special:DoubleRedirects|دوہرے]] یا [[Special:BrokenRedirects|شکستہ رجوع مکررات]] موجود نہ ہوں۔\n\nنیز آپ اس بات کے بھی ذمہ دار ہیں کہ روابط انہیں جگہوں سے مربوط رہیں جہاں سے ابھی ہیں۔\n\nخیال رہے کہ اگر نئے عنوان سے کوئی صفحہ پہلے سے موجود ہو تو یہ صفحہ منتقل '''نہیں''' ہوگا، ہاں اگر صفحہ خالی ہو اور اس کا گذشتہ ترمیمی تاریخچہ موجود نہ ہو تو منتقل کیا جا سکتا ہے۔\nاس کا مطلب یہ ہے کہ آپ سے اگر غلطی ہوجائے تو آپ صفحہ کو اسی جگہ لوٹا سکتے ہیں، تاہم موجود صفحہ پر برتحریر (overwrite) نہیں کرسکتے۔\n\n<strong>اطلاع:</strong>\nکسی اہم اور مقبول صفحہ کی منتقلی، غیرمتوقع اور پریشان کن بھی ہی ہوسکتی ہے اس لیے منتقلی سے قبل یقین کرلیں کہ آپ اس کے منطقی نتائج سے باخبر ہیں۔",
-       "movepagetalktext": "اگر آپ اس خانے کو نشان زد کریں تو ملحقہ تبادلہ خیال صفحہ بھی نئے عنوان کی جانب خودکار طور پر منتقل ہو جائے گا اگر اس عنوان کے تحت پہلے سے کوئی تبادلۂ خیال صفحہ موجود نہ ہو۔\n\nاس صورت میں آپ کو دستی طور پر اس صفحہ کو منتقل ضم کرنا ہوگا۔",
+       "movepagetalktext": "اگر آپ اس خانے کو نشان زد کریں تو ملحقہ تبادلہ خیال صفحہ بھی (بشرطیکہ موجود ہو) نئے عنوان کی جانب خودکار طور پر منتقل ہو جائے گا۔\n\nاگر آپ نے اس خانہ کو نشان زد نہیں کیا تو ملحقہ تبادلہ خیال صفحہ کو دستی طور پر منتقل کرکے ضم کرنا ہوگا۔",
        "moveuserpage-warning": "<strong>انتباہ:</strong> آپ صارف صفحہ کو منتقل کر رہے ہیں۔ واضح رہے کہ اس منتقلی کے بعد صارف کا محض صفحہ منتقل ہوگا، اس کا صارف نام تبدیل <em>نہیں</em> ہوگا۔",
        "movecategorypage-warning": "<strong>انتباہ:</strong> آپ زمرہ منتقل کر رہے ہیں۔ واضح رہے کہ منتقلی کے بعد اس زمرے میں موجود صفحات نئے زمرے میں منتقل <em>نہیں</em> ہونگے۔",
        "movenologintext": "صفحہ کو منتقل کرنے کے لیے آپ کو اپنے کھاتے میں [[Special:UserLogin|داخل ہونا]] ضروری ہے۔",
index 5f57a44..d20f14a 100644 (file)
        "edit-error-long": "錯誤:\n\n$1",
        "revid": "修訂 $1",
        "pageid": "頁面 ID $1",
-       "interfaceadmin-info": "$1\n\n編輯全站 CSS/JS/JSON 檔案的權限,剛剛已從 <code>editinterface</code> 權限裡拆分。若您不清楚為何會收到此錯誤,請查看 [[mw:MediaWiki_1.32/interface-admin]]。",
+       "interfaceadmin-info": "$1\n\n編輯全站 CSS/JS/JSON 檔案的權限,近期已從 <code>editinterface</code> 權限裡拆分。若您不清楚為何會收到此錯誤,請查看 [[mw:MediaWiki_1.32/interface-admin]]。",
        "rawhtml-notallowed": "&lt;html&gt; 標籤無法在一般頁面之外使用。",
        "gotointerwiki": "離開 {{SITENAME}}",
        "gotointerwiki-invalid": "指定的標題無效。",
index bb88040..2a05046 100644 (file)
@@ -177,7 +177,6 @@ EXCLUDE                = {{EXCLUDE}}
 EXCLUDE_SYMLINKS       = YES
 EXCLUDE_PATTERNS       = LocalSettings.php \
                          AdminSettings.php \
-                         StartProfiler.php \
                          .svn \
                          */.git/* \
                          {{EXCLUDE_PATTERNS}}
index 70fdebf..dc1de4f 100644 (file)
@@ -485,7 +485,9 @@ class GenerateSitemap extends Maintenance {
         */
        function indexEntry( $filename ) {
                return "\t<sitemap>\n" .
-                       "\t\t<loc>{$this->urlpath}$filename</loc>\n" .
+                       "\t\t<loc>" . wfGetServerUrl( PROTO_CANONICAL ) .
+                               ( substr( $this->urlpath, 0, 1 ) === "/" ? "" : "/" ) .
+                               "{$this->urlpath}$filename</loc>\n" .
                        "\t\t<lastmod>{$this->timestamp}</lastmod>\n" .
                        "\t</sitemap>\n";
        }
index b8d9848..6a02eea 100644 (file)
@@ -1,17 +1,39 @@
 ### Format of this file
 #
 # The top-level keys are module names (as registered in Resources.php).
-# The values of these keys are resource descriptors.
+# Each top-level key holds a resource descriptor that must have one of
+# the following `type` values:
 #
-# In each resource descriptor object, the `src` and `integrity` keys are required.
+# - `tar`: For tarball archive (may be gzip-compressed).
+# - `file: For a plain file.
+# - `multi-file`: For multiple plain files.
 #
-# * `src`: Full URL to a remote resource.
-# * `integrity`: Cryptographic hash used to verify the remote content.
-#    Uses the "integrity metadata" format defined at <https://www.w3.org/TR/SRI/>.
-# * `dest`: An object mapping paths from the remote resource to a destination in
-#    `/resources/lib/$module/`. The value may be omitted to indicate that
-#    paths should be extracted to the destination directory itself.
+### Type tar
+#
+# The `src` and `integrity` keys are quired.
+#
+# * `src`: Full URL to thes remote resource.
+# * `integrity`: Cryptographic hash (integrity metadata format per <https://www.w3.org/TR/SRI/>).
+# * `dest`: An object mapping paths to files or directory from the remote resource to a destination
+#    in the module directory. The value of key in dest may be omitted, which will extract the key
+#    directly to the module directory.
+#
+### Type file
+#
+# The `src` and `integrity` keys are quired.
+#
+# * `src`: Full URL to thes remote resource.
+# * `integrity`: Cryptographic hash (integrity metadata format per <https://www.w3.org/TR/SRI/>).
+# * `dest`: The name of the file in the module directory. Default: Basename of URL.
+#
+### Type mult-file
+#
+# The `files` key is required.
+#
+# * `files`: An object mapping destination paths to an object containing `src` and `integrity`
+#    keys.
 oojs:
+  type: tar
   src: https://registry.npmjs.org/oojs/-/oojs-2.2.2.tgz
   integrity: sha256-ebgQW2EGrSkBCnDJBGqDpsBDjA3PMN/M8U5DyLHt9mw=
   dest:
@@ -20,6 +42,7 @@ oojs:
     package/LICENSE-MIT:
     package/README.md:
 oojs-ui:
+  type: tar
   src: https://registry.npmjs.org/oojs-ui/-/oojs-ui-0.28.0.tgz
   integrity: sha384-j8bzlCPrfS4sca+U9JO9tdcewDlLlDlOVOsLn+Vqlcg5GU59vLSd7TVm4FiuTowy
   dest:
@@ -48,3 +71,18 @@ oojs-ui:
     package/dist/History.md:
     package/dist/LICENSE-MIT:
     package/dist/README.md:
+jquery:
+  type: file
+  src: https://code.jquery.com/jquery-3.2.1.js
+  # From https://code.jquery.com/jquery/
+  integrity: sha256-DZAnKJ/6XZ9si04Hgrsxu/8s717jcIzLy3oi35EouyE=
+  dest: jquery.js
+qunitjs:
+  type: multi-file
+  files:
+    qunit.js:
+      src: https://code.jquery.com/qunit/qunit-2.6.0.js
+      integrity: sha384-5O3bKbJBbAbxsqV+w/I1fcXgWJgbqM+hmYAPOE9aELSYpcTEsv48X8H+Hnq66V/9
+    qunit.css:
+      src: https://code.jquery.com/qunit/qunit-2.6.0.css
+      integrity: sha384-8vDvsmsuiD7tCQyC+pW2LOwDDgsluGsIPeCqr3rHsDSF2k4WpmfvKKxcgSV5zPai
index 528d6e7..41e579b 100644 (file)
@@ -30,6 +30,8 @@ require_once __DIR__ . '/../Maintenance.php';
 class ManageForeignResources extends Maintenance {
        private $defaultAlgo = 'sha384';
        private $tmpParentDir;
+       private $action;
+       private $failAfterOutput = false;
 
        public function __construct() {
                global $IP;
@@ -40,17 +42,16 @@ Manage foreign resources registered with ResourceLoader.
 This helps developers to download, verify and update local copies of upstream
 libraries registered as ResourceLoader modules. See also foreign-resources.yaml.
 
-For sources that don't publish an integrity hash, leave the value empty at
-first, and run this script with --make-sri to compute the hashes.
+For sources that don't publish an integrity hash, omit "integrity" (or leave empty)
+and run the "make-sri" action to compute the missing hashes.
 
 This script runs in dry mode by default. Use --update to actually change, remove,
 or add files to /resources/lib/.
 TEXT
                );
+               $this->addArg( 'action', 'One of "update", "verify" or "make-sri"', true );
                $this->addArg( 'module', 'Name of a single module (Default: all)', false );
-               $this->addOption( 'update', ' resources/lib/ missing integrity metadata' );
-               $this->addOption( 'make-sri', 'Compute missing integrity metadata' );
-               $this->addOption( 'verbose', 'Be verbose' );
+               $this->addOption( 'verbose', 'Be verbose', false, false, 'v' );
 
                // Use a directory in $IP instead of wfTempDir() because
                // PHP's rename() does not work across file systems.
@@ -59,67 +60,127 @@ TEXT
 
        public function execute() {
                global $IP;
-               $module = $this->getArg();
-               $makeSRI = $this->hasOption( 'make-sri' );
+               $this->action = $this->getArg( 0 );
+               if ( !in_array( $this->action, [ 'update', 'verify', 'make-sri' ] ) ) {
+                       $this->fatalError( "Invalid action argument." );
+               }
 
                $registry = $this->parseBasicYaml(
                        file_get_contents( __DIR__ . '/foreign-resources.yaml' )
                );
+               $module = $this->getArg( 1, 'all' );
                foreach ( $registry as $moduleName => $info ) {
-                       if ( $module !== null && $moduleName !== $module ) {
+                       if ( $module !== 'all' && $moduleName !== $module ) {
                                continue;
                        }
                        $this->verbose( "\n### {$moduleName}\n\n" );
+                       $destDir = "{$IP}/resources/lib/$moduleName";
 
-                       // Validate required keys
-                       $info += [ 'src' => null, 'integrity' => null, 'dest' => null ];
-                       if ( $info['src'] === null ) {
-                               $this->fatalError( "Module '$moduleName' must have a 'src' key." );
+                       if ( $this->action === 'update' ) {
+                               $this->output( "... updating '{$moduleName}'\n" );
+                               $this->verbose( "... emptying /resources/lib/$moduleName\n" );
+                               wfRecursiveRemoveDir( $destDir );
+                       } elseif ( $this->action === 'verify' ) {
+                               $this->output( "... verifying '{$moduleName}'\n" );
+                       } else {
+                               $this->output( "... checking '{$moduleName}'\n" );
                        }
-                       $integrity = is_string( $info['integrity'] ) ? $info['integrity'] : $makeSRI;
-                       if ( $integrity === false ) {
-                               $this->fatalError( "Module '$moduleName' must have an 'integrity' key." );
+
+                       $this->verbose( "... preparing {$this->tmpParentDir}\n" );
+                       wfRecursiveRemoveDir( $this->tmpParentDir );
+                       if ( !wfMkdirParents( $this->tmpParentDir ) ) {
+                               $this->fatalError( "Unable to create {$this->tmpParentDir}" );
                        }
 
-                       // Download the resource
-                       $data = Http::get( $info['src'], [ 'followRedirects' => false ] );
-                       if ( $data === false ) {
-                               $this->fatalError( "Failed to download resource for '$moduleName'." );
+                       if ( !isset( $info['type'] ) ) {
+                               $this->fatalError( "Module '$moduleName' must have a 'type' key." );
+                       }
+                       switch ( $info['type'] ) {
+                               case 'tar':
+                                       $this->handleTypeTar( $moduleName, $destDir, $info );
+                                       break;
+                               case 'file':
+                                       $this->handleTypeFile( $moduleName, $destDir, $info );
+                                       break;
+                               case 'multi-file':
+                                       $this->handleTypeMultiFile( $moduleName, $destDir, $info );
+                                       break;
+                               default:
+                                       $this->fatalError( "Unknown type '{$info['type']}' for '$moduleName'" );
                        }
+               }
 
-                       // Validate integrity metadata
-                       $this->output( "... checking integrity of '{$moduleName}'\n" );
-                       $algo = $integrity === true ? $this->defaultAlgo : explode( '-', $integrity )[0];
-                       $actualIntegrity = $algo . '-' . base64_encode( hash( $algo, $data, true ) );
-                       if ( $integrity === true ) {
-                               $this->output( "Integrity for '{$moduleName}':\n\t${actualIntegrity}\n" );
-                               continue;
-                       } elseif ( $integrity !== $actualIntegrity ) {
-                               $this->fatalError( "Integrity check failed for '{$moduleName}:\n" .
-                                       "Expected: {$integrity}\n" .
-                                       "Actual: {$actualIntegrity}"
+               $this->cleanUp();
+               $this->output( "\nDone!\n" );
+               if ( $this->failAfterOutput ) {
+                       // The verify mode should check all modules/files and fail after, not during.
+                       return false;
+               }
+       }
+
+       private function fetch( $src, $integrity ) {
+               $data = Http::get( $src, [ 'followRedirects' => false ] );
+               if ( $data === false ) {
+                       $this->fatalError( "Failed to download resource at {$src}" );
+               }
+               $algo = $integrity === null ? $this->defaultAlgo : explode( '-', $integrity )[0];
+               $actualIntegrity = $algo . '-' . base64_encode( hash( $algo, $data, true ) );
+               if ( $integrity === $actualIntegrity ) {
+                       $this->verbose( "... passed integrity check for {$src}\n" );
+               } else {
+                       if ( $this->action === 'make-sri' ) {
+                               $this->output( "Integrity for {$src}\n\tintegrity: ${actualIntegrity}\n" );
+                       } else {
+                               $this->fatalError( "Integrity check failed for {$src}\n" .
+                                       "\tExpected: {$integrity}\n" .
+                                       "\tActual: {$actualIntegrity}"
                                );
                        }
-
-                       // Determine destination
-                       $destDir = "{$IP}/resources/lib/$moduleName";
-                       $this->output( "... extracting files for '{$moduleName}'\n" );
-                       $this->handleTypeTar( $moduleName, $data, $destDir, $info );
                }
+               return $data;
+       }
 
-               // Clean up
-               wfRecursiveRemoveDir( $this->tmpParentDir );
-               $this->output( "\nDone!\n" );
+       private function handleTypeFile( $moduleName, $destDir, array $info ) {
+               if ( !isset( $info['src'] ) ) {
+                       $this->fatalError( "Module '$moduleName' must have a 'src' key." );
+               }
+               $data = $this->fetch( $info['src'], $info['integrity'] ?? null );
+               $dest = $info['dest'] ?? basename( $info['src'] );
+               $path = "$destDir/$dest";
+               if ( $this->action === 'verify' && sha1_file( $path ) !== sha1( $data ) ) {
+                       $this->fatalError( "File for '$moduleName' is different." );
+               } elseif ( $this->action === 'update' ) {
+                       wfMkdirParents( $destDir );
+                       file_put_contents( "$destDir/$dest", $data );
+               }
        }
 
-       private function handleTypeTar( $moduleName, $data, $destDir, array $info ) {
-               global $IP;
-               wfRecursiveRemoveDir( $this->tmpParentDir );
-               if ( !wfMkdirParents( $this->tmpParentDir ) ) {
-                       $this->fatalError( "Unable to create {$this->tmpParentDir}" );
+       private function handleTypeMultiFile( $moduleName, $destDir, array $info ) {
+               if ( !isset( $info['files'] ) ) {
+                       $this->fatalError( "Module '$moduleName' must have a 'files' key." );
                }
+               foreach ( $info['files'] as $dest => $file ) {
+                       if ( !isset( $file['src'] ) ) {
+                               $this->fatalError( "Module '$moduleName' file '$dest' must have a 'src' key." );
+                       }
+                       $data = $this->fetch( $file['src'], $file['integrity'] ?? null );
+                       $path = "$destDir/$dest";
+                       if ( $this->action === 'verify' && sha1_file( $path ) !== sha1( $data ) ) {
+                               $this->fatalError( "File '$dest' for '$moduleName' is different." );
+                       } elseif ( $this->action === 'update' ) {
+                               wfMkdirParents( $destDir );
+                               file_put_contents( "$destDir/$dest", $data );
+                       }
+               }
+       }
 
-               // Write resource to temporary file and open it
+       private function handleTypeTar( $moduleName, $destDir, array $info ) {
+               $info += [ 'src' => null, 'integrity' => null, 'dest' => null ];
+               if ( $info['src'] === null ) {
+                       $this->fatalError( "Module '$moduleName' must have a 'src' key." );
+               }
+               // Download the resource to a temporary file and open it
+               $data = $this->fetch( $info['src'], $info['integrity' ] );
                $tmpFile = "{$this->tmpParentDir}/$moduleName.tar";
                $this->verbose( "... writing '$moduleName' src to $tmpFile\n" );
                file_put_contents( $tmpFile, $data );
@@ -129,46 +190,45 @@ TEXT
                unset( $data, $p );
 
                if ( $info['dest'] === null ) {
-                       // Replace the entire directory as-is
-                       if ( !$this->hasOption( 'update' ) ) {
-                               $this->output( "[dry run] Would replace /resources/lib/$moduleName\n" );
-                       } else {
-                               wfRecursiveRemoveDir( $destDir );
-                               if ( !rename( $tmpDir, $destDir ) ) {
-                                       $this->fatalError( "Could not move $destDir to $tmpDir." );
-                               }
-                       }
-                       return;
-               }
-
-               // Create and/or empty the destination
-               if ( !$this->hasOption( 'update' ) ) {
-                       $this->output( "... [dry run] would empty /resources/lib/$moduleName\n" );
+                       // Default: Replace the entire directory
+                       $toCopy = [ $tmpDir => $destDir ];
                } else {
-                       wfRecursiveRemoveDir( $destDir );
-                       wfMkdirParents( $destDir );
-               }
-
-               // Expand and normalise the 'dest' entries
-               $toCopy = [];
-               foreach ( $info['dest'] as $fromSubPath => $toSubPath ) {
-                       // Use glob() to expand wildcards and check existence
-                       $fromPaths = glob( "{$tmpDir}/{$fromSubPath}", GLOB_BRACE );
-                       if ( !$fromPaths ) {
-                               $this->fatalError( "Path '$fromSubPath' of '$moduleName' not found." );
-                       }
-                       foreach ( $fromPaths as $fromPath ) {
-                               $toCopy[$fromPath] = $toSubPath === null
-                                       ? "$destDir/" . basename( $fromPath )
-                                       : "$destDir/$toSubPath/" . basename( $fromPath );
+                       // Expand and normalise the 'dest' entries
+                       $toCopy = [];
+                       foreach ( $info['dest'] as $fromSubPath => $toSubPath ) {
+                               // Use glob() to expand wildcards and check existence
+                               $fromPaths = glob( "{$tmpDir}/{$fromSubPath}", GLOB_BRACE );
+                               if ( !$fromPaths ) {
+                                       $this->fatalError( "Path '$fromSubPath' of '$moduleName' not found." );
+                               }
+                               foreach ( $fromPaths as $fromPath ) {
+                                       $toCopy[$fromPath] = $toSubPath === null
+                                               ? "$destDir/" . basename( $fromPath )
+                                               : "$destDir/$toSubPath/" . basename( $fromPath );
+                               }
                        }
                }
                foreach ( $toCopy as $from => $to ) {
-                       if ( !$this->hasOption( 'update' ) ) {
-                               $shortFrom = strtr( $from, [ "$tmpDir/" => '' ] );
-                               $shortTo = strtr( $to, [ "$IP/" => '' ] );
-                               $this->output( "... [dry run] would move $shortFrom to $shortTo\n" );
-                       } else {
+                       if ( $this->action === 'verify' ) {
+                               $this->verbose( "... verifying $to\n" );
+                               if ( is_dir( $from ) ) {
+                                       $rii = new RecursiveIteratorIterator( new RecursiveDirectoryIterator(
+                                               $from,
+                                               RecursiveDirectoryIterator::SKIP_DOTS
+                                       ) );
+                                       foreach ( $rii as $file ) {
+                                               $remote = $file->getPathname();
+                                               $local = strtr( $remote, [ $from => $to ] );
+                                               if ( sha1_file( $remote ) !== sha1_file( $local ) ) {
+                                                       $this->error( "File '$local' is different." );
+                                                       $this->failAfterOutput = true;
+                                               }
+                                       }
+                               } elseif ( sha1_file( $from ) !== sha1_file( $to ) ) {
+                                       $this->error( "File '$to' is different." );
+                                       $this->failAfterOutput = true;
+                               }
+                       } elseif ( $this->action === 'update' ) {
                                $this->verbose( "... moving $from to $to\n" );
                                wfMkdirParents( dirname( $to ) );
                                if ( !rename( $from, $to ) ) {
@@ -184,6 +244,15 @@ TEXT
                }
        }
 
+       private function cleanUp() {
+               wfRecursiveRemoveDir( $this->tmpParentDir );
+       }
+
+       protected function fatalError( $msg, $exitCode = 1 ) {
+               $this->cleanUp();
+               parent::fatalError( $msg, $exitCode );
+       }
+
        /**
         * Basic YAML parser.
         *
index aa86a4b..a163a3d 100644 (file)
@@ -2,7 +2,7 @@
        'use strict';
 
        var notification,
-               // The #mw-notification-area div that all notifications are contained inside.
+               // The .mw-notification-area div that all notifications are contained inside.
                $area,
                // Number of open notification boxes at any time
                openNotificationCount = 0,
 
                // Write to the DOM:
                // Prepend the notification area to the content area and save its object.
+               // The ID attribute here is deprecated.
                $area = $( '<div id="mw-notification-area" class="mw-notification-area mw-notification-area-layout"></div>' )
                        // Pause auto-hide timers when the mouse is in the notification area.
                        .on( {
index 9e40db9..3fb7ffc 100644 (file)
                         * @param {string} module Module name to execute
                         */
                        function execute( module ) {
-                               var key, value, media, i, urls, cssHandle, checkCssHandles, runScript,
-                                       cssHandlesRegistered = false;
+                               var key, value, media, i, urls, cssHandle, siteDeps, siteDepErr, runScript,
+                                       cssPending = 0;
 
                                if ( !hasOwn.call( registry, module ) ) {
                                        throw new Error( 'Module has not been registered yet: ' + module );
                                        mw.templates.set( module, registry[ module ].templates );
                                }
 
-                               // Make sure we don't run the scripts until all stylesheet insertions have completed.
-                               ( function () {
-                                       var pending = 0;
-                                       checkCssHandles = function () {
-                                               var ex, dependencies;
-                                               // cssHandlesRegistered ensures we don't take off too soon, e.g. when
-                                               // one of the cssHandles is fired while we're still creating more handles.
-                                               if ( cssHandlesRegistered && pending === 0 && runScript ) {
-                                                       if ( module === 'user' ) {
-                                                               // Implicit dependency on the site module. Not real dependency because
-                                                               // it should run after 'site' regardless of whether it succeeds or fails.
-                                                               // Note: This is a simplified version of mw.loader.using(), inlined here
-                                                               // as using() depends on jQuery (T192623).
-                                                               try {
-                                                                       dependencies = resolve( [ 'site' ] );
-                                                               } catch ( e ) {
-                                                                       ex = e;
-                                                                       runScript();
-                                                               }
-                                                               if ( ex === undefined ) {
-                                                                       enqueue( dependencies, runScript, runScript );
-                                                               }
-                                                       } else {
-                                                               runScript();
-                                                       }
-                                                       runScript = undefined; // Revoke
+                               // Adding of stylesheets is asynchronous via addEmbeddedCSS().
+                               // The below function uses a counting semaphore to make sure we don't call
+                               // runScript() until after this module's stylesheets have been inserted
+                               // into the DOM.
+                               cssHandle = function () {
+                                       // Increase semaphore, when creating a callback for addEmbeddedCSS.
+                                       cssPending++;
+                                       return function () {
+                                               var runScriptCopy;
+                                               // Decrease semaphore, when said callback is invoked.
+                                               cssPending--;
+                                               if ( cssPending === 0 ) {
+                                                       // Paranoia:
+                                                       // This callback is exposed to addEmbeddedCSS, which is outside the execute()
+                                                       // function and is not concerned with state-machine integrity. In turn,
+                                                       // addEmbeddedCSS() actually exposes stuff further into the browser (rAF).
+                                                       // If increment and decrement callbacks happen in the wrong order, or start
+                                                       // again afterwards, then this branch could be reached multiple times.
+                                                       // To protect the integrity of the state-machine, prevent that from happening
+                                                       // by making runScript() cannot be called more than once.  We store a private
+                                                       // reference when we first reach this branch, then deference the original, and
+                                                       // call our reference to it.
+                                                       runScriptCopy = runScript;
+                                                       runScript = undefined;
+                                                       runScriptCopy();
                                                }
                                        };
-                                       cssHandle = function () {
-                                               var check = checkCssHandles;
-                                               pending++;
-                                               return function () {
-                                                       if ( check ) {
-                                                               pending--;
-                                                               check();
-                                                               check = undefined; // Revoke
-                                                       }
-                                               };
-                                       };
-                               }() );
+                               };
 
                                // Process styles (see also mw.loader.implement)
                                // * back-compat: { <media>: css }
                                        }
                                }
 
-                               // End profiling of execute()-self before we call checkCssHandles(),
-                               // which (sometimes asynchronously) calls runScript(), which we want
-                               // to measure separately without overlap.
+                               // End profiling of execute()-self before we call runScript(),
+                               // which we want to measure separately without overlap.
                                $CODE.profileExecuteEnd();
 
-                               // Kick off.
-                               cssHandlesRegistered = true;
-                               checkCssHandles();
+                               if ( module === 'user' ) {
+                                       // Implicit dependency on the site module. Not a real dependency because it should
+                                       // run after 'site' regardless of whether it succeeds or fails.
+                                       // Note: This is a simplified version of mw.loader.using(), inlined here because
+                                       // mw.loader.using() is part of mediawiki.base (depends on jQuery; T192623).
+                                       try {
+                                               siteDeps = resolve( [ 'site' ] );
+                                       } catch ( e ) {
+                                               siteDepErr = e;
+                                               runScript();
+                                       }
+                                       if ( siteDepErr === undefined ) {
+                                               enqueue( siteDeps, runScript, runScript );
+                                       }
+                               } else if ( cssPending === 0 ) {
+                                       // Regular module without styles
+                                       runScript();
+                               }
+                               // else: runScript will get called via cssHandle()
                        }
 
                        function sortQuery( o ) {
index b0cefc7..42ea9ed 100644 (file)
@@ -2037,82 +2037,6 @@ class OutputPageTest extends MediaWikiTestCase {
                ] );
        }
 
-       /**
-        * @dataProvider providePreloadLinkHeaders
-        * @covers OutputPage::addLogoPreloadLinkHeaders
-        * @covers ResourceLoaderSkinModule::getLogo
-        */
-       public function testPreloadLinkHeaders( $config, $result, $baseDir = null ) {
-               if ( $baseDir ) {
-                       $this->setMwGlobals( 'IP', $baseDir );
-               }
-               $out = TestingAccessWrapper::newFromObject( $this->newInstance( $config ) );
-               $out->addLogoPreloadLinkHeaders();
-
-               $this->assertEquals( $result, $out->getLinkHeader() );
-       }
-
-       public function providePreloadLinkHeaders() {
-               return [
-                       [
-                               [
-                                       'ResourceBasePath' => '/w',
-                                       'Logo' => '/img/default.png',
-                                       'LogoHD' => [
-                                               '1.5x' => '/img/one-point-five.png',
-                                               '2x' => '/img/two-x.png',
-                                       ],
-                               ],
-                               'Link: </img/default.png>;rel=preload;as=image;media=' .
-                               'not all and (min-resolution: 1.5dppx),' .
-                               '</img/one-point-five.png>;rel=preload;as=image;media=' .
-                               '(min-resolution: 1.5dppx) and (max-resolution: 1.999999dppx),' .
-                               '</img/two-x.png>;rel=preload;as=image;media=(min-resolution: 2dppx)'
-                       ],
-                       [
-                               [
-                                       'ResourceBasePath' => '/w',
-                                       'Logo' => '/img/default.png',
-                                       'LogoHD' => false,
-                               ],
-                               'Link: </img/default.png>;rel=preload;as=image'
-                       ],
-                       [
-                               [
-                                       'ResourceBasePath' => '/w',
-                                       'Logo' => '/img/default.png',
-                                       'LogoHD' => [
-                                               '2x' => '/img/two-x.png',
-                                       ],
-                               ],
-                               'Link: </img/default.png>;rel=preload;as=image;media=' .
-                               'not all and (min-resolution: 2dppx),' .
-                               '</img/two-x.png>;rel=preload;as=image;media=(min-resolution: 2dppx)'
-                       ],
-                       [
-                               [
-                                       'ResourceBasePath' => '/w',
-                                       'Logo' => '/img/default.png',
-                                       'LogoHD' => [
-                                               'svg' => '/img/vector.svg',
-                                       ],
-                               ],
-                               'Link: </img/vector.svg>;rel=preload;as=image'
-
-                       ],
-                       [
-                               [
-                                       'ResourceBasePath' => '/w',
-                                       'Logo' => '/w/test.jpg',
-                                       'LogoHD' => false,
-                                       'UploadPath' => '/w/images',
-                               ],
-                               'Link: </w/test.jpg?edcf2>;rel=preload;as=image',
-                               'baseDir' => dirname( __DIR__ ) . '/data/media',
-                       ],
-               ];
-       }
-
        /**
         * @return OutputPage
         */
index 8a40266..7f2c1a6 100644 (file)
@@ -77,11 +77,15 @@ class ApiParseTest extends ApiTestCase {
                        $expectedEnd = "</div>";
                        $this->assertSame( $expectedEnd, substr( $html, -strlen( $expectedEnd ) ) );
 
+                       $unexpectedEnd = '#<!-- \nNewPP limit report|' .
+                               '<!--\nTransclusion expansion time report#s';
+                       $this->assertNotRegExp( $unexpectedEnd, $html );
+
                        $html = substr( $html, 0, strlen( $html ) - strlen( $expectedEnd ) );
                } else {
                        $expectedEnd = '#\n<!-- \nNewPP limit report\n(?>.+?\n-->)\n' .
                                '<!--\nTransclusion expansion time report \(%,ms,calls,template\)\n(?>.*?\n-->)\n' .
-                               '</div>(\n<!-- Saved in parser cache (?>.*?\n -->)\n)?$#s';
+                               '(\n<!-- Saved in parser cache (?>.*?\n -->)\n)?</div>$#s';
                        $this->assertRegExp( $expectedEnd, $html );
 
                        $html = preg_replace( $expectedEnd, '', $html );
diff --git a/tests/phpunit/includes/page/ArticleViewTest.php b/tests/phpunit/includes/page/ArticleViewTest.php
new file mode 100644 (file)
index 0000000..d721274
--- /dev/null
@@ -0,0 +1,488 @@
+<?php
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\RevisionRecord;
+use PHPUnit\Framework\MockObject\MockObject;
+
+/**
+ * @covers \Article::view()
+ */
+class ArticleViewTest extends MediaWikiTestCase {
+
+       protected function setUp() {
+               parent::setUp();
+
+               $this->setUserLang( 'qqx' );
+       }
+
+       private function getHtml( OutputPage $output ) {
+               return preg_replace( '/<!--.*?-->/s', '', $output->getHTML() );
+       }
+
+       /**
+        * @param string|Title $title
+        * @param Content[]|string[] $revisionContents Content of the revisions to create
+        *        (as Content or string).
+        * @param RevisionRecord[] &$revisions will be filled with the RevisionRecord for $content.
+        *
+        * @return WikiPage
+        * @throws MWException
+        */
+       private function getPage( $title, array $revisionContents = [], array &$revisions = [] ) {
+               if ( is_string( $title ) ) {
+                       $title = Title::makeTitle( $this->getDefaultWikitextNS(), $title );
+               }
+
+               $page = WikiPage::factory( $title );
+
+               $user = $this->getTestUser()->getUser();
+
+               foreach ( $revisionContents as $key => $cont ) {
+                       if ( is_string( $cont ) ) {
+                               $cont = new WikitextContent( $cont );
+                       }
+
+                       $u = $page->newPageUpdater( $user );
+                       $u->setContent( 'main', $cont );
+                       $rev = $u->saveRevision( CommentStoreComment::newUnsavedComment( 'Rev ' . $key ) );
+
+                       $revisions[ $key ] = $rev;
+               }
+
+               return $page;
+       }
+
+       /**
+        * @covers Article::getOldId()
+        * @covers Article::getRevIdFetched()
+        */
+       public function testGetOldId() {
+               $revisions = [];
+               $page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );
+
+               $idA = $revisions[1]->getId();
+               $idB = $revisions[2]->getId();
+
+               // oldid in constructor
+               $article = new Article( $page->getTitle(), $idA );
+               $this->assertSame( $idA, $article->getOldID() );
+               $article->getRevisionFetched();
+               $this->assertSame( $idA, $article->getRevIdFetched() );
+
+               // oldid 0 in constructor
+               $article = new Article( $page->getTitle(), 0 );
+               $this->assertSame( 0, $article->getOldID() );
+               $article->getRevisionFetched();
+               $this->assertSame( $idB, $article->getRevIdFetched() );
+
+               // oldid in request
+               $article = new Article( $page->getTitle() );
+               $context = new RequestContext();
+               $context->setRequest( new FauxRequest( [ 'oldid' => $idA ] ) );
+               $article->setContext( $context );
+               $this->assertSame( $idA, $article->getOldID() );
+               $article->getRevisionFetched();
+               $this->assertSame( $idA, $article->getRevIdFetched() );
+
+               // no oldid
+               $article = new Article( $page->getTitle() );
+               $context = new RequestContext();
+               $context->setRequest( new FauxRequest( [] ) );
+               $article->setContext( $context );
+               $this->assertSame( 0, $article->getOldID() );
+               $article->getRevisionFetched();
+               $this->assertSame( $idB, $article->getRevIdFetched() );
+       }
+
+       public function testView() {
+               $page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ] );
+
+               $article = new Article( $page->getTitle(), 0 );
+               $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+               $article->view();
+
+               $output = $article->getContext()->getOutput();
+               $this->assertContains( 'Test B', $this->getHtml( $output ) );
+               $this->assertNotContains( 'id="mw-revision-info"', $this->getHtml( $output ) );
+               $this->assertNotContains( 'id="mw-revision-nav"', $this->getHtml( $output ) );
+       }
+
+       public function testViewCached() {
+               $page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ] );
+
+               $po = new ParserOutput( 'Cached Text' );
+
+               $article = new Article( $page->getTitle(), 0 );
+               $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+
+               $cache = MediaWikiServices::getInstance()->getParserCache();
+               $cache->save( $po, $page, $article->getParserOptions() );
+
+               $article->view();
+
+               $output = $article->getContext()->getOutput();
+               $this->assertContains( 'Cached Text', $this->getHtml( $output ) );
+               $this->assertNotContains( 'Test A', $this->getHtml( $output ) );
+               $this->assertNotContains( 'Test B', $this->getHtml( $output ) );
+       }
+
+       /**
+        * @covers Article::getRedirectTarget()
+        */
+       public function testViewRedirect() {
+               $target = Title::makeTitle( $this->getDefaultWikitextNS(), 'Test_Target' );
+               $redirectText = '#REDIRECT [[' . $target->getPrefixedText() . ']]';
+
+               $page = $this->getPage( __METHOD__, [ $redirectText ] );
+
+               $article = new Article( $page->getTitle(), 0 );
+               $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+               $article->view();
+
+               $this->assertNotNull(
+                       $article->getRedirectTarget()->getPrefixedDBkey()
+               );
+               $this->assertSame(
+                       $target->getPrefixedDBkey(),
+                       $article->getRedirectTarget()->getPrefixedDBkey()
+               );
+
+               $output = $article->getContext()->getOutput();
+               $this->assertContains( 'class="redirectText"', $this->getHtml( $output ) );
+               $this->assertContains(
+                       '>' . htmlspecialchars( $target->getPrefixedText() ) . '<',
+                       $this->getHtml( $output )
+               );
+       }
+
+       public function testViewNonText() {
+               $dummy = $this->getPage( __METHOD__, [ 'Dummy' ] );
+               $dummyRev = $dummy->getRevision()->getRevisionRecord();
+               $title = $dummy->getTitle();
+
+               /** @var MockObject|ContentHandler $mockHandler */
+               $mockHandler = $this->getMockBuilder( ContentHandler::class )
+                       ->setMethods(
+                               [
+                                       'isParserCacheSupported',
+                                       'serializeContent',
+                                       'unserializeContent',
+                                       'makeEmptyContent',
+                               ]
+                       )
+                       ->setConstructorArgs( [ 'NotText', [ 'application/frobnitz' ] ] )
+                       ->getMock();
+
+               $mockHandler->method( 'isParserCacheSupported' )
+                       ->willReturn( false );
+
+               $this->setTemporaryHook(
+                       'ContentHandlerForModelID',
+                       function ( $id, &$handler ) use ( $mockHandler ) {
+                               $handler = $mockHandler;
+                       }
+               );
+
+               /** @var MockObject|Content $content */
+               $content = $this->getMock( Content::class );
+               $content->method( 'getParserOutput' )
+                       ->willReturn( new ParserOutput( 'Structured Output' ) );
+               $content->method( 'getModel' )
+                       ->willReturn( 'NotText' );
+               $content->method( 'getNativeData' )
+                       ->willReturn( [ (object)[ 'x' => 'stuff' ] ] );
+               $content->method( 'copy' )
+                       ->willReturn( $content );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( $dummyRev->getId() );
+               $rev->setPageId( $title->getArticleID() );
+               $rev->setUser( $dummyRev->getUser() );
+               $rev->setComment( $dummyRev->getComment() );
+               $rev->setTimestamp( $dummyRev->getTimestamp() );
+
+               $rev->setContent( 'main', $content );
+
+               $rev = new Revision( $rev );
+
+               /** @var MockObject|WikiPage $page */
+               $page = $this->getMockBuilder( WikiPage::class )
+                       ->setMethods( [ 'getRevision', 'getLatest' ] )
+                       ->setConstructorArgs( [ $title ] )
+                       ->getMock();
+
+               $page->method( 'getRevision' )
+                       ->willReturn( $rev );
+               $page->method( 'getLatest' )
+                       ->willReturn( $rev->getId() );
+
+               $article = Article::newFromWikiPage( $page, RequestContext::getMain() );
+               $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+               $article->view();
+
+               $output = $article->getContext()->getOutput();
+               $this->assertContains( 'Structured Output', $this->getHtml( $output ) );
+               $this->assertNotContains( 'Dummy', $this->getHtml( $output ) );
+       }
+
+       public function testViewOfOldRevision() {
+               $revisions = [];
+               $page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );
+               $idA = $revisions[1]->getId();
+
+               $article = new Article( $page->getTitle(), $idA );
+               $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+               $article->view();
+
+               $output = $article->getContext()->getOutput();
+               $this->assertContains( 'Test A', $this->getHtml( $output ) );
+               $this->assertContains( 'id="mw-revision-info"', $output->getSubtitle() );
+               $this->assertContains( 'id="mw-revision-nav"', $output->getSubtitle() );
+
+               $this->assertNotContains( 'id="revision-info-current"', $output->getSubtitle() );
+               $this->assertNotContains( 'Test B', $this->getHtml( $output ) );
+       }
+
+       public function testViewOfCurrentRevision() {
+               $revisions = [];
+               $page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );
+               $idB = $revisions[2]->getId();
+
+               $article = new Article( $page->getTitle(), $idB );
+               $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+               $article->view();
+
+               $output = $article->getContext()->getOutput();
+               $this->assertContains( 'Test B', $this->getHtml( $output ) );
+               $this->assertContains( 'id="mw-revision-info-current"', $output->getSubtitle() );
+               $this->assertContains( 'id="mw-revision-nav"', $output->getSubtitle() );
+       }
+
+       public function testViewOfMissingRevision() {
+               $revisions = [];
+               $page = $this->getPage( __METHOD__, [ 1 => 'Test A' ], $revisions );
+               $badId = $revisions[1]->getId() + 100;
+
+               $article = new Article( $page->getTitle(), $badId );
+               $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+               $article->view();
+
+               $output = $article->getContext()->getOutput();
+               $this->assertContains( 'missing-revision: ' . $badId, $this->getHtml( $output ) );
+
+               $this->assertNotContains( 'Test A', $this->getHtml( $output ) );
+       }
+
+       public function testViewOfDeletedRevision() {
+               $revisions = [];
+               $page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ], $revisions );
+               $idA = $revisions[1]->getId();
+
+               $revDelList = new RevDelRevisionList(
+                       RequestContext::getMain(), $page->getTitle(), [ $idA ]
+               );
+               $revDelList->setVisibility( [
+                       'value' => [ RevisionRecord::DELETED_TEXT => 1 ],
+                       'comment' => "Testing",
+               ] );
+
+               $article = new Article( $page->getTitle(), $idA );
+               $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+               $article->view();
+
+               $output = $article->getContext()->getOutput();
+               $this->assertContains( '(rev-deleted-text-permission)', $this->getHtml( $output ) );
+
+               $this->assertNotContains( 'Test A', $this->getHtml( $output ) );
+               $this->assertNotContains( 'Test B', $this->getHtml( $output ) );
+       }
+
+       public function testViewMissingPage() {
+               $page = $this->getPage( __METHOD__ );
+
+               $article = new Article( $page->getTitle() );
+               $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+               $article->view();
+
+               $output = $article->getContext()->getOutput();
+               $this->assertContains( '(noarticletextanon)', $this->getHtml( $output ) );
+       }
+
+       public function testViewDeletedPage() {
+               $page = $this->getPage( __METHOD__, [ 1 => 'Test A', 2 => 'Test B' ] );
+               $page->doDeleteArticle( 'Test' );
+
+               $article = new Article( $page->getTitle() );
+               $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+               $article->view();
+
+               $output = $article->getContext()->getOutput();
+               $this->assertContains( 'moveddeleted', $this->getHtml( $output ) );
+               $this->assertContains( 'logentry-delete-delete', $this->getHtml( $output ) );
+               $this->assertContains( '(noarticletextanon)', $this->getHtml( $output ) );
+
+               $this->assertNotContains( 'Test A', $this->getHtml( $output ) );
+               $this->assertNotContains( 'Test B', $this->getHtml( $output ) );
+       }
+
+       public function testViewMessagePage() {
+               $title = Title::makeTitle( NS_MEDIAWIKI, 'Mainpage' );
+               $page = $this->getPage( $title );
+
+               $article = new Article( $page->getTitle() );
+               $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+               $article->view();
+
+               $output = $article->getContext()->getOutput();
+               $this->assertContains(
+                       wfMessage( 'mainpage' )->inContentLanguage()->parse(),
+                       $this->getHtml( $output )
+               );
+               $this->assertNotContains( '(noarticletextanon)', $this->getHtml( $output ) );
+       }
+
+       public function testViewMissingUserPage() {
+               $user = $this->getTestUser()->getUser();
+               $user->addToDatabase();
+
+               $title = Title::makeTitle( NS_USER, $user->getName() );
+
+               $page = $this->getPage( $title );
+
+               $article = new Article( $page->getTitle() );
+               $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+               $article->view();
+
+               $output = $article->getContext()->getOutput();
+               $this->assertContains( '(noarticletextanon)', $this->getHtml( $output ) );
+               $this->assertNotContains( '(userpage-userdoesnotexist-view)', $this->getHtml( $output ) );
+       }
+
+       public function testViewUserPageOfNonexistingUser() {
+               $user = User::newFromName( 'Testing ' . __METHOD__ );
+
+               $title = Title::makeTitle( NS_USER, $user->getName() );
+
+               $page = $this->getPage( $title );
+
+               $article = new Article( $page->getTitle() );
+               $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+               $article->view();
+
+               $output = $article->getContext()->getOutput();
+               $this->assertContains( '(noarticletextanon)', $this->getHtml( $output ) );
+               $this->assertContains( '(userpage-userdoesnotexist-view:', $this->getHtml( $output ) );
+       }
+
+       public function testArticleViewHeaderHook() {
+               $page = $this->getPage( __METHOD__, [ 1 => 'Test A' ] );
+
+               $article = new Article( $page->getTitle(), 0 );
+               $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+
+               $this->setTemporaryHook(
+                       'ArticleViewHeader',
+                       function ( Article $articlePage, &$outputDone, &$useParserCache ) use ( $article ) {
+                               $this->assertSame( $article, $articlePage, '$articlePage' );
+
+                               $outputDone = new ParserOutput( 'Hook Text' );
+                               $outputDone->setTitleText( 'Hook Title' );
+
+                               $articlePage->getContext()->getOutput()->addParserOutput( $outputDone );
+                       }
+               );
+
+               $article->view();
+
+               $output = $article->getContext()->getOutput();
+               $this->assertNotContains( 'Test A', $this->getHtml( $output ) );
+               $this->assertContains( 'Hook Text', $this->getHtml( $output ) );
+               $this->assertSame( 'Hook Title', $output->getPageTitle() );
+       }
+
+       public function testArticleContentViewCustomHook() {
+               $page = $this->getPage( __METHOD__, [ 1 => 'Test A' ] );
+
+               $article = new Article( $page->getTitle(), 0 );
+               $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+
+               // use ArticleViewHeader hook to bypass the parser cache
+               $this->setTemporaryHook(
+                       'ArticleViewHeader',
+                       function ( Article $articlePage, &$outputDone, &$useParserCache ) use ( $article ) {
+                               $useParserCache = false;
+                       }
+               );
+
+               $this->setTemporaryHook(
+                       'ArticleContentViewCustom',
+                       function ( Content $content, Title $title, OutputPage $output ) use ( $page ) {
+                               $this->assertSame( $page->getTitle(), $title, '$title' );
+                               $this->assertSame( 'Test A', $content->getNativeData(), '$content' );
+
+                               $output->addHTML( 'Hook Text' );
+                               return false;
+                       }
+               );
+
+               $article->view();
+
+               $output = $article->getContext()->getOutput();
+               $this->assertNotContains( 'Test A', $this->getHtml( $output ) );
+               $this->assertContains( 'Hook Text', $this->getHtml( $output ) );
+       }
+
+       public function testArticleAfterFetchContentObjectHook() {
+               $page = $this->getPage( __METHOD__, [ 1 => 'Test A' ] );
+
+               $article = new Article( $page->getTitle(), 0 );
+               $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+
+               // use ArticleViewHeader hook to bypass the parser cache
+               $this->setTemporaryHook(
+                       'ArticleViewHeader',
+                       function ( Article $articlePage, &$outputDone, &$useParserCache ) use ( $article ) {
+                               $useParserCache = false;
+                       }
+               );
+
+               $this->setTemporaryHook(
+                       'ArticleAfterFetchContentObject',
+                       function ( Article &$articlePage, Content &$content ) use ( $page, $article ) {
+                               $this->assertSame( $article, $articlePage, '$articlePage' );
+                               $this->assertSame( 'Test A', $content->getNativeData(), '$content' );
+
+                               $content = new WikitextContent( 'Hook Text' );
+                       }
+               );
+
+               $article->view();
+
+               $output = $article->getContext()->getOutput();
+               $this->assertNotContains( 'Test A', $this->getHtml( $output ) );
+               $this->assertContains( 'Hook Text', $this->getHtml( $output ) );
+       }
+
+       public function testShowMissingArticleHook() {
+               $page = $this->getPage( __METHOD__ );
+
+               $article = new Article( $page->getTitle() );
+               $article->getContext()->getOutput()->setTitle( $page->getTitle() );
+
+               $this->setTemporaryHook(
+                       'ShowMissingArticle',
+                       function ( Article $articlePage ) use ( $article ) {
+                               $this->assertSame( $article, $articlePage, '$articlePage' );
+
+                               $articlePage->getContext()->getOutput()->addHTML( 'Hook Text' );
+                       }
+               );
+
+               $article->view();
+
+               $output = $article->getContext()->getOutput();
+               $this->assertContains( '(noarticletextanon)', $this->getHtml( $output ) );
+               $this->assertContains( 'Hook Text', $this->getHtml( $output ) );
+       }
+
+}
index d2ed441..497c6b5 100644 (file)
@@ -180,6 +180,18 @@ class ParserMethodsTest extends MediaWikiLangTestCase {
                ];
        }
 
+       public function testWrapOutput() {
+               global $wgParser;
+               $title = Title::newFromText( 'foo' );
+               $po = new ParserOptions();
+               $wgParser->parse( 'Hello World', $title, $po );
+               $text = $wgParser->getOutput()->getText();
+
+               $this->assertContains( 'Hello World', $text );
+               $this->assertContains( '<div', $text );
+               $this->assertContains( 'class="mw-parser-output"', $text );
+       }
+
        // @todo Add tests for cleanSig() / cleanSigInSig(), getSection(),
        // replaceSection(), getPreloadText()
 }
index b08ba6c..439b24d 100644 (file)
@@ -89,6 +89,59 @@ class ParserOutputTest extends MediaWikiTestCase {
                $this->assertArrayNotHasKey( 'foo', $properties );
        }
 
+       /**
+        * @covers ParserOutput::getWrapperDivClass
+        * @covers ParserOutput::addWrapperDivClass
+        * @covers ParserOutput::clearWrapperDivClass
+        * @covers ParserOutput::getText
+        */
+       public function testWrapperDivClass() {
+               $po = new ParserOutput();
+
+               $po->setText( 'Kittens' );
+               $this->assertContains( 'Kittens', $po->getText() );
+               $this->assertNotContains( '<div', $po->getText() );
+               $this->assertSame( 'Kittens', $po->getRawText() );
+
+               $po->addWrapperDivClass( 'foo' );
+               $text = $po->getText();
+               $this->assertContains( 'Kittens', $text );
+               $this->assertContains( '<div', $text );
+               $this->assertContains( 'class="foo"', $text );
+
+               $po->addWrapperDivClass( 'bar' );
+               $text = $po->getText();
+               $this->assertContains( 'Kittens', $text );
+               $this->assertContains( '<div', $text );
+               $this->assertContains( 'class="foo bar"', $text );
+
+               $po->addWrapperDivClass( 'bar' ); // second time does nothing, no "foo bar bar".
+               $text = $po->getText( [ 'unwrap' => true ] );
+               $this->assertContains( 'Kittens', $text );
+               $this->assertNotContains( '<div', $text );
+               $this->assertNotContains( 'class="foo bar"', $text );
+
+               $text = $po->getText( [ 'wrapperDivClass' => '' ] );
+               $this->assertContains( 'Kittens', $text );
+               $this->assertNotContains( '<div', $text );
+               $this->assertNotContains( 'class="foo bar"', $text );
+
+               $text = $po->getText( [ 'wrapperDivClass' => 'xyzzy' ] );
+               $this->assertContains( 'Kittens', $text );
+               $this->assertContains( '<div', $text );
+               $this->assertContains( 'class="xyzzy"', $text );
+               $this->assertNotContains( 'class="foo bar"', $text );
+
+               $text = $po->getRawText();
+               $this->assertSame( 'Kittens', $text );
+
+               $po->clearWrapperDivClass();
+               $text = $po->getText();
+               $this->assertContains( 'Kittens', $text );
+               $this->assertNotContains( '<div', $text );
+               $this->assertNotContains( 'class="foo bar"', $text );
+       }
+
        /**
         * @covers ParserOutput::getText
         * @dataProvider provideGetText
@@ -111,7 +164,7 @@ class ParserOutputTest extends MediaWikiTestCase {
        public static function provideGetText() {
                // phpcs:disable Generic.Files.LineLength
                $text = <<<EOF
-<div class="mw-parser-output"><p>Test document.
+<p>Test document.
 </p>
 <mw:toc><div id="toc" class="toc"><div class="toctitle"><h2>Contents</h2></div>
 <ul>
@@ -136,7 +189,7 @@ class ParserOutputTest extends MediaWikiTestCase {
 </p>
 <h2><span class="mw-headline" id="Section_3">Section 3</span><mw:editsection page="Test Page" section="4">Section 3</mw:editsection></h2>
 <p>Three
-</p></div>
+</p>
 EOF;
 
                $dedupText = <<<EOF
@@ -155,7 +208,7 @@ EOF;
                return [
                        'No options' => [
                                [], $text, <<<EOF
-<div class="mw-parser-output"><p>Test document.
+<p>Test document.
 </p>
 <div id="toc" class="toc"><div class="toctitle"><h2>Contents</h2></div>
 <ul>
@@ -180,12 +233,12 @@ EOF;
 </p>
 <h2><span class="mw-headline" id="Section_3">Section 3</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=4" title="Edit section: Section 3">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
 <p>Three
-</p></div>
+</p>
 EOF
                        ],
                        'Disable section edit links' => [
                                [ 'enableSectionEditLinks' => false ], $text, <<<EOF
-<div class="mw-parser-output"><p>Test document.
+<p>Test document.
 </p>
 <div id="toc" class="toc"><div class="toctitle"><h2>Contents</h2></div>
 <ul>
@@ -210,11 +263,11 @@ EOF
 </p>
 <h2><span class="mw-headline" id="Section_3">Section 3</span></h2>
 <p>Three
-</p></div>
+</p>
 EOF
                        ],
-                       'Disable TOC' => [
-                               [ 'allowTOC' => false ], $text, <<<EOF
+                       'Disable TOC, but wrap' => [
+                               [ 'allowTOC' => false, 'wrapperDivClass' => 'mw-parser-output' ], $text, <<<EOF
 <div class="mw-parser-output"><p>Test document.
 </p>
 
@@ -231,44 +284,6 @@ EOF
 <p>Three
 </p></div>
 EOF
-                       ],
-                       'Unwrap text' => [
-                               [ 'unwrap' => true ], $text, <<<EOF
-<p>Test document.
-</p>
-<div id="toc" class="toc"><div class="toctitle"><h2>Contents</h2></div>
-<ul>
-<li class="toclevel-1 tocsection-1"><a href="#Section_1"><span class="tocnumber">1</span> <span class="toctext">Section 1</span></a></li>
-<li class="toclevel-1 tocsection-2"><a href="#Section_2"><span class="tocnumber">2</span> <span class="toctext">Section 2</span></a>
-<ul>
-<li class="toclevel-2 tocsection-3"><a href="#Section_2.1"><span class="tocnumber">2.1</span> <span class="toctext">Section 2.1</span></a></li>
-</ul>
-</li>
-<li class="toclevel-1 tocsection-4"><a href="#Section_3"><span class="tocnumber">3</span> <span class="toctext">Section 3</span></a></li>
-</ul>
-</div>
-
-<h2><span class="mw-headline" id="Section_1">Section 1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=1" title="Edit section: Section 1">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
-<p>One
-</p>
-<h2><span class="mw-headline" id="Section_2">Section 2</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=2" title="Edit section: Section 2">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
-<p>Two
-</p>
-<h3><span class="mw-headline" id="Section_2.1">Section 2.1</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=3" title="Edit section: Section 2.1">edit</a><span class="mw-editsection-bracket">]</span></span></h3>
-<p>Two point one
-</p>
-<h2><span class="mw-headline" id="Section_3">Section 3</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/w/index.php?title=Test_Page&amp;action=edit&amp;section=4" title="Edit section: Section 3">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
-<p>Three
-</p>
-EOF
-                       ],
-                       'Unwrap without a mw-parser-output wrapper' => [
-                               [ 'unwrap' => true ], '<div class="foobar">Content</div>', '<div class="foobar">Content</div>'
-                       ],
-                       'Unwrap with extra comment at end' => [
-                               [ 'unwrap' => true ], '<div class="mw-parser-output"><p>Test document.</p></div>
-<!-- Saved in parser cache... -->', '<p>Test document.</p>
-<!-- Saved in parser cache... -->'
                        ],
                        'Style deduplication' => [
                                [], $dedupText, <<<EOF
index 61ab8a5..231979d 100644 (file)
@@ -1,9 +1,11 @@
 <?php
 
+use Wikimedia\TestingAccessWrapper;
+
 /**
  * @group ResourceLoader
  */
-class ResourceLoaderSkinModuleTest extends PHPUnit\Framework\TestCase {
+class ResourceLoaderSkinModuleTest extends MediaWikiTestCase {
 
        use MediaWikiCoversValidator;
 
@@ -107,25 +109,23 @@ CSS
        }
 
        /**
-        * @dataProvider provideGetLogo
-        * @covers ResourceLoaderSkinModule::getLogo
+        * @dataProvider provideGetLogoData
+        * @covers ResourceLoaderSkinModule::getLogoData
         */
-       public function testGetLogo( $config, $expected, $baseDir = null ) {
+       public function testGetLogoData( $config, $expected, $baseDir = null ) {
                if ( $baseDir ) {
-                       $oldIP = $GLOBALS['IP'];
-                       $GLOBALS['IP'] = $baseDir;
-                       $teardown = new Wikimedia\ScopedCallback( function () use ( $oldIP ) {
-                               $GLOBALS['IP'] = $oldIP;
-                       } );
+                       $this->setMwGlobals( 'IP', $baseDir );
                }
+               // Allow testing of protected method
+               $module = TestingAccessWrapper::newFromObject( new ResourceLoaderSkinModule() );
 
                $this->assertEquals(
                        $expected,
-                       ResourceLoaderSkinModule::getLogo( new HashConfig( $config ) )
+                       $module->getLogoData( new HashConfig( $config ) )
                );
        }
 
-       public function provideGetLogo() {
+       public function provideGetLogoData() {
                return [
                        'simple' => [
                                'config' => [
@@ -203,4 +203,80 @@ CSS
                        ],
                ];
        }
+
+       /**
+        * @dataProvider providePreloadLinks
+        * @covers ResourceLoaderSkinModule::getPreloadLinks
+        * @covers ResourceLoaderSkinModule::getLogoPreloadlinks
+        * @covers ResourceLoaderSkinModule::getLogoData
+        */
+       public function testPreloadLinkHeaders( $config, $result ) {
+               $this->setMwGlobals( $config );
+               $ctx = $this->getMockBuilder( ResourceLoaderContext::class )
+                       ->disableOriginalConstructor()->getMock();
+               $module = new ResourceLoaderSkinModule();
+
+               $this->assertEquals( [ $result ], $module->getHeaders( $ctx ) );
+       }
+
+       public function providePreloadLinks() {
+               return [
+                       [
+                               [
+                                       'wgResourceBasePath' => '/w',
+                                       'wgLogo' => '/img/default.png',
+                                       'wgLogoHD' => [
+                                               '1.5x' => '/img/one-point-five.png',
+                                               '2x' => '/img/two-x.png',
+                                       ],
+                               ],
+                               'Link: </img/default.png>;rel=preload;as=image;media=' .
+                               'not all and (min-resolution: 1.5dppx),' .
+                               '</img/one-point-five.png>;rel=preload;as=image;media=' .
+                               '(min-resolution: 1.5dppx) and (max-resolution: 1.999999dppx),' .
+                               '</img/two-x.png>;rel=preload;as=image;media=(min-resolution: 2dppx)'
+                       ],
+                       [
+                               [
+                                       'wgResourceBasePath' => '/w',
+                                       'wgLogo' => '/img/default.png',
+                                       'wgLogoHD' => false,
+                               ],
+                               'Link: </img/default.png>;rel=preload;as=image'
+                       ],
+                       [
+                               [
+                                       'wgResourceBasePath' => '/w',
+                                       'wgLogo' => '/img/default.png',
+                                       'wgLogoHD' => [
+                                               '2x' => '/img/two-x.png',
+                                       ],
+                               ],
+                               'Link: </img/default.png>;rel=preload;as=image;media=' .
+                               'not all and (min-resolution: 2dppx),' .
+                               '</img/two-x.png>;rel=preload;as=image;media=(min-resolution: 2dppx)'
+                       ],
+                       [
+                               [
+                                       'wgResourceBasePath' => '/w',
+                                       'wgLogo' => '/img/default.png',
+                                       'wgLogoHD' => [
+                                               'svg' => '/img/vector.svg',
+                                       ],
+                               ],
+                               'Link: </img/vector.svg>;rel=preload;as=image'
+
+                       ],
+                       [
+                               [
+                                       'wgResourceBasePath' => '/w',
+                                       'wgLogo' => '/w/test.jpg',
+                                       'wgLogoHD' => false,
+                                       'wgUploadPath' => '/w/images',
+                                       'IP' => dirname( dirname( __DIR__ ) ) . '/data/media',
+                               ],
+                               'Link: </w/test.jpg?edcf2>;rel=preload;as=image',
+                       ],
+               ];
+       }
 }