Merge "Title: Fix isRawHtmlMessage() for messages with underscores"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Fri, 31 Aug 2018 17:39:51 +0000 (17:39 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Fri, 31 Aug 2018 17:39:51 +0000 (17:39 +0000)
106 files changed:
.gitignore
.phpcs.xml
RELEASE-NOTES-1.32
autoload.php
composer.json
includes/AjaxDispatcher.php
includes/DefaultSettings.php
includes/EditPage.php
includes/Html.php
includes/Linker.php
includes/MediaWikiServices.php
includes/OutputPage.php
includes/Revision/RenderedRevision.php [new file with mode: 0644]
includes/Revision/RevisionRenderer.php [new file with mode: 0644]
includes/ServiceWiring.php
includes/Setup.php
includes/Storage/DerivedPageDataUpdater.php
includes/Storage/MutableRevisionRecord.php
includes/Storage/PageUpdater.php
includes/api/ApiParse.php
includes/api/i18n/zh-hans.json
includes/api/i18n/zh-hant.json
includes/changetags/ChangeTags.php
includes/content/AbstractContent.php
includes/content/TextContent.php
includes/deferred/LinksDeletionUpdate.php
includes/deferred/LinksUpdate.php
includes/htmlform/HTMLForm.php
includes/htmlform/HTMLFormField.php
includes/jobqueue/jobs/DeleteLinksJob.php
includes/jobqueue/jobs/RefreshLinksJob.php
includes/json/FormatJson.php
includes/page/Article.php
includes/page/WikiPage.php
includes/parser/Parser.php
includes/parser/ParserCache.php
includes/parser/ParserOptions.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/Language.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/cy.json
languages/i18n/da.json
languages/i18n/de.json
languages/i18n/fa.json
languages/i18n/fi.json
languages/i18n/fy.json
languages/i18n/gcr.json
languages/i18n/it.json
languages/i18n/lij.json
languages/i18n/mk.json
languages/i18n/ml.json
languages/i18n/mni.json
languages/i18n/ms.json
languages/i18n/my.json
languages/i18n/nb.json
languages/i18n/nl.json
languages/i18n/nn.json
languages/i18n/pt.json
languages/i18n/qqq.json
languages/i18n/ru.json
languages/i18n/sd.json
languages/i18n/sq.json
languages/i18n/sr-ec.json
languages/i18n/tr.json
languages/i18n/tt-cyrl.json
languages/i18n/ur.json
languages/i18n/vec.json
languages/i18n/zh-hans.json
languages/i18n/zh-hant.json
maintenance/Doxyfile
maintenance/generateSitemap.php
maintenance/resources/foreign-resources.yaml
maintenance/resources/manageForeignResources.php
package.json
resources/Resources.php
resources/src/mediawiki.notification/notification.js
resources/src/startup/mediawiki.js
resources/src/startup/startup.js
tests/phpunit/includes/GitInfoTest.php
tests/phpunit/includes/HtmlTest.php
tests/phpunit/includes/OutputPageTest.php
tests/phpunit/includes/Revision/RenderedRevisionTest.php [new file with mode: 0644]
tests/phpunit/includes/Revision/RevisionRendererTest.php [new file with mode: 0644]
tests/phpunit/includes/Storage/DerivedPageDataUpdaterTest.php
tests/phpunit/includes/Storage/MutableRevisionRecordTest.php
tests/phpunit/includes/Storage/PageUpdaterTest.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/ParserOptionsTest.php
tests/phpunit/includes/parser/ParserOutputTest.php
tests/phpunit/includes/resourceloader/ResourceLoaderModuleTest.php
tests/phpunit/includes/resourceloader/ResourceLoaderSkinModuleTest.php
tests/phpunit/maintenance/DumpTestCase.php
tests/phpunit/maintenance/backupTextPassTest.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..ab008db 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,11 @@ 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.
+* The jquery.localize module is now deprecated. Use jquery.i18n instead.
 
 === Other changes in 1.32 ===
 * (T198811) The following tables have had their UNIQUE indexes turned into
index 10aab64..cc2b428 100644 (file)
@@ -931,6 +931,8 @@ $wgAutoloadLocalClasses = [
        'MediaWiki\\MediaWikiServices' => __DIR__ . '/includes/MediaWikiServices.php',
        'MediaWiki\\OutputHandler' => __DIR__ . '/includes/OutputHandler.php',
        'MediaWiki\\ProcOpenError' => __DIR__ . '/includes/exception/ProcOpenError.php',
+       'MediaWiki\\Revision\\RenderedRevision' => __DIR__ . '/includes/Revision/RenderedRevision.php',
+       'MediaWiki\\Revision\\RevisionRenderer' => __DIR__ . '/includes/Revision/RevisionRenderer.php',
        'MediaWiki\\Search\\ParserOutputSearchDataExtractor' => __DIR__ . '/includes/search/ParserOutputSearchDataExtractor.php',
        'MediaWiki\\ShellDisabledError' => __DIR__ . '/includes/exception/ShellDisabledError.php',
        'MediaWiki\\Site\\MediaWikiPageNameNormalizer' => __DIR__ . '/includes/site/MediaWikiPageNameNormalizer.php',
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 5f825c8..f6c9075 100644 (file)
@@ -104,6 +104,9 @@ class AjaxDispatcher {
         * they should be carefully handled in the function processing the
         * request.
         *
+        * phan-taint-check triggers as it is not smart enough to understand
+        * the early return if func_name not in AjaxExportList.
+        * @suppress SecurityCheck-XSS
         * @param User $user
         */
        function performAction( User $user ) {
index 2d1b331..928d875 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 e087a6e..6b4dcc2 100644 (file)
@@ -1058,7 +1058,7 @@ class EditPage {
                                $this->sectiontitle = $request->getVal( 'preloadtitle' );
                                // Once wpSummary isn't being use for setting section titles, we should delete this.
                                $this->summary = $request->getVal( 'preloadtitle' );
-                       } elseif ( $this->section != 'new' && $request->getVal( 'summary' ) ) {
+                       } elseif ( $this->section != 'new' && $request->getVal( 'summary' ) !== '' ) {
                                $this->summary = $request->getText( 'summary' );
                                if ( $this->summary !== '' ) {
                                        $this->hasPresetSummary = true;
@@ -1782,7 +1782,7 @@ ERROR;
                        if ( $this->summary === '' ) {
                                $cleanSectionTitle = $wgParser->stripSectionName( $this->sectiontitle );
                                return $this->context->msg( 'newsectionsummary' )
-                                       ->rawParams( $cleanSectionTitle )->inContentLanguage()->text();
+                                       ->plaintextParams( $cleanSectionTitle )->inContentLanguage()->text();
                        }
                } elseif ( $this->summary !== '' ) {
                        $sectionanchor = $this->guessSectionName( $this->summary );
@@ -1790,7 +1790,7 @@ ERROR;
                        # in the revision summary.
                        $cleanSummary = $wgParser->stripSectionName( $this->summary );
                        return $this->context->msg( 'newsectionsummary' )
-                               ->rawParams( $cleanSummary )->inContentLanguage()->text();
+                               ->plaintextParams( $cleanSummary )->inContentLanguage()->text();
                }
                return $this->summary;
        }
@@ -2869,7 +2869,7 @@ ERROR;
                        $this->autoSumm = md5( '' );
                }
 
-               $autosumm = $this->autoSumm ?: md5( $this->summary );
+               $autosumm = $this->autoSumm !== '' ? $this->autoSumm : md5( $this->summary );
                $out->addHTML( Html::hidden( 'wpAutoSummary', $autosumm ) );
 
                $out->addHTML( Html::hidden( 'oldid', $this->oldid ) );
index dba4c67..aac492c 100644 (file)
@@ -552,10 +552,13 @@ class Html {
        }
 
        /**
-        * Output a "<script>" tag with the given contents.
+        * Output an HTML script tag with the given contents.
         *
-        * @todo do some useful escaping as well, like if $contents contains
-        * literal "</script>" or (for XML) literal "]]>".
+        * It is unsupported for the contents to contain the sequence `<script` or `</script`
+        * (case-insensitive). This ensures the script can be terminated easily and consistently.
+        * It is the responsibility of the caller to avoid such character sequence by escaping
+        * or avoiding it. If found at run-time, the contents are replaced with a comment, and
+        * a warning is logged server-side.
         *
         * @param string $contents JavaScript
         * @param string|null $nonce Nonce for CSP header, from OutputPage::getCSPNonce()
@@ -571,8 +574,9 @@ class Html {
                        }
                }
 
-               if ( preg_match( '/[<&]/', $contents ) ) {
-                       $contents = "/*<![CDATA[*/$contents/*]]>*/";
+               if ( preg_match( '/<\/?script/i', $contents ) ) {
+                       wfLogWarning( __METHOD__ . ': Illegal character sequence found in inline script.' );
+                       $contents = '/* ERROR: Invalid script */';
                }
 
                return self::rawElement( 'script', $attrs, $contents );
index 7e56522..0aa8ec5 100644 (file)
@@ -1212,7 +1212,8 @@ class Linker {
         * @param string|null $wikiId Id of the wiki to link to (if not the local wiki),
         *  as used by WikiMap.
         *
-        * @return string
+        * @return string HTML
+        * @return-taint escapes_html
         */
        public static function formatLinksInComment(
                $comment, $title = null, $local = false, $wikiId = null
index 4a6046d..5b53ad1 100644 (file)
@@ -16,6 +16,7 @@ use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
 use MediaWiki\Http\HttpRequestFactory;
 use MediaWiki\Preferences\PreferencesFactory;
 use MediaWiki\Shell\CommandFactory;
+use MediaWiki\Revision\RevisionRenderer;
 use MediaWiki\Special\SpecialPageFactory;
 use MediaWiki\Storage\BlobStore;
 use MediaWiki\Storage\BlobStoreFactory;
@@ -749,6 +750,14 @@ class MediaWikiServices extends ServiceContainer {
                return $this->getService( 'RevisionLookup' );
        }
 
+       /**
+        * @since 1.32
+        * @return RevisionRenderer
+        */
+       public function getRevisionRenderer() {
+               return $this->getService( 'RevisionRenderer' );
+       }
+
        /**
         * @since 1.31
         * @return RevisionStore
index c8e18e0..4f12e0c 100644 (file)
@@ -2364,10 +2364,6 @@ class OutputPage extends ContextSource {
 
                if ( !$this->mArticleBodyOnly ) {
                        $sk = $this->getSkin();
-
-                       if ( $sk->shouldPreloadLogo() ) {
-                               $this->addLogoPreloadLinkHeaders();
-                       }
                }
 
                $linkHeader = $this->getLinkHeader();
@@ -2758,6 +2754,18 @@ class OutputPage extends ContextSource {
                                        foreach ( $this->contentOverrideCallbacks as $callback ) {
                                                $content = $callback( $title );
                                                if ( $content !== null ) {
+                                                       $text = ContentHandler::getContentText( $content );
+                                                       if ( strpos( $text, '</script>' ) !== false ) {
+                                                               // Proactively replace this so that we can display a message
+                                                               // to the user, instead of letting it go to Html::inlineScript(),
+                                                               // where it would be considered a server-side issue.
+                                                               $titleFormatted = $title->getPrefixedText();
+                                                               $content = new JavaScriptContent(
+                                                                       Xml::encodeJsCall( 'mw.log.error', [
+                                                                               "Cannot preview $titleFormatted due to script-closing tag."
+                                                                       ] )
+                                                               );
+                                                       }
                                                        return $content;
                                                }
                                        }
@@ -3915,80 +3923,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.
         *
diff --git a/includes/Revision/RenderedRevision.php b/includes/Revision/RenderedRevision.php
new file mode 100644 (file)
index 0000000..0c052d1
--- /dev/null
@@ -0,0 +1,345 @@
+<?php
+/**
+ * This file is part of MediaWiki.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Revision;
+
+use InvalidArgumentException;
+use LogicException;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\SuppressedDataException;
+use ParserOptions;
+use ParserOutput;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Revision;
+use Title;
+use User;
+use Wikimedia\Assert\Assert;
+
+/**
+ * RenderedRevision represents the rendered representation of a revision. It acts as a lazy provider
+ * of ParserOutput objects for the revision's individual slots, as well as a combined ParserOutput
+ * of all slots.
+ *
+ * @since 1.32
+ */
+class RenderedRevision {
+
+       /**
+        * @var Title
+        */
+       private $title;
+
+       /** @var RevisionRecord */
+       private $revision;
+
+       /**
+        * @var ParserOptions
+        */
+       private $options;
+
+       /**
+        * @var int Audience to check when accessing content.
+        */
+       private $audience = RevisionRecord::FOR_PUBLIC;
+
+       /**
+        * @var User|null The user to use for audience checks during content access.
+        */
+       private $forUser = null;
+
+       /**
+        * @var ParserOutput|null The combined ParserOutput for the revision,
+        *      initialized lazily by getRevisionParserOutput().
+        */
+       private $revisionOutput = null;
+
+       /**
+        * @var ParserOutput[] The ParserOutput for each slot,
+        *      initialized lazily by getSlotParserOutput().
+        */
+       private $slotsOutput = [];
+
+       /**
+        * @var callable Callback for combining slot output into revision output.
+        *      Signature: function ( RenderedRevision $this ): ParserOutput.
+        */
+       private $combineOutput;
+
+       /**
+        * @var LoggerInterface For profiling ParserOutput re-use.
+        */
+       private $saveParseLogger;
+
+       /**
+        * @note Application logic should not instantiate RenderedRevision instances directly,
+        * but should use a RevisionRenderer instead.
+        *
+        * @param Title $title
+        * @param RevisionRecord $revision
+        * @param ParserOptions $options
+        * @param callable $combineOutput Callback for combining slot output into revision output.
+        *        Signature: function ( RenderedRevision $this ): ParserOutput.
+        * @param int $audience Use RevisionRecord::FOR_PUBLIC, FOR_THIS_USER, or RAW.
+        * @param User|null $forUser Required if $audience is FOR_THIS_USER.
+        */
+       public function __construct(
+               Title $title,
+               RevisionRecord $revision,
+               ParserOptions $options,
+               callable $combineOutput,
+               $audience = RevisionRecord::FOR_PUBLIC,
+               User $forUser = null
+       ) {
+               $this->title = $title;
+               $this->options = $options;
+
+               $this->setRevisionInternal( $revision );
+
+               $this->combineOutput = $combineOutput;
+               $this->saveParseLogger = new NullLogger();
+
+               if ( $audience === RevisionRecord::FOR_THIS_USER && !$forUser ) {
+                       throw new InvalidArgumentException(
+                               'User must be specified when setting audience to FOR_THIS_USER'
+                       );
+               }
+
+               $this->audience = $audience;
+               $this->forUser = $forUser;
+       }
+
+       /**
+        * @param LoggerInterface $saveParseLogger
+        */
+       public function setSaveParseLogger( LoggerInterface $saveParseLogger ) {
+               $this->saveParseLogger = $saveParseLogger;
+       }
+
+       /**
+        * @return bool Whether the revision's content has been hidden from unprivileged users.
+        */
+       public function isContentDeleted() {
+               return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT );
+       }
+
+       /**
+        * @return RevisionRecord
+        */
+       public function getRevision() {
+               return $this->revision;
+       }
+
+       /**
+        * @return ParserOptions
+        */
+       public function getOptions() {
+               return $this->options;
+       }
+
+       /**
+        * @param array $hints Hints given as an associative array. Known keys:
+        *      - 'generate-html' => bool: Whether the caller is interested in output HTML (as opposed
+        *        to just meta-data). Default is to generate HTML.
+        *
+        * @return ParserOutput
+        */
+       public function getRevisionParserOutput( array $hints = [] ) {
+               $withHtml = $hints['generate-html'] ?? true;
+
+               if ( !$this->revisionOutput
+                       || ( $withHtml && !$this->revisionOutput->hasText() )
+               ) {
+                       $output = call_user_func( $this->combineOutput, $this, $hints );
+
+                       Assert::postcondition(
+                               $output instanceof ParserOutput,
+                               'Callback did not return a ParserOutput object!'
+                       );
+
+                       $this->revisionOutput = $output;
+               }
+
+               return $this->revisionOutput;
+       }
+
+       /**
+        * @param string $role
+        * @param array $hints Hints given as an associative array. Known keys:
+        *      - 'generate-html' => bool: Whether the caller is interested in output HTML (as opposed
+        *        to just meta-data). Default is to generate HTML.
+        *
+        * @throws SuppressedDataException if the content is not accessible for the audience
+        *         specified in the constructor.
+        * @return ParserOutput
+        */
+       public function getSlotParserOutput( $role, array $hints = [] ) {
+               $withHtml = $hints['generate-html'] ?? true;
+
+               if ( !isset( $this->slotsOutput[ $role ] )
+                       || ( $withHtml && !$this->slotsOutput[ $role ]->hasText() )
+               ) {
+                       $content = $this->revision->getContent( $role, $this->audience, $this->forUser );
+
+                       if ( !$content ) {
+                               throw new SuppressedDataException(
+                                       'Access to the content has been suppressed for this audience'
+                               );
+                       } else {
+                               $output = $content->getParserOutput(
+                                       $this->title,
+                                       $this->revision->getId(),
+                                       $this->options,
+                                       $withHtml
+                               );
+
+                               if ( $withHtml && !$output->hasText() ) {
+                                       throw new LogicException(
+                                               'HTML generation was requested, but '
+                                               . get_class( $content )
+                                               . '::getParserOutput() returns a ParserOutput with no text set.'
+                                       );
+                               }
+
+                               // Detach watcher, to ensure option use is not recorded in the wrong ParserOutput.
+                               $this->options->registerWatcher( null );
+                       }
+
+                       $this->slotsOutput[ $role ] = $output;
+               }
+
+               return $this->slotsOutput[$role];
+       }
+
+       /**
+        * Updates the RevisionRecord after the revision has been saved. This can be used to discard
+        * and cached ParserOutput so parser functions like {{REVISIONTIMESTAMP}} or {{REVISIONID}}
+        * are re-evaluated.
+        *
+        * @note There should be no need to call this for null-edits.
+        *
+        * @param RevisionRecord $rev
+        */
+       public function updateRevision( RevisionRecord $rev ) {
+               if ( $rev->getId() === $this->revision->getId() ) {
+                       return;
+               }
+
+               if ( $this->revision->getId() ) {
+                       throw new LogicException( 'RenderedRevision already has a revision with ID '
+                               . $this->revision->getId(), ', can\'t update to revision with ID ' . $rev->getId() );
+               }
+
+               if ( !$this->revision->getSlots()->hasSameContent( $rev->getSlots() ) ) {
+                       throw new LogicException( 'Cannot update to a revision with different content!' );
+               }
+
+               $this->setRevisionInternal( $rev );
+
+               $this->pruneRevisionSensitiveOutput( $this->revision->getId() );
+       }
+
+       /**
+        * Prune any output that depends on the revision ID.
+        *
+        * @param int|bool  $actualRevId The actual rev id, to check the used speculative rev ID
+        *        against, or false to not purge on vary-revision-id, or true to purge on
+        *        vary-revision-id unconditionally.
+        */
+       private function pruneRevisionSensitiveOutput( $actualRevId ) {
+               if ( $this->revisionOutput ) {
+                       if ( $this->outputVariesOnRevisionMetaData( $this->revisionOutput, $actualRevId ) ) {
+                               $this->revisionOutput = null;
+                       }
+               } else {
+                       $this->saveParseLogger->debug( __METHOD__ . ": no prepared revision output...\n" );
+               }
+
+               foreach ( $this->slotsOutput as $role => $output ) {
+                       if ( $this->outputVariesOnRevisionMetaData( $output, $actualRevId ) ) {
+                               unset( $this->slotsOutput[$role] );
+                       }
+               }
+       }
+
+       /**
+        * @param RevisionRecord $revision
+        */
+       private function setRevisionInternal( RevisionRecord $revision ) {
+               $this->revision = $revision;
+
+               // Make sure the parser uses the correct Revision object
+               $title = $this->title;
+               $oldCallback = $this->options->getCurrentRevisionCallback();
+               $this->options->setCurrentRevisionCallback(
+                       function ( Title $parserTitle, $parser = false ) use ( $title, $oldCallback ) {
+                               if ( $parserTitle->equals( $title ) ) {
+                                       $legacyRevision = new Revision( $this->revision );
+                                       return $legacyRevision;
+                               } else {
+                                       return call_user_func( $oldCallback, $parserTitle, $parser );
+                               }
+                       }
+               );
+       }
+
+       /**
+        * @param ParserOutput $out
+        * @param int|bool  $actualRevId The actual rev id, to check the used speculative rev ID
+        *        against, or false to not purge on vary-revision-id, or true to purge on
+        *        vary-revision-id unconditionally.
+        * @return bool
+        */
+       private function outputVariesOnRevisionMetaData( ParserOutput $out, $actualRevId ) {
+               $method = __METHOD__;
+
+               if ( $out->getFlag( 'vary-revision' ) ) {
+                       // XXX: Would be just keep the output if the speculative revision ID was correct,
+                       // but that can go wrong for some edge cases, like {{PAGEID}} during page creation.
+                       // For that specific case, it would perhaps nice to have a vary-page flag.
+                       $this->saveParseLogger->info(
+                               "$method: Prepared output has vary-revision...\n"
+                       );
+                       return true;
+               } elseif ( $out->getFlag( 'vary-revision-id' )
+                       && $actualRevId !== false
+                       && ( $actualRevId === true || $out->getSpeculativeRevIdUsed() !== $actualRevId )
+               ) {
+                       $this->saveParseLogger->info(
+                               "$method: Prepared output has vary-revision-id with wrong ID...\n"
+                       );
+                       return true;
+               } else {
+                       // NOTE: In the original fix for T135261, the output was discarded if 'vary-user' was
+                       // set for a null-edit. The reason was that the original rendering in that case was
+                       // targeting the user making the null-edit, not the user who made the original edit,
+                       // causing {{REVISIONUSER}} to return the wrong name.
+                       // This case is now expected to be handled by the code in RevisionRenderer that
+                       // constructs the ParserOptions: For a null-edit, setCurrentRevisionCallback is called
+                       // with the old, existing revision.
+
+                       wfDebug( "$method: Keeping prepared output...\n" );
+                       return false;
+               }
+       }
+
+}
diff --git a/includes/Revision/RevisionRenderer.php b/includes/Revision/RevisionRenderer.php
new file mode 100644 (file)
index 0000000..f71f9e7
--- /dev/null
@@ -0,0 +1,218 @@
+<?php
+/**
+ * This file is part of MediaWiki.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Revision;
+
+use Html;
+use InvalidArgumentException;
+use MediaWiki\Storage\RevisionRecord;
+use ParserOptions;
+use ParserOutput;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Title;
+use User;
+use Wikimedia\Rdbms\ILoadBalancer;
+
+/**
+ * The RevisionRenderer service provides access to rendered output for revisions.
+ * It does so be acting as a factory for RenderedRevision instances, which in turn
+ * provide lazy access to ParserOutput objects.
+ *
+ * One key responsibility of RevisionRenderer is implementing the layout used to combine
+ * the output of multiple slots.
+ *
+ * @since 1.32
+ */
+class RevisionRenderer {
+
+       /** @var LoggerInterface */
+       private $saveParseLogger;
+
+       /** @var ILoadBalancer */
+       private $loadBalancer;
+
+       /** @var string|bool */
+       private $wikiId;
+
+       /**
+        * @param ILoadBalancer $loadBalancer
+        * @param bool|string $wikiId
+        */
+       public function __construct( ILoadBalancer $loadBalancer, $wikiId = false ) {
+               $this->loadBalancer = $loadBalancer;
+               $this->wikiId = $wikiId;
+
+               $this->saveParseLogger = new NullLogger();
+       }
+
+       /**
+        * @param RevisionRecord $rev
+        * @param ParserOptions|null $options
+        * @param User|null $forUser User for privileged access. Default is unprivileged (public)
+        *        access, unless the 'audience' hint is set to something else RevisionRecord::RAW.
+        * @param array $hints Hints given as an associative array. Known keys:
+        *      - 'use-master' Use master when rendering for the parser cache during save.
+        *        Default is to use a replica.
+        *      - 'audience' the audience to use for content access. Default is
+        *        RevisionRecord::FOR_PUBLIC if $forUser is not set, RevisionRecord::FOR_THIS_USER
+        *        if $forUser is set. Can be set to RevisionRecord::RAW to disable audience checks.
+        *
+        * @return RenderedRevision|null The rendered revision, or null if the audience checks fails.
+        */
+       public function getRenderedRevision(
+               RevisionRecord $rev,
+               ParserOptions $options = null,
+               User $forUser = null,
+               array $hints = []
+       ) {
+               if ( $rev->getWikiId() !== $this->wikiId ) {
+                       throw new InvalidArgumentException( 'Mismatching wiki ID ' . $rev->getWikiId() );
+               }
+
+               $audience = $hints['audience']
+                       ?? ( $forUser ? RevisionRecord::FOR_THIS_USER : RevisionRecord::FOR_PUBLIC );
+
+               if ( !$rev->audienceCan( RevisionRecord::DELETED_TEXT, $audience, $forUser ) ) {
+                       // Returning null here is awkward, but consist with the signature of
+                       // Revision::getContent() and RevisionRecord::getContent().
+                       return null;
+               }
+
+               if ( !$options ) {
+                       $options = ParserOptions::newCanonical( $forUser ?: 'canonical' );
+               }
+
+               $useMaster = $hints['use-master'] ?? false;
+
+               $dbIndex = $useMaster
+                       ? DB_MASTER // use latest values
+                       : DB_REPLICA; // T154554
+
+               $options->setSpeculativeRevIdCallback( function () use ( $dbIndex ) {
+                       return $this->getSpeculativeRevId( $dbIndex );
+               } );
+
+               $title = Title::newFromLinkTarget( $rev->getPageAsLinkTarget() );
+
+               $renderedRevision = new RenderedRevision(
+                       $title,
+                       $rev,
+                       $options,
+                       function ( RenderedRevision $rrev, array $hints ) {
+                               return $this->combineSlotOutput( $rrev, $hints );
+                       },
+                       $audience,
+                       $forUser
+               );
+
+               $renderedRevision->setSaveParseLogger( $this->saveParseLogger );
+
+               return $renderedRevision;
+       }
+
+       private function getSpeculativeRevId( $dbIndex ) {
+               // Use a fresh master connection in order to see the latest data, by avoiding
+               // stale data from REPEATABLE-READ snapshots.
+               // HACK: But don't use a fresh connection in unit tests, since it would not have
+               // the fake tables. This should be handled by the LoadBalancer!
+               $flags = defined( 'MW_PHPUNIT_TEST' ) || $dbIndex === DB_REPLICA
+                       ? 0 : ILoadBalancer::CONN_TRX_AUTOCOMMIT;
+
+               $db = $this->loadBalancer->getConnectionRef( $dbIndex, [], $this->wikiId, $flags );
+
+               return 1 + (int)$db->selectField(
+                       'revision',
+                       'MAX(rev_id)',
+                       [],
+                       __METHOD__
+               );
+       }
+
+       /**
+        * This implements the layout for combining the output of multiple slots.
+        *
+        * @todo Use placement hints from SlotRoleHandlers instead of hard-coding the layout.
+        *
+        * @param RenderedRevision $rrev
+        * @param array $hints see RenderedRevision::getRevisionParserOutput()
+        *
+        * @return ParserOutput
+        */
+       private function combineSlotOutput( RenderedRevision $rrev, array $hints = [] ) {
+               $revision = $rrev->getRevision();
+               $slots = $revision->getSlots()->getSlots();
+
+               $withHtml = $hints['generate-html'] ?? true;
+
+               // short circuit if there is only the main slot
+               if ( array_keys( $slots ) === [ 'main' ] ) {
+                       return $rrev->getSlotParserOutput( 'main' );
+               }
+
+               // TODO: put fancy layout logic here, see T200915.
+
+               // move main slot to front
+               if ( isset( $slots['main'] ) ) {
+                       $slots = [ 'main' => $slots['main'] ] + $slots;
+               }
+
+               $combinedOutput = new ParserOutput( null );
+               $slotOutput = [];
+
+               $options = $rrev->getOptions();
+               $options->registerWatcher( [ $combinedOutput, 'recordOption' ] );
+
+               foreach ( $slots as $role => $slot ) {
+                       $out = $rrev->getSlotParserOutput( $role, $hints );
+                       $slotOutput[$role] = $out;
+
+                       $combinedOutput->mergeInternalMetaDataFrom( $out, $role );
+                       $combinedOutput->mergeTrackingMetaDataFrom( $out );
+               }
+
+               if ( $withHtml ) {
+                       $html = '';
+                       $first = true;
+                       /** @var ParserOutput $out */
+                       foreach ( $slotOutput as $role => $out ) {
+                               if ( $first ) {
+                                       // skip header for the first slot
+                                       $first = false;
+                               } else {
+                                       // NOTE: this placeholder is hydrated by ParserOutput::getText().
+                                       $headText = Html::element( 'mw:slotheader', [], $role );
+                                       $html .= Html::rawElement( 'h1', [ 'class' => 'mw-slot-header' ], $headText );
+                               }
+
+                               $html .= $out->getRawText();
+                               $combinedOutput->mergeHtmlMetaDataFrom( $out );
+                       }
+
+                       $combinedOutput->setText( $html );
+               }
+
+               $options->registerWatcher( null );
+               return $combinedOutput;
+       }
+
+}
index 99b2942..59cdec9 100644 (file)
@@ -51,6 +51,7 @@ use MediaWiki\Preferences\DefaultPreferencesFactory;
 use MediaWiki\Shell\CommandFactory;
 use MediaWiki\Special\SpecialPageFactory;
 use MediaWiki\Storage\BlobStore;
+use MediaWiki\Revision\RevisionRenderer;
 use MediaWiki\Storage\BlobStoreFactory;
 use MediaWiki\Storage\NameTableStore;
 use MediaWiki\Storage\RevisionFactory;
@@ -445,6 +446,10 @@ return [
                return $services->getRevisionStore();
        },
 
+       'RevisionRenderer' => function ( MediaWikiServices $services ) : RevisionRenderer {
+               return new RevisionRenderer( $services->getDBLoadBalancer() );
+       },
+
        'RevisionStore' => function ( MediaWikiServices $services ) : RevisionStore {
                return $services->getRevisionStoreFactory()->getRevisionStore();
        },
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 dacec96..2df1670 100644 (file)
@@ -36,14 +36,13 @@ use Language;
 use LinksUpdate;
 use LogicException;
 use MediaWiki\Edit\PreparedEdit;
-use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RenderedRevision;
+use MediaWiki\Revision\RevisionRenderer;
 use MediaWiki\User\UserIdentity;
 use MessageCache;
 use ParserCache;
 use ParserOptions;
 use ParserOutput;
-use Psr\Log\LoggerInterface;
-use Psr\Log\NullLogger;
 use RecentChangesUpdateJob;
 use ResourceLoaderWikiModule;
 use Revision;
@@ -112,11 +111,6 @@ class DerivedPageDataUpdater implements IDBAccessObject {
         */
        private $contLang;
 
-       /**
-        * @var LoggerInterface
-        */
-       private $saveParseLogger;
-
        /**
         * @var JobQueueGroup
         */
@@ -177,31 +171,19 @@ class DerivedPageDataUpdater implements IDBAccessObject {
        private $slotsUpdate = null;
 
        /**
-        * @var MutableRevisionSlots|null
-        */
-       private $pstContentSlots = null;
-
-       /**
-        * @var object[] anonymous objects with two fields, using slot roles as keys:
-        *  - hasHtml: whether the output contains HTML
-        *  - ParserOutput: the slot's parser output
-        */
-       private $slotsOutput = [];
-
-       /**
-        * @var ParserOutput|null
+        * @var RevisionRecord|null
         */
-       private $canonicalParserOutput = null;
+       private $revision = null;
 
        /**
-        * @var ParserOptions|null
+        * @var RenderedRevision
         */
-       private $canonicalParserOptions = null;
+       private $renderedRevision = null;
 
        /**
-        * @var RevisionRecord
+        * @var RevisionRenderer
         */
-       private $revision = null;
+       private $revisionRenderer;
 
        /**
         * A stage identifier for managing the life cycle of this instance.
@@ -248,31 +230,29 @@ class DerivedPageDataUpdater implements IDBAccessObject {
        /**
         * @param WikiPage $wikiPage ,
         * @param RevisionStore $revisionStore
+        * @param RevisionRenderer $revisionRenderer
         * @param ParserCache $parserCache
         * @param JobQueueGroup $jobQueueGroup
         * @param MessageCache $messageCache
         * @param Language $contLang
-        * @param LoggerInterface|null $saveParseLogger
         */
        public function __construct(
                WikiPage $wikiPage,
                RevisionStore $revisionStore,
+               RevisionRenderer $revisionRenderer,
                ParserCache $parserCache,
                JobQueueGroup $jobQueueGroup,
                MessageCache $messageCache,
-               Language $contLang,
-               LoggerInterface $saveParseLogger = null
+               Language $contLang
        ) {
                $this->wikiPage = $wikiPage;
 
                $this->parserCache = $parserCache;
                $this->revisionStore = $revisionStore;
+               $this->revisionRenderer = $revisionRenderer;
                $this->jobQueueGroup = $jobQueueGroup;
                $this->messageCache = $messageCache;
                $this->contLang = $contLang;
-
-               // XXX: replace all wfDebug calls with a Logger. Do we nede more than one logger here?
-               $this->saveParseLogger = $saveParseLogger ?: new NullLogger();
        }
 
        /**
@@ -353,7 +333,9 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                        return false;
                }
 
-               if ( $revision && $this->revision && $this->revision->getId() !== $revision->getId() ) {
+               if ( $revision && $this->revision && $this->revision->getId()
+                       && $this->revision->getId() !== $revision->getId()
+               ) {
                        return false;
                }
 
@@ -378,6 +360,7 @@ class DerivedPageDataUpdater implements IDBAccessObject {
 
                if ( $this->revision
                        && $user
+                       && $this->revision->getUser( RevisionRecord::RAW )
                        && $this->revision->getUser( RevisionRecord::RAW )->getName() !== $user->getName()
                ) {
                        return false;
@@ -385,6 +368,7 @@ class DerivedPageDataUpdater implements IDBAccessObject {
 
                if ( $revision
                        && $this->user
+                       && $this->revision->getUser( RevisionRecord::RAW )
                        && $revision->getUser( RevisionRecord::RAW )->getName() !== $this->user->getName()
                ) {
                        return false;
@@ -398,9 +382,9 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                        return false;
                }
 
-               if ( $this->pstContentSlots
-                       && $revision
-                       && !$this->pstContentSlots->hasSameContent( $revision->getSlots() )
+               if ( $revision
+                       && $this->revision
+                       && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() )
                ) {
                        return false;
                }
@@ -533,16 +517,18 @@ class DerivedPageDataUpdater implements IDBAccessObject {
         * @return bool
         */
        public function isContentPrepared() {
-               return $this->pstContentSlots !== null;
+               return $this->revision !== null;
        }
 
        /**
         * Whether prepareUpdate() has been called on this instance.
         *
+        * @note will also return null in case of a null-edit!
+        *
         * @return bool
         */
        public function isUpdatePrepared() {
-               return $this->revision !== null;
+               return $this->revision !== null && $this->revision->getId() !== null;
        }
 
        /**
@@ -562,17 +548,17 @@ class DerivedPageDataUpdater implements IDBAccessObject {
        }
 
        /**
-        * Whether the content of the target revision is publicly visible.
+        * Whether the content is deleted and thus not visible to the public.
         *
         * @return bool
         */
-       public function isContentPublic() {
+       public function isContentDeleted() {
                if ( $this->revision ) {
-                       // XXX: if that revision is the current revision, this can be skipped
-                       return !$this->revision->isDeleted( RevisionRecord::DELETED_TEXT );
+                       // XXX: if that revision is the current revision, this should be skipped
+                       return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT );
                } else {
-                       // If the content has not been saved yet, it cannot have been suppressed yet.
-                       return true;
+                       // If the content has not been saved yet, it cannot have been deleted yet.
+                       return false;
                }
        }
 
@@ -635,7 +621,7 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                        return false;
                }
 
-               if ( !$this->isContentPublic() ) {
+               if ( $this->isContentDeleted() ) {
                        // This should be irrelevant: countability only applies to the current revision,
                        // and the current revision is never suppressed.
                        return false;
@@ -739,7 +725,6 @@ class DerivedPageDataUpdater implements IDBAccessObject {
 
                $this->slotsOutput = [];
                $this->canonicalParserOutput = null;
-               $this->canonicalParserOptions = null;
 
                // The edit may have already been prepared via api.php?action=stashedit
                $stashedEdit = false;
@@ -769,14 +754,13 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                $this->slotsUpdate = $slotsUpdate;
 
                if ( $parentRevision ) {
-                       // start out by inheriting all parent slots
-                       $this->pstContentSlots = MutableRevisionSlots::newFromParentRevisionSlots(
-                               $parentRevision->getSlots()->getSlots()
-                       );
+                       $this->revision = MutableRevisionRecord::newFromParentRevision( $parentRevision );
                } else {
-                       $this->pstContentSlots = new MutableRevisionSlots();
+                       $this->revision = new MutableRevisionRecord( $title );
                }
 
+               $pstContentSlots = $this->revision->getSlots();
+
                foreach ( $slotsUpdate->getModifiedRoles() as $role ) {
                        $slot = $slotsUpdate->getModifiedSlot( $role );
 
@@ -793,18 +777,78 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                                $pstSlot = SlotRecord::newUnsaved( $role, $pstContent );
                        }
 
-                       $this->pstContentSlots->setSlot( $pstSlot );
+                       $pstContentSlots->setSlot( $pstSlot );
                }
 
                foreach ( $slotsUpdate->getRemovedRoles() as $role ) {
-                       $this->pstContentSlots->removeSlot( $role );
+                       $pstContentSlots->removeSlot( $role );
                }
 
                $this->options['created'] = ( $parentRevision === null );
                $this->options['changed'] = ( $parentRevision === null
-                       || !$this->pstContentSlots->hasSameContent( $parentRevision->getSlots() ) );
+                       || !$pstContentSlots->hasSameContent( $parentRevision->getSlots() ) );
 
                $this->doTransition( 'has-content' );
+
+               if ( !$this->options['changed'] ) {
+                       // null-edit!
+
+                       // TODO: move this into MutableRevisionRecord
+                       // TODO: This needs to behave differently for a forced dummy edit!
+                       $this->revision->setId( $parentRevision->getId() );
+                       $this->revision->setTimestamp( $parentRevision->getTimestamp() );
+                       $this->revision->setPageId( $parentRevision->getPageId() );
+                       $this->revision->setParentId( $parentRevision->getParentId() );
+                       $this->revision->setUser( $parentRevision->getUser( RevisionRecord::RAW ) );
+                       $this->revision->setComment( $parentRevision->getComment( RevisionRecord::RAW ) );
+                       $this->revision->setMinorEdit( $parentRevision->isMinor() );
+                       $this->revision->setVisibility( $parentRevision->getVisibility() );
+
+                       // prepareUpdate() is redundant for null-edits
+                       $this->doTransition( 'has-revision' );
+               } else {
+                       $this->revision->setUser( $user );
+               }
+       }
+
+       /**
+        * Returns the update's target revision - that is, the revision that will be the current
+        * revision after the update.
+        *
+        * @note Callers must treat the returned RevisionRecord's content as immutable, even
+        * if it is a MutableRevisionRecord instance. Other aspects of a MutableRevisionRecord
+        * returned from here, such as the user or the comment, may be changed, but may not
+        * be reflected in ParserOutput until after prepareUpdate() has been called.
+        *
+        * @todo This is currently used by PageUpdater::makeNewRevision() to construct an unsaved
+        * MutableRevisionRecord instance. Introduce something like an UnsavedRevisionFactory service
+        * for that purpose instead!
+        *
+        * @return RevisionRecord
+        */
+       public function getRevision() {
+               $this->assertPrepared( __METHOD__ );
+               return $this->revision;
+       }
+
+       /**
+        * @return RenderedRevision
+        */
+       public function getRenderedRevision() {
+               if ( !$this->renderedRevision ) {
+                       $this->assertPrepared( __METHOD__ );
+
+                       // NOTE: we want a canonical rendering, so don't pass $this->user or ParserOptions
+                       // NOTE: the revision is either new or current, so we can bypass audience checks.
+                       $this->renderedRevision = $this->revisionRenderer->getRenderedRevision(
+                               $this->revision,
+                               null,
+                               null,
+                               [ 'use-master' => $this->useMaster(), 'audience' => RevisionRecord::RAW ]
+                       );
+               }
+
+               return $this->renderedRevision;
        }
 
        private function assertHasPageState( $method ) {
@@ -817,7 +861,7 @@ class DerivedPageDataUpdater implements IDBAccessObject {
        }
 
        private function assertPrepared( $method ) {
-               if ( !$this->pstContentSlots ) {
+               if ( !$this->revision ) {
                        throw new LogicException(
                                'Must call prepareContent() or prepareUpdate() before calling ' . $method
                        );
@@ -872,11 +916,14 @@ class DerivedPageDataUpdater implements IDBAccessObject {
        /**
         * Returns the slots of the target revision, after PST.
         *
+        * @note Callers must treat the returned RevisionSlots instance as immutable, even
+        * if it is a MutableRevisionSlots instance.
+        *
         * @return RevisionSlots
         */
        public function getSlots() {
                $this->assertPrepared( __METHOD__ );
-               return $this->pstContentSlots;
+               return $this->revision->getSlots();
        }
 
        /**
@@ -888,12 +935,6 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                $this->assertPrepared( __METHOD__ );
 
                if ( !$this->slotsUpdate ) {
-                       if ( !$this->revision ) {
-                               // This should not be possible: if assertPrepared() returns true,
-                               // at least one of $this->slotsUpdate or $this->revision should be set.
-                               throw new LogicException( 'No revision nor a slots update is known!' );
-                       }
-
                        $old = $this->getOldRevision();
                        $this->slotsUpdate = RevisionSlotsUpdate::newFromRevisionSlots(
                                $this->revision->getSlots(),
@@ -957,7 +998,6 @@ class DerivedPageDataUpdater implements IDBAccessObject {
         * - moved: bool, whether the page was moved (default false)
         * - restored: bool, whether the page was undeleted (default false)
         * - oldrevision: Revision object for the pre-update revision (default null)
-        * - parseroutput: The canonical ParserOutput of $revision (default null)
         * - triggeringuser: The user triggering the update (UserIdentity, default null)
         * - oldredirect: bool, null, or string 'no-change' (default null):
         *    - bool: whether the page was counted as a redirect before that
@@ -979,12 +1019,6 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                        '$options["oldrevision"]',
                        'must be a RevisionRecord (or Revision)'
                );
-               Assert::parameter(
-                       !isset( $options['parseroutput'] )
-                       || $options['parseroutput'] instanceof ParserOutput,
-                       '$options["parseroutput"]',
-                       'must be a ParserOutput'
-               );
                Assert::parameter(
                        !isset( $options['triggeringuser'] )
                        || $options['triggeringuser'] instanceof UserIdentity,
@@ -998,7 +1032,7 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                        );
                }
 
-               if ( $this->revision ) {
+               if ( $this->revision && $this->revision->getId() ) {
                        if ( $this->revision->getId() === $revision->getId() ) {
                                return; // nothing to do!
                        } else {
@@ -1011,8 +1045,8 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                        }
                }
 
-               if ( $this->pstContentSlots
-                       && !$this->pstContentSlots->hasSameContent( $revision->getSlots() )
+               if ( $this->revision
+                       && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() )
                ) {
                        throw new LogicException(
                                'The Revision provided has mismatching content!'
@@ -1107,7 +1141,6 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                $this->options['created'] = ( $this->pageState['oldId'] === 0 );
 
                $this->revision = $revision;
-               $this->pstContentSlots = $revision->getSlots();
 
                $this->doTransition( 'has-revision' );
 
@@ -1118,74 +1151,14 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                }
 
                // Prune any output that depends on the revision ID.
-               if ( $this->canonicalParserOutput ) {
-                       if ( $this->outputVariesOnRevisionMetaData( $this->canonicalParserOutput, __METHOD__ ) ) {
-                               $this->canonicalParserOutput = null;
-                       }
-               } else {
-                       $this->saveParseLogger->debug( __METHOD__ . ": No prepared canonical output...\n" );
-               }
-
-               if ( $this->slotsOutput ) {
-                       foreach ( $this->slotsOutput as $role => $prep ) {
-                               if ( $this->outputVariesOnRevisionMetaData( $prep->output, __METHOD__ ) ) {
-                                       unset( $this->slotsOutput[$role] );
-                               }
-                       }
-               } else {
-                       $this->saveParseLogger->debug( __METHOD__ . ": No prepared output...\n" );
-               }
-
-               // reset ParserOptions, so the actual revision ID is used in future ParserOutput generation
-               $this->canonicalParserOptions = null;
-
-               // Avoid re-generating the canonical ParserOutput if it's known.
-               // We just trust that the caller is passing the correct ParserOutput!
-               if ( isset( $options['parseroutput'] ) ) {
-                       $this->canonicalParserOutput = $options['parseroutput'];
+               if ( $this->renderedRevision ) {
+                       $this->renderedRevision->updateRevision( $revision );
                }
 
                // TODO: optionally get ParserOutput from the ParserCache here.
                // Move the logic used by RefreshLinksJob here!
        }
 
-       /**
-        * @param ParserOutput $out
-        * @param string $method
-        * @return bool
-        */
-       private function outputVariesOnRevisionMetaData( ParserOutput $out, $method = __METHOD__ ) {
-               if ( $out->getFlag( 'vary-revision' ) ) {
-                       // XXX: Just keep the output if the speculative revision ID was correct, like below?
-                       $this->saveParseLogger->info(
-                               "$method: Prepared output has vary-revision...\n"
-                       );
-                       return true;
-               } elseif ( $out->getFlag( 'vary-revision-id' )
-                       && $out->getSpeculativeRevIdUsed() !== $this->revision->getId()
-               ) {
-                       $this->saveParseLogger->info(
-                               "$method: Prepared output has vary-revision-id with wrong ID...\n"
-                       );
-                       return true;
-               } elseif ( $out->getFlag( 'vary-user' )
-                       && !$this->options['changed']
-               ) {
-                       // When Alice makes a null-edit on top of Bob's edit,
-                       // {{REVISIONUSER}} must resolve to "Bob", not "Alice", see T135261.
-                       // TODO: to avoid this, we should check for null-edits in makeCanonicalparserOptions,
-                       // and set setCurrentRevisionCallback to return the existing revision when appropriate.
-                       // See also the comment there [dk 2018-05]
-                       $this->saveParseLogger->info(
-                               "$method: Prepared output has vary-user and is null-edit...\n"
-                       );
-                       return true;
-               } else {
-                       wfDebug( "$method: Keeping prepared output...\n" );
-                       return false;
-               }
-       }
-
        /**
         * @deprecated This only exists for B/C, use the getters on DerivedPageDataUpdater directly!
         * @return PreparedEdit
@@ -1198,11 +1171,11 @@ class DerivedPageDataUpdater implements IDBAccessObject {
 
                $preparedEdit->popts = $this->getCanonicalParserOptions();
                $preparedEdit->output = $this->getCanonicalParserOutput();
-               $preparedEdit->pstContent = $this->pstContentSlots->getContent( 'main' );
+               $preparedEdit->pstContent = $this->revision->getContent( 'main' );
                $preparedEdit->newContent =
                        $slotsUpdate->isModifiedSlot( 'main' )
                        ? $slotsUpdate->getModifiedSlot( 'main' )->getContent()
-                       : $this->pstContentSlots->getContent( 'main' ); // XXX: can we just remove this?
+                       : $this->revision->getContent( 'main' ); // XXX: can we just remove this?
                $preparedEdit->oldContent = null; // unused. // XXX: could get this from the parent revision
                $preparedEdit->revid = $this->revision ? $this->revision->getId() : null;
                $preparedEdit->timestamp = $preparedEdit->output->getCacheTime();
@@ -1211,130 +1184,30 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                return $preparedEdit;
        }
 
-       /**
-        * @return bool
-        */
-       private function isContentAccessible() {
-               // XXX: when we move this to a RevisionHtmlProvider, the audience may be configurable!
-               return $this->isContentPublic();
-       }
-
        /**
         * @param string $role
         * @param bool $generateHtml
         * @return ParserOutput
         */
        public function getSlotParserOutput( $role, $generateHtml = true ) {
-               // TODO: factor this out into a RevisionHtmlProvider that can also be used for viewing.
-
-               $this->assertPrepared( __METHOD__ );
-
-               if ( isset( $this->slotsOutput[$role] ) ) {
-                       $entry = $this->slotsOutput[$role];
-
-                       if ( $entry->hasHtml || !$generateHtml ) {
-                               return $entry->output;
-                       }
-               }
-
-               if ( !$this->isContentAccessible() ) {
-                       // empty output
-                       $output = new ParserOutput();
-               } else {
-                       $content = $this->getRawContent( $role );
-
-                       $output = $content->getParserOutput(
-                               $this->getTitle(),
-                               $this->revision ? $this->revision->getId() : null,
-                               $this->getCanonicalParserOptions(),
-                               $generateHtml
-                       );
-               }
-
-               $this->slotsOutput[$role] = (object)[
-                       'output' => $output,
-                       'hasHtml' => $generateHtml,
-               ];
-
-               $output->setCacheTime( $this->getTimestampNow() );
-
-               return $output;
+               return $this->getRenderedRevision()->getSlotParserOutput(
+                       $role,
+                       [ 'generate-html' => $generateHtml ]
+               );
        }
 
        /**
         * @return ParserOutput
         */
        public function getCanonicalParserOutput() {
-               if ( $this->canonicalParserOutput ) {
-                       return $this->canonicalParserOutput;
-               }
-
-               // TODO: MCR: logic for combining the output of multiple slot goes here!
-               // TODO: factor this out into a RevisionHtmlProvider that can also be used for viewing.
-               $this->canonicalParserOutput = $this->getSlotParserOutput( 'main' );
-
-               return $this->canonicalParserOutput;
+               return $this->getRenderedRevision()->getRevisionParserOutput();
        }
 
        /**
         * @return ParserOptions
         */
        public function getCanonicalParserOptions() {
-               if ( $this->canonicalParserOptions ) {
-                       return $this->canonicalParserOptions;
-               }
-
-               // TODO: ParserOptions should *not* be controlled by the ContentHandler!
-               // See T190712 for how to fix this for Wikibase.
-               $this->canonicalParserOptions = $this->wikiPage->makeParserOptions( 'canonical' );
-
-               //TODO: if $this->revision is not set but we already know that we pending update is a
-               // null-edit, we should probably use the page's current revision here.
-               // That would avoid the need for the !$this->options['changed'] branch in
-               // outputVariesOnRevisionMetaData [dk 2018-05]
-
-               if ( $this->revision ) {
-                       // Make sure we use the appropriate revision ID when generating output
-                       $title = $this->getTitle();
-                       $oldCallback = $this->canonicalParserOptions->getCurrentRevisionCallback();
-                       $this->canonicalParserOptions->setCurrentRevisionCallback(
-                               function ( Title $parserTitle, $parser = false ) use ( $title, &$oldCallback ) {
-                                       if ( $parserTitle->equals( $title ) ) {
-                                               $legacyRevision = new Revision( $this->revision );
-                                               return $legacyRevision;
-                                       } else {
-                                               return call_user_func( $oldCallback, $parserTitle, $parser );
-                                       }
-                               }
-                       );
-               } else {
-                       // NOTE: we only get here without READ_LATEST if called directly by application logic
-                       $dbIndex = $this->useMaster()
-                               ? DB_MASTER // use the best possible guess
-                               : DB_REPLICA; // T154554
-
-                       $this->canonicalParserOptions->setSpeculativeRevIdCallback(
-                               function () use ( $dbIndex ) {
-                                       // TODO: inject LoadBalancer!
-                                       $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
-                                       // Use a fresh connection in order to see the latest data, by avoiding
-                                       // stale data from REPEATABLE-READ snapshots.
-                                       // HACK: But don't use a fresh connection in unit tests, since it would not have
-                                       // the fake tables. This should be handled by the LoadBalancer!
-                                       $flags = defined( 'MW_PHPUNIT_TEST' ) ? 0 : $lb::CONN_TRX_AUTOCOMMIT;
-                                       $db = $lb->getConnectionRef( $dbIndex, [], $this->getWikiId(), $flags );
-
-                                       return 1 + (int)$db->selectField(
-                                               'revision',
-                                               'MAX(rev_id)',
-                                               [],
-                                               __METHOD__
-                                       );
-                               }
-                       );
-               }
-
-               return $this->canonicalParserOptions;
+               return $this->getRenderedRevision()->getOptions();
        }
 
        /**
@@ -1490,7 +1363,7 @@ class DerivedPageDataUpdater implements IDBAccessObject {
 
                // TODO: make search infrastructure aware of slots!
                $mainSlot = $this->revision->getSlot( 'main' );
-               if ( !$mainSlot->isInherited() && $this->isContentPublic() ) {
+               if ( !$mainSlot->isInherited() && !$this->isContentDeleted() ) {
                        DeferredUpdates::addUpdate( new SearchUpdate( $id, $dbKey, $mainSlot->getContent() ) );
                }
 
@@ -1525,7 +1398,7 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                if ( $title->getNamespace() == NS_MEDIAWIKI
                        && $this->getRevisionSlotsUpdate()->isModifiedSlot( 'main' )
                ) {
-                       $mainContent = $this->isContentPublic() ? $this->getRawContent( 'main' ) : null;
+                       $mainContent = $this->isContentDeleted() ? null : $this->getRawContent( 'main' );
 
                        $this->messageCache->updateMessageOverride( $title, $mainContent );
                }
index 1aa1165..72d6547 100644 (file)
@@ -314,6 +314,17 @@ class MutableRevisionRecord extends RevisionRecord {
                return $this->mSha1;
        }
 
+       /**
+        * Returns the slots defined for this revision as a MutableRevisionSlots instance,
+        * which can be modified to defined the slots for this revision.
+        *
+        * @return MutableRevisionSlots
+        */
+       public function getSlots() {
+               // Overwritten just guarantee the more narrow return type.
+               return parent::getSlots();
+       }
+
        /**
         * Invalidate cached aggregate values such as hash and size.
         */
index 838efcd..9d2f209 100644 (file)
@@ -344,10 +344,6 @@ class PageUpdater {
                // TODO: MCR: check the role and the content's model against the list of supported
                // roles, see T194046.
 
-               if ( $role !== 'main' ) {
-                       throw new InvalidArgumentException( 'Only the main slot is presently supported' );
-               }
-
                $this->slotsUpdate->modifyContent( $role, $content );
        }
 
@@ -861,7 +857,11 @@ class PageUpdater {
                $title = $this->getTitle();
                $parent = $this->grabParentRevision();
 
-               $rev = new MutableRevisionRecord( $title, $this->getWikiId() );
+               // XXX: we expect to get a MutableRevisionRecord here, but that's a bit brittle!
+               // TODO: introduce something like an UnsavedRevisionFactory service instead!
+               /** @var MutableRevisionRecord $rev */
+               $rev = $this->derivedDataUpdater->getRevision();
+
                $rev->setPageId( $title->getArticleID() );
 
                if ( $parent ) {
@@ -876,17 +876,13 @@ class PageUpdater {
                $rev->setTimestamp( $timestamp );
                $rev->setMinorEdit( ( $flags & EDIT_MINOR ) > 0 );
 
-               foreach ( $this->derivedDataUpdater->getSlots()->getSlots() as $slot ) {
+               foreach ( $rev->getSlots()->getSlots() as $slot ) {
                        $content = $slot->getContent();
 
                        // XXX: We may push this up to the "edit controller" level, see T192777.
                        // TODO: change the signature of PrepareSave to not take a WikiPage!
                        $prepStatus = $content->prepareSave( $wikiPage, $flags, $oldid, $user );
 
-                       if ( $prepStatus->isOK() ) {
-                               $rev->setSlot( $slot );
-                       }
-
                        // TODO: MCR: record which problem arose in which slot.
                        $status->merge( $prepStatus );
                }
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..28d331b 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>",
        "apihelp-opensearch-example-te": "找出以 <kbd>Te</kbd> 為開頭的頁面。",
        "apihelp-options-summary": "更改目前使用者的偏好設定。",
        "apihelp-options-param-reset": "重設偏好設定為網站預設值。",
+       "apihelp-options-param-optionvalue": "由 <var>$1optionname</var> 所指定,用於選項的值。",
        "apihelp-options-example-reset": "重設所有偏好設定",
        "apihelp-options-example-change": "更改<kbd>skin</kbd>和<kbd>hideminor</kbd>偏好設定。",
        "apihelp-paraminfo-summary": "獲得有關 API 模組的資訊。",
        "apihelp-paraminfo-param-helpformat": "說明字串的格式。",
+       "apihelp-parse-summary": "解析內容併回傳解析器輸出。",
        "apihelp-parse-param-summary": "解析摘要。",
        "apihelp-parse-param-pageid": "解析此頁面的內容。覆蓋 <var>$1page</var>。",
        "apihelp-parse-param-redirects": "若 <var>$1page</var> 或者 <var>$1pageid</var> 被設定成重新導向,則解析它。",
        "apihelp-parse-param-prop": "要取得的資訊部份:",
+       "apihelp-parse-paramvalue-prop-revid": "添加已解析頁面的修訂 ID。",
        "apihelp-parse-paramvalue-prop-headhtml": "取得頁面已解析的 <code>&lt;head&gt;</code>。",
        "apihelp-parse-param-disablepp": "請改用<var>$1disablelimitreport</var>。",
        "apihelp-parse-param-preview": "在預覽模式下解析。",
        "apihelp-query-param-prop": "替已查詢頁面所要取得的屬性。",
        "apihelp-query-param-list": "要取得的清單。",
        "apihelp-query-param-meta": "要取得的詮釋資料。",
+       "apihelp-query-example-allpages": "索取以 <kbd>API/</kbd> 為開頭的頁面修訂。",
        "apihelp-query+allcategories-summary": "列舉所有分類。",
        "apihelp-query+allcategories-param-from": "起始列舉的分類。",
        "apihelp-query+allcategories-param-to": "終止列舉的分類。",
        "apihelp-query+alldeletedrevisions-param-from": "在此標題開始列出。",
        "apihelp-query+alldeletedrevisions-param-to": "在此標題停止列出。",
        "apihelp-query+alldeletedrevisions-param-prefix": "搜尋以此值為開頭的所有頁面標題。",
+       "apihelp-query+alldeletedrevisions-param-tag": "僅列出以此標籤所標記的修訂。",
        "apihelp-query+alldeletedrevisions-param-user": "此列出由該使用者作出的修訂。",
        "apihelp-query+alldeletedrevisions-param-excludeuser": "不要列出由該使用者作出的修訂。",
        "apihelp-query+alldeletedrevisions-param-namespace": "僅列出此命名空間的頁面。",
        "apihelp-query+allimages-param-limit": "要回傳的圖片總數。",
        "apihelp-query+allimages-example-B": "搜尋以字母 <kbd>B</kbd> 為開頭的所有檔案清單。",
        "apihelp-query+allimages-example-recent": "顯示近期已上傳檔案的清單,類似於 [[Special:NewFiles]]。",
+       "apihelp-query+allimages-example-generator": "顯示 4 個以 <kbd>T</kbd> 為開頭的檔案之資訊。",
        "apihelp-query+alllinks-param-from": "要起始列舉的連結標題。",
        "apihelp-query+alllinks-param-to": "要終止列舉的連結標題。",
+       "apihelp-query+alllinks-param-prefix": "搜尋以此值為開頭的所有連結標題。",
        "apihelp-query+alllinks-param-prop": "要包含的資訊部份:",
        "apihelp-query+alllinks-paramvalue-prop-title": "添加連結標題。",
        "apihelp-query+alllinks-param-namespace": "要列舉的命名空間。",
        "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 b6211b0..733d85a 100644 (file)
@@ -505,6 +505,7 @@ abstract class AbstractContent implements Content {
                }
 
                $po = new ParserOutput();
+               $options->registerWatcher( [ $po, 'recordOption' ] );
 
                if ( Hooks::run( 'ContentGetParserOutput',
                        [ $this, $title, $revId, $options, $generateHtml, &$po ] )
@@ -518,6 +519,7 @@ abstract class AbstractContent implements Content {
                }
 
                Hooks::run( 'ContentAlterParserOutput', [ $this, $title, $po ] );
+               $options->registerWatcher( null );
 
                return $po;
        }
index 20bce37..0198a0d 100644 (file)
@@ -253,6 +253,7 @@ class TextContent extends AbstractContent {
                        $html = '';
                }
 
+               $output->clearWrapperDivClass();
                $output->setText( $html );
        }
 
index f370e43..5ab83c6 100644 (file)
@@ -71,6 +71,9 @@ class LinksDeletionUpdate extends DataUpdate implements EnqueueableDataUpdate {
                        // Make sure all links update threads see the changes of each other.
                        // This handles the case when updates have to batched into several COMMITs.
                        $scopedLock = LinksUpdate::acquirePageLock( $this->getDB(), $id );
+                       if ( !$scopedLock ) {
+                               throw new RuntimeException( "Could not acquire lock for page ID '{$id}'." );
+                       }
                }
 
                $title = $this->page->getTitle();
index 5b1be6d..dbe387b 100644 (file)
@@ -21,6 +21,7 @@
  */
 
 use Wikimedia\Rdbms\IDatabase;
+use MediaWiki\Logger\LoggerFactory;
 use MediaWiki\MediaWikiServices;
 use Wikimedia\ScopedCallback;
 
@@ -163,6 +164,9 @@ class LinksUpdate extends DataUpdate implements EnqueueableDataUpdate {
                        // Make sure all links update threads see the changes of each other.
                        // This handles the case when updates have to batched into several COMMITs.
                        $scopedLock = self::acquirePageLock( $this->getDB(), $this->mId );
+                       if ( !$scopedLock ) {
+                               throw new RuntimeException( "Could not acquire lock for page ID '{$this->mId}'." );
+                       }
                }
 
                // Avoid PHP 7.1 warning from passing $this by reference
@@ -190,15 +194,19 @@ class LinksUpdate extends DataUpdate implements EnqueueableDataUpdate {
         * @param IDatabase $dbw
         * @param int $pageId
         * @param string $why One of (job, atomicity)
-        * @return ScopedCallback
-        * @throws RuntimeException
+        * @return ScopedCallback|null
         * @since 1.27
         */
        public static function acquirePageLock( IDatabase $dbw, $pageId, $why = 'atomicity' ) {
                $key = "LinksUpdate:$why:pageid:$pageId";
                $scopedLock = $dbw->getScopedLockAndFlush( $key, __METHOD__, 15 );
                if ( !$scopedLock ) {
-                       throw new RuntimeException( "Could not acquire lock '$key'." );
+                       $logger = LoggerFactory::getInstance( 'SecondaryDataUpdate' );
+                       $logger->info( "Could not acquire lock '{key}' for page ID '{page_id}'.", [
+                               'key' => $key,
+                               'page_id' => $pageId,
+                       ] );
+                       return null;
                }
 
                return $scopedLock;
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..a88ab99 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 );
                }
@@ -868,8 +878,13 @@ abstract class HTMLFormField {
         * Determine form errors to display and their classes
         * @since 1.20
         *
+        * phan-taint-check gets confused with returning both classes
+        * and errors and thinks double escaping is happening, so specify
+        * that return value has no taint.
+        *
         * @param string $value The value of the input
         * @return array array( $errors, $errorClass )
+        * @return-taint none
         */
        public function getErrorsAndErrorClass( $value ) {
                $errors = $this->validate( $value, $this->mParent->mFieldData );
@@ -915,9 +930,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'] ) ) {
@@ -1132,6 +1154,12 @@ abstract class HTMLFormField {
         * Formats one or more errors as accepted by field validation-callback.
         *
         * @param string|Message|array $errors Array of strings or Message instances
+        * To work around limitations in phan-taint-check the calling
+        * class has taintedness disabled. So instead we pretend that
+        * this method outputs html, since the result is eventually
+        * outputted anyways without escaping and this allows us to verify
+        * stuff is safe even though the caller has taintedness cleared.
+        * @param-taint $errors exec_html
         * @return string HTML
         * @since 1.18
         */
index 0a14192..8328abf 100644 (file)
@@ -47,6 +47,10 @@ class DeleteLinksJob extends Job {
 
                // Serialize links updates by page ID so they see each others' changes
                $scopedLock = LinksUpdate::acquirePageLock( wfGetDB( DB_MASTER ), $pageId, 'job' );
+               if ( $scopedLock === null ) {
+                       $this->setLastError( 'LinksUpdate already running for this page, try again later.' );
+                       return false;
+               }
 
                if ( WikiPage::newFromID( $pageId, WikiPage::READ_LATEST ) ) {
                        // The page was restored somehow or something went wrong
index 8854c65..aa8e121 100644 (file)
@@ -146,6 +146,11 @@ class RefreshLinksJob extends Job {
                $dbw = $lbFactory->getMainLB()->getConnection( DB_MASTER );
                /** @noinspection PhpUnusedLocalVariableInspection */
                $scopedLock = LinksUpdate::acquirePageLock( $dbw, $page->getId(), 'job' );
+               if ( $scopedLock === null ) {
+                       // Another job is already updating the page, likely for an older revision (T170596).
+                       $this->setLastError( 'LinksUpdate already running for this page, try again later.' );
+                       return false;
+               }
                // Get the latest ID *after* acquirePageLock() flushed the transaction.
                // This is used to detect edits/moves after loadPageData() but before the scope lock.
                // The works around the chicken/egg problem of determining the scope lock key.
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 24cc8b5..b609d7b 100644 (file)
@@ -23,6 +23,7 @@
 use MediaWiki\Edit\PreparedEdit;
 use MediaWiki\Logger\LoggerFactory;
 use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RevisionRenderer;
 use MediaWiki\Storage\DerivedPageDataUpdater;
 use MediaWiki\Storage\PageUpdater;
 use MediaWiki\Storage\RevisionRecord;
@@ -223,6 +224,13 @@ class WikiPage implements Page, IDBAccessObject {
                return MediaWikiServices::getInstance()->getRevisionStore();
        }
 
+       /**
+        * @return RevisionRenderer
+        */
+       private function getRevisionRenderer() {
+               return MediaWikiServices::getInstance()->getRevisionRenderer();
+       }
+
        /**
         * @return ParserCache
         */
@@ -931,6 +939,7 @@ class WikiPage implements Page, IDBAccessObject {
                                // links.
                                $hasLinks = (bool)count( $editInfo->output->getLinks() );
                        } else {
+                               // NOTE: keep in sync with revisionRenderer::getLinkCount
                                $hasLinks = (bool)wfGetDB( DB_REPLICA )->selectField( 'pagelinks', 1,
                                        [ 'pl_from' => $this->getId() ], __METHOD__ );
                        }
@@ -1630,6 +1639,7 @@ class WikiPage implements Page, IDBAccessObject {
                $derivedDataUpdater = new DerivedPageDataUpdater(
                        $this, // NOTE: eventually, PageUpdater should not know about WikiPage
                        $this->getRevisionStore(),
+                       $this->getRevisionRenderer(),
                        $this->getParserCache(),
                        JobQueueGroup::singleton(),
                        MessageCache::singleton(),
index 78265e8..bd3c32a 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 );
@@ -1313,6 +1313,7 @@ class Parser {
         * @private
         *
         * @param string $text The text to parse
+        * @param-taint $text escapes_html
         * @param bool $isMain Whether this is being called from the main parse() function
         * @param PPFrame|bool $frame A pre-processor frame
         *
@@ -2692,9 +2693,19 @@ class Parser {
                                $this->mOutput->setFlag( 'vary-revision-id' );
                                wfDebug( __METHOD__ . ": {{REVISIONID}} used, setting vary-revision-id...\n" );
                                $value = $this->mRevisionId;
-                               if ( !$value && $this->mOptions->getSpeculativeRevIdCallback() ) {
-                                       $value = call_user_func( $this->mOptions->getSpeculativeRevIdCallback() );
-                                       $this->mOutput->setSpeculativeRevIdUsed( $value );
+
+                               if ( !$value ) {
+                                       $rev = $this->getRevisionObject();
+                                       if ( $rev ) {
+                                               $value = $rev->getId();
+                                       }
+                               }
+
+                               if ( !$value ) {
+                                       $value = $this->mOptions->getSpeculativeRevId();
+                                       if ( $value ) {
+                                               $this->mOutput->setSpeculativeRevIdUsed( $value );
+                                       }
                                }
                                break;
                        case 'revisionday':
@@ -5750,10 +5761,9 @@ class Parser {
                if ( !is_null( $this->mRevisionObject ) ) {
                        return $this->mRevisionObject;
                }
-               if ( is_null( $this->mRevisionId ) ) {
-                       return null;
-               }
 
+               // NOTE: try to get the RevisionObject even if mRevisionId is null.
+               // This is useful when parsing revision that has not yet been saved.
                $rev = call_user_func(
                        $this->mOptions->getCurrentRevisionCallback(), $this->getTitle(), $this
                );
@@ -5761,7 +5771,7 @@ class Parser {
                # If the parse is for a new revision, then the callback should have
                # already been set to force the object and should match mRevisionId.
                # If not, try to fetch by mRevisionId for sanity.
-               if ( $rev && $rev->getId() != $this->mRevisionId ) {
+               if ( $this->mRevisionId && $rev && $rev->getId() != $this->mRevisionId ) {
                        $rev = Revision::newFromId( $this->mRevisionId );
                }
 
index 5e6081d..43c72b1 100644 (file)
@@ -301,6 +301,10 @@ class ParserCache {
                $cacheTime = null,
                $revId = null
        ) {
+               if ( !$parserOutput->hasText() ) {
+                       throw new InvalidArgumentException( 'Attempt to cache a ParserOutput with no text set!' );
+               }
+
                $expire = $parserOutput->getCacheExpiry();
                if ( $expire > 0 && !$this->mMemc instanceof EmptyBagOStuff ) {
                        $cacheTime = $cacheTime ?: wfTimestampNow();
index b30c116..a8da3ce 100644 (file)
@@ -61,6 +61,7 @@ class ParserOptions {
         */
        private static $lazyOptions = [
                'dateformat' => [ __CLASS__, 'initDateFormat' ],
+               'speculativeRevId' => [ __CLASS__, 'initSpeculativeRevId' ],
        ];
 
        /**
@@ -831,9 +832,38 @@ class ParserOptions {
                return $this->setOptionLegacy( 'templateCallback', $x );
        }
 
+       /**
+        * A guess for {{REVISIONID}}, calculated using the callback provided via
+        * setSpeculativeRevIdCallback(). For consistency, the value will be calculated upon the
+        * first call of this method, and re-used for subsequent calls.
+        *
+        * If no callback was defined via setSpeculativeRevIdCallback(), this method will return false.
+        *
+        * @since 1.32
+        * @return int|false
+        */
+       public function getSpeculativeRevId() {
+               return $this->getOption( 'speculativeRevId' );
+       }
+
+       /**
+        * Callback registered with ParserOptions::$lazyOptions, triggered by getSpeculativeRevId().
+        *
+        * @param ParserOptions $popt
+        * @return bool|false
+        */
+       private static function initSpeculativeRevId( ParserOptions $popt ) {
+               $cb = $popt->getOption( 'speculativeRevIdCallback' );
+               $id = $cb ? $cb() : null;
+
+               // returning null would result in this being re-called every access
+               return $id ?? false;
+       }
+
        /**
         * Callback to generate a guess for {{REVISIONID}}
         * @since 1.28
+        * @deprecated since 1.32, use getSpeculativeRevId() instead!
         * @return callable|null
         */
        public function getSpeculativeRevIdCallback() {
@@ -847,6 +877,7 @@ class ParserOptions {
         * @return callable|null Old value
         */
        public function setSpeculativeRevIdCallback( $x ) {
+               $this->setOption( 'speculativeRevId', null ); // reset
                return $this->setOptionLegacy( 'speculativeRevIdCallback', $x );
        }
 
@@ -1081,6 +1112,7 @@ class ParserOptions {
                                'currentRevisionCallback' => [ Parser::class, 'statelessFetchRevision' ],
                                'templateCallback' => [ Parser::class, 'statelessFetchTemplate' ],
                                'speculativeRevIdCallback' => null,
+                               'speculativeRevId' => null,
                        ];
 
                        Hooks::run( 'ParserOptionsRegister', [
index 182648a..78160ca 100644 (file)
@@ -31,9 +31,9 @@ class ParserOutput extends CacheTime {
        const SUPPORTS_UNWRAP_TRANSFORM = 1;
 
        /**
-        * @var string $mText The output text
+        * @var string|null $mText The output text
         */
-       public $mText;
+       public $mText = null;
 
        /**
         * @var array $mLanguageLinks List of the full text of language links,
@@ -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;
 
@@ -227,6 +232,15 @@ class ParserOutput extends CacheTime {
        const SLOW_AR_TTL = 3600; // adaptive TTL for "slow" pages
        const MIN_AR_TTL = 15; // min adaptive TTL (for sanity, pool counter, and edit stashing)
 
+       /**
+        * @param string|null $text HTML. Use null to indicate that this ParserOutput contains only
+        *        meta-data, and the HTML output is undetermined, as opposed to empty. Passing null
+        *        here causes hasText() to return false.
+        * @param array $languageLinks
+        * @param array $categoryLinks
+        * @param bool $unused
+        * @param string $titletext
+        */
        public function __construct( $text = '', $languageLinks = [], $categoryLinks = [],
                $unused = false, $titletext = ''
        ) {
@@ -236,6 +250,20 @@ class ParserOutput extends CacheTime {
                $this->mTitleText = $titletext;
        }
 
+       /**
+        * Returns true if text was passed to the constructor, or set using setText(). Returns false
+        * if null was passed to the $text parameter of the constructor to indicate that this
+        * ParserOutput only contains meta-data, and the HTML output is undetermined.
+        *
+        * @since 1.32
+        *
+        * @return bool Whether this ParserOutput contains rendered text. If this returns false, the
+        *         ParserOutput contains meta-data only.
+        */
+       public function hasText() {
+               return ( $this->mText !== null );
+       }
+
        /**
         * Get the cacheable text with <mw:editsection> markers still in it. The
         * return value is suitable for writing back via setText() but is not valid
@@ -245,6 +273,10 @@ class ParserOutput extends CacheTime {
         * @since 1.27
         */
        public function getRawText() {
+               if ( $this->mText === null ) {
+                       throw new LogicException( 'This ParserOutput contains no text!' );
+               }
+
                return $this->mText;
        }
 
@@ -258,7 +290,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 +310,14 @@ class ParserOutput extends CacheTime {
                        'enableSectionEditLinks' => true,
                        'unwrap' => false,
                        'deduplicateStyles' => true,
+                       'wrapperDivClass' => $this->getWrapperDivClass(),
                ];
-               $text = $this->mText;
+               $text = $this->getRawText();
 
                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'] ) {
@@ -362,9 +385,48 @@ class ParserOutput extends CacheTime {
                        );
                }
 
+               // Hydrate slot section header placeholders generated by RevisionRenderer.
+               $text = preg_replace_callback(
+                       '#<mw:slotheader>(.*?)</mw:slotheader>#',
+                       function ( $m ) {
+                               $role = htmlspecialchars_decode( $m[1] );
+                               // TODO: map to message, using the interface language. Set lang="xyz" accordingly.
+                               $headerText = $role;
+                               return $headerText;
+                       },
+                       $text
+               );
                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
@@ -445,6 +507,9 @@ class ParserOutput extends CacheTime {
                return $this->mExternalLinks;
        }
 
+       public function setNoGallery( $value ) {
+               $this->mNoGallery = (bool)$value;
+       }
        public function getNoGallery() {
                return $this->mNoGallery;
        }
@@ -1223,4 +1288,211 @@ class ParserOutput extends CacheTime {
                        [ 'mParseStartTime' ]
                );
        }
+
+       /**
+        * Merges internal metadata such as flags, accessed options, and profiling info
+        * from $source into this ParserOutput. This should be used whenever the state of $source
+        * has any impact on the state of this ParserOutput.
+        *
+        * @param ParserOutput $source
+        */
+       public function mergeInternalMetaDataFrom( ParserOutput $source ) {
+               $this->mOutputHooks = self::mergeList( $this->mOutputHooks, $source->getOutputHooks() );
+               $this->mWarnings = self::mergeMap( $this->mWarnings, $source->mWarnings ); // don't use getter
+               $this->mTimestamp = $this->useMaxValue( $this->mTimestamp, $source->getTimestamp() );
+
+               if ( $this->mSpeculativeRevId && $source->mSpeculativeRevId
+                       && $this->mSpeculativeRevId !== $source->mSpeculativeRevId
+               ) {
+                       wfLogWarning(
+                               'Inconsistent speculative revision ID encountered while merging parser output!'
+                       );
+               }
+
+               $this->mSpeculativeRevId = $this->useMaxValue(
+                       $this->mSpeculativeRevId,
+                       $source->getSpeculativeRevIdUsed()
+               );
+               $this->mParseStartTime = $this->useEachMinValue(
+                       $this->mParseStartTime,
+                       $source->mParseStartTime
+               );
+
+               $this->mFlags = self::mergeMap( $this->mFlags, $source->mFlags );
+               $this->mAccessedOptions = self::mergeMap( $this->mAccessedOptions, $source->mAccessedOptions );
+
+               // TODO: maintain per-slot limit reports!
+               if ( empty( $this->mLimitReportData ) ) {
+                       $this->mLimitReportData = $source->mLimitReportData;
+               }
+               if ( empty( $this->mLimitReportJSData ) ) {
+                       $this->mLimitReportJSData = $source->mLimitReportJSData;
+               }
+       }
+
+       /**
+        * Merges HTML metadata such as head items, JS config vars, and HTTP cache control info
+        * from $source into this ParserOutput. This should be used whenever the HTML in $source
+        * has been somehow mered into the HTML of this ParserOutput.
+        *
+        * @param ParserOutput $source
+        */
+       public function mergeHtmlMetaDataFrom( ParserOutput $source ) {
+               // HTML and HTTP
+               $this->mHeadItems = self::mergeMixedList( $this->mHeadItems, $source->getHeadItems() );
+               $this->mModules = self::mergeList( $this->mModules, $source->getModules() );
+               $this->mModuleScripts = self::mergeList( $this->mModuleScripts, $source->getModuleScripts() );
+               $this->mModuleStyles = self::mergeList( $this->mModuleStyles, $source->getModuleStyles() );
+               $this->mJsConfigVars = self::mergeMap( $this->mJsConfigVars, $source->getJsConfigVars() );
+               $this->mMaxAdaptiveExpiry = min( $this->mMaxAdaptiveExpiry, $source->mMaxAdaptiveExpiry );
+
+               // "noindex" always wins!
+               if ( $this->mIndexPolicy === 'noindex' || $source->mIndexPolicy === 'noindex' ) {
+                       $this->mIndexPolicy = 'noindex';
+               } elseif ( $this->mIndexPolicy !== 'index' ) {
+                       $this->mIndexPolicy = $source->mIndexPolicy;
+               }
+
+               // Skin control
+               $this->mNewSection = $this->mNewSection || $source->getNewSection();
+               $this->mHideNewSection = $this->mHideNewSection || $source->getHideNewSection();
+               $this->mNoGallery = $this->mNoGallery || $source->getNoGallery();
+               $this->mEnableOOUI = $this->mEnableOOUI || $source->getEnableOOUI();
+               $this->mPreventClickjacking = $this->mPreventClickjacking || $source->preventClickjacking();
+
+               // TODO: we'll have to be smarter about this!
+               $this->mSections = array_merge( $this->mSections, $source->getSections() );
+               $this->mTOCHTML = $this->mTOCHTML . $source->mTOCHTML;
+
+               // XXX: we don't want to concatenate title text, so first write wins.
+               // We should use the first *modified* title text, but we don't have the original to check.
+               if ( $this->mTitleText === null || $this->mTitleText === '' ) {
+                       $this->mTitleText = $source->mTitleText;
+               }
+
+               // class names are stored in array keys
+               $this->mWrapperDivClasses = self::mergeMap(
+                       $this->mWrapperDivClasses,
+                       $source->mWrapperDivClasses
+               );
+
+               // NOTE: last write wins, same as within one ParserOutput
+               $this->mIndicators = self::mergeMap( $this->mIndicators, $source->getIndicators() );
+
+               // NOTE: include extension data in "tracking meta data" as well as "html meta data"!
+               // TODO: add a $mergeStrategy parameter to setExtensionData to allow different
+               // kinds of extension data to be merged in different ways.
+               $this->mExtensionData = self::mergeMap(
+                       $this->mExtensionData,
+                       $source->mExtensionData
+               );
+       }
+
+       /**
+        * Merges dependency tracking metadata such as backlinks, images used, and extension data
+        * from $source into this ParserOutput. This allows dependency tracking to be done for the
+        * combined output of multiple content slots.
+        *
+        * @param ParserOutput $source
+        */
+       public function mergeTrackingMetaDataFrom( ParserOutput $source ) {
+               $this->mLanguageLinks = self::mergeList( $this->mLanguageLinks, $source->getLanguageLinks() );
+               $this->mCategories = self::mergeMap( $this->mCategories, $source->getCategories() );
+               $this->mLinks = self::merge2D( $this->mLinks, $source->getLinks() );
+               $this->mTemplates = self::merge2D( $this->mTemplates, $source->getTemplates() );
+               $this->mTemplateIds = self::merge2D( $this->mTemplateIds, $source->getTemplateIds() );
+               $this->mImages = self::mergeMap( $this->mImages, $source->getImages() );
+               $this->mFileSearchOptions = self::mergeMap(
+                       $this->mFileSearchOptions,
+                       $source->getFileSearchOptions()
+               );
+               $this->mExternalLinks = self::mergeMap( $this->mExternalLinks, $source->getExternalLinks() );
+               $this->mInterwikiLinks = self::merge2D(
+                       $this->mInterwikiLinks,
+                       $source->getInterwikiLinks()
+               );
+
+               // TODO: add a $mergeStrategy parameter to setProperty to allow different
+               // kinds of properties to be merged in different ways.
+               $this->mProperties = self::mergeMap( $this->mProperties, $source->getProperties() );
+
+               // NOTE: include extension data in "tracking meta data" as well as "html meta data"!
+               // TODO: add a $mergeStrategy parameter to setExtensionData to allow different
+               // kinds of extension data to be merged in different ways.
+               $this->mExtensionData = self::mergeMap(
+                       $this->mExtensionData,
+                       $source->mExtensionData
+               );
+       }
+
+       private static function mergeMixedList( array $a, array $b ) {
+               return array_unique( array_merge( $a, $b ), SORT_REGULAR );
+       }
+
+       private static function mergeList( array $a, array $b ) {
+               return array_values( array_unique( array_merge( $a, $b ), SORT_REGULAR ) );
+       }
+
+       private static function mergeMap( array $a, array $b ) {
+               return array_replace( $a, $b );
+       }
+
+       private static function merge2D( array $a, array $b ) {
+               $values = [];
+               $keys = array_merge( array_keys( $a ), array_keys( $b ) );
+
+               foreach ( $keys as $k ) {
+                       if ( empty( $a[$k] ) ) {
+                               $values[$k] = $b[$k];
+                       } elseif ( empty( $b[$k] ) ) {
+                               $values[$k] = $a[$k];
+                       } elseif ( is_array( $a[$k] ) && is_array( $b[$k] ) ) {
+                               $values[$k] = array_replace( $a[$k], $b[$k] );
+                       } else {
+                               $values[$k] = $b[$k];
+                       }
+               }
+
+               return $values;
+       }
+
+       private static function useEachMinValue( array $a, array $b ) {
+               $values = [];
+               $keys = array_merge( array_keys( $a ), array_keys( $b ) );
+
+               foreach ( $keys as $k ) {
+                       if ( is_array( $a[$k] ?? null ) && is_array( $b[$k] ?? null ) ) {
+                               $values[$k] = self::useEachMinValue( $a[$k], $b[$k] );
+                       } else {
+                               $values[$k] = self::useMinValue( $a[$k] ?? null, $b[$k] ?? null );
+                       }
+               }
+
+               return $values;
+       }
+
+       private static function useMinValue( $a, $b ) {
+               if ( $a === null ) {
+                       return $b;
+               }
+
+               if ( $b === null ) {
+                       return $a;
+               }
+
+               return min( $a, $b );
+       }
+
+       private static function useMaxValue( $a, $b ) {
+               if ( $a === null ) {
+                       return $b;
+               }
+
+               if ( $b === null ) {
+                       return $a;
+               }
+
+               return max( $a, $b );
+       }
+
 }
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..a507ad3 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',
                                        [
@@ -687,79 +689,77 @@ abstract class ResourceLoaderModule implements LoggerAwareInterface {
                $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
                $statStart = microtime( true );
 
-               // Only include properties that are relevant to this context (e.g. only=scripts)
-               // and that are non-empty (e.g. don't include "templates" for modules without
-               // templates). This helps prevent invalidating cache for all modules when new
-               // optional properties are introduced.
+               // This MUST build both scripts and styles, regardless of whether $context->getOnly()
+               // is 'scripts' or 'styles' because the result is used by getVersionHash which
+               // must be consistent regardles of the 'only' filter on the current request.
+               // Also, when introducing new module content resources (e.g. templates, headers),
+               // these should only be included in the array when they are non-empty so that
+               // existing modules not using them do not get their cache invalidated.
                $content = [];
 
                // Scripts
-               if ( $context->shouldIncludeScripts() ) {
-                       // If we are in debug mode, we'll want to return an array of URLs if possible
-                       // However, we can't do this if the module doesn't support it
-                       // We also can't do this if there is an only= parameter, because we have to give
-                       // the module a way to return a load.php URL without causing an infinite loop
-                       if ( $context->getDebug() && !$context->getOnly() && $this->supportsURLLoading() ) {
-                               $scripts = $this->getScriptURLsForDebug( $context );
-                       } else {
-                               $scripts = $this->getScript( $context );
-                               // Make the script safe to concatenate by making sure there is at least one
-                               // trailing new line at the end of the content. Previously, this looked for
-                               // a semi-colon instead, but that breaks concatenation if the semicolon
-                               // is inside a comment like "// foo();". Instead, simply use a
-                               // line break as separator which matches JavaScript native logic for implicitly
-                               // ending statements even if a semi-colon is missing.
-                               // Bugs: T29054, T162719.
-                               if ( is_string( $scripts )
-                                       && strlen( $scripts )
-                                       && substr( $scripts, -1 ) !== "\n"
-                               ) {
-                                       $scripts .= "\n";
-                               }
+               // If we are in debug mode, we'll want to return an array of URLs if possible
+               // However, we can't do this if the module doesn't support it.
+               // We also can't do this if there is an only= parameter, because we have to give
+               // the module a way to return a load.php URL without causing an infinite loop
+               if ( $context->getDebug() && !$context->getOnly() && $this->supportsURLLoading() ) {
+                       $scripts = $this->getScriptURLsForDebug( $context );
+               } else {
+                       $scripts = $this->getScript( $context );
+                       // Make the script safe to concatenate by making sure there is at least one
+                       // trailing new line at the end of the content. Previously, this looked for
+                       // a semi-colon instead, but that breaks concatenation if the semicolon
+                       // is inside a comment like "// foo();". Instead, simply use a
+                       // line break as separator which matches JavaScript native logic for implicitly
+                       // ending statements even if a semi-colon is missing.
+                       // Bugs: T29054, T162719.
+                       if ( is_string( $scripts )
+                               && strlen( $scripts )
+                               && substr( $scripts, -1 ) !== "\n"
+                       ) {
+                               $scripts .= "\n";
                        }
-                       $content['scripts'] = $scripts;
                }
+               $content['scripts'] = $scripts;
 
                // Styles
-               if ( $context->shouldIncludeStyles() ) {
-                       $styles = [];
-                       // Don't create empty stylesheets like [ '' => '' ] for modules
-                       // that don't *have* any stylesheets (T40024).
-                       $stylePairs = $this->getStyles( $context );
-                       if ( count( $stylePairs ) ) {
-                               // If we are in debug mode without &only= set, we'll want to return an array of URLs
-                               // See comment near shouldIncludeScripts() for more details
-                               if ( $context->getDebug() && !$context->getOnly() && $this->supportsURLLoading() ) {
-                                       $styles = [
-                                               'url' => $this->getStyleURLsForDebug( $context )
-                                       ];
-                               } else {
-                                       // Minify CSS before embedding in mw.loader.implement call
-                                       // (unless in debug mode)
-                                       if ( !$context->getDebug() ) {
-                                               foreach ( $stylePairs as $media => $style ) {
-                                                       // Can be either a string or an array of strings.
-                                                       if ( is_array( $style ) ) {
-                                                               $stylePairs[$media] = [];
-                                                               foreach ( $style as $cssText ) {
-                                                                       if ( is_string( $cssText ) ) {
-                                                                               $stylePairs[$media][] =
-                                                                                       ResourceLoader::filter( 'minify-css', $cssText );
-                                                                       }
+               $styles = [];
+               // Don't create empty stylesheets like [ '' => '' ] for modules
+               // that don't *have* any stylesheets (T40024).
+               $stylePairs = $this->getStyles( $context );
+               if ( count( $stylePairs ) ) {
+                       // If we are in debug mode without &only= set, we'll want to return an array of URLs
+                       // See comment near shouldIncludeScripts() for more details
+                       if ( $context->getDebug() && !$context->getOnly() && $this->supportsURLLoading() ) {
+                               $styles = [
+                                       'url' => $this->getStyleURLsForDebug( $context )
+                               ];
+                       } else {
+                               // Minify CSS before embedding in mw.loader.implement call
+                               // (unless in debug mode)
+                               if ( !$context->getDebug() ) {
+                                       foreach ( $stylePairs as $media => $style ) {
+                                               // Can be either a string or an array of strings.
+                                               if ( is_array( $style ) ) {
+                                                       $stylePairs[$media] = [];
+                                                       foreach ( $style as $cssText ) {
+                                                               if ( is_string( $cssText ) ) {
+                                                                       $stylePairs[$media][] =
+                                                                               ResourceLoader::filter( 'minify-css', $cssText );
                                                                }
-                                                       } elseif ( is_string( $style ) ) {
-                                                               $stylePairs[$media] = ResourceLoader::filter( 'minify-css', $style );
                                                        }
+                                               } elseif ( is_string( $style ) ) {
+                                                       $stylePairs[$media] = ResourceLoader::filter( 'minify-css', $style );
                                                }
                                        }
-                                       // Wrap styles into @media groups as needed and flatten into a numerical array
-                                       $styles = [
-                                               'css' => $rl->makeCombinedStyles( $stylePairs )
-                                       ];
                                }
+                               // Wrap styles into @media groups as needed and flatten into a numerical array
+                               $styles = [
+                                       'css' => $rl->makeCombinedStyles( $stylePairs )
+                               ];
                        }
-                       $content['styles'] = $styles;
                }
+               $content['styles'] = $styles;
 
                // Messages
                $blob = $this->getMessageBlob( $context );
@@ -803,22 +803,12 @@ abstract class ResourceLoaderModule implements LoggerAwareInterface {
         * @return string Hash (should use ResourceLoader::makeHash)
         */
        public function getVersionHash( ResourceLoaderContext $context ) {
-               // The startup module produces a manifest with versions representing the entire module.
-               // Typically, the request for the startup module itself has only=scripts. That must apply
-               // only to the startup module content, and not to the module version computed here.
-               $context = new DerivativeResourceLoaderContext( $context );
-               $context->setModules( [] );
-               // Version hash must cover all resources, regardless of startup request itself.
-               $context->setOnly( null );
-               // Compute version hash based on content, not debug urls.
-               $context->setDebug( false );
-
                // Cache this somewhat expensive operation. Especially because some classes
                // (e.g. startup module) iterate more than once over all modules to get versions.
                $contextHash = $context->getHash();
                if ( !array_key_exists( $contextHash, $this->versionHash ) ) {
                        if ( $this->enableModuleContentVersion() ) {
-                               // Detect changes directly
+                               // Detect changes directly by hashing the module contents.
                                $str = json_encode( $this->getModuleContent( $context ) );
                        } else {
                                // Infer changes based on definition and other metrics
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 eab09a1..cbdd59d 100644 (file)
@@ -4194,8 +4194,8 @@ class Language {
        /**
         * convert text to different variants of a language.
         *
-        * @param string $text
-        * @return string
+        * @param string $text Content that has been already escaped for use in HTML
+        * @return string HTML
         */
        public function convert( $text ) {
                return $this->mConverter->convert( $text );
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..1ae3dee 100644 (file)
        "backend-fail-hashes": "Немагчыма атрымаць хэшы файлаў для параўнаньня.",
        "backend-fail-notsame": "Ужо існуе неідэнтычны файл «$1».",
        "backend-fail-invalidpath": "«$1» не зьяўляецца слушным шляхам да сховішча.",
-       "backend-fail-delete": "Немагчыма выдаліць файл $1.",
-       "backend-fail-describe": "Не атрымалася зьмяніць мэтазьвесткі для файла «$1».",
+       "backend-fail-delete": "Немагчыма выдаліць файл «$1».",
+       "backend-fail-describe": "Не атрымалася зьмяніць мэтазьвесткі для файлу «$1».",
        "backend-fail-alreadyexists": "Файл $1 ужо існуе.",
        "backend-fail-store": "Немагчыма захаваць файл $1 у $2.",
        "backend-fail-copy": "Немагчыма скапіяваць файл $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..2222d42 100644 (file)
        "feed-atom": "অ্যাটম",
        "red-link-title": "$1 (পাতার অস্তিত্ব নেই)",
        "sort-descending": "উল্টো বর্ণক্রমে সাজান",
-       "sort-ascending": "বরà§\8dণানুক্রমে সাজান",
+       "sort-ascending": "à¦\8aরà§\8dদà§\8dধানুক্রমে সাজান",
        "nstab-main": "পাতা",
        "nstab-user": "ব্যবহারকারীর পাতা",
        "nstab-media": "মিডিয়া পাতা",
        "ns-specialprotected": "বিশেষ পাতাসমূহ সম্পাদনা করা যাবে না।",
        "titleprotected": "[[User:$1|$1]] কর্তৃক এই শিরোনামটি সৃষ্টি করা থেকে সুরক্ষিত করা হয়েছে। কারণ: <em>$2</em>।",
        "filereadonlyerror": "\"$1\" ফাইলটিকে পরিবর্তন করা সম্ভব হচ্ছে না কারণ \"$2\" ফাইল সংগ্রহশালাটি শুধুমাত্র-পঠন মোডে আছে।\n\nসিস্টেম প্রশাসক যিনি ফাইলটি অবরুদ্ধ করেছেন তিনি এই ব্যাখ্যা দিয়েছেন: \"$3\"।",
+       "invalidtitle": "ভুল শিরোনাম",
        "invalidtitle-knownnamespace": "অবৈধ শিরোনাম, যেখানে নামস্থান \"$2\" এবং লেখা হয়েছে \"$3\"",
        "invalidtitle-unknownnamespace": "অবৈধ শিরোনাম, যেখানে ব্যবহৃত হয়েছে অপরিচিত নামস্থান সংখ্যা $1 এবং লেখা হয়েছে \"$2\"",
        "exception-nologin": "প্রবেশ করেন নি",
        "filehist-filesize": "ফাইলের আকার",
        "filehist-comment": "মন্তব্য",
        "imagelinks": "ফাইলের ব্যবহার",
-       "linkstoimage": "নিà¦\9aà§\87র {{PLURAL:$1|à¦\9fি à¦ªà¦¾à¦¤à¦¾|$1à¦\9fি à¦ªà¦¾à¦¤à¦¾}} à¦¥à§\87à¦\95à§\87 à¦\8fà¦\87 à¦«à¦¾à¦\87লà§\87 à¦¸à¦\82যà§\8bà¦\97 à¦\86à¦\9bে:",
+       "linkstoimage": "নিমà§\8dনলিà¦\96িত {{PLURAL:$1|পাতাà¦\9fি|$1à¦\9fি à¦ªà¦¾à¦¤à¦¾}} à¦\8fà¦\87 à¦«à¦¾à¦\87ল à¦¬à§\8dযবহার à¦\95রে:",
        "linkstoimage-more": "এই ফাইলের সাথে $1টির বেশি {{PLURAL:$1|পাতার লিংক}} রয়েছে।\nনিচের তালিকায় ফাইলের সাথে যুক্ত {{PLURAL:$1|প্রথম পাতাটির লিংক|প্রথম $1টি পাতার লিংক}} দেখানো হচ্চে।\nএছাড়া একটি [[Special:WhatLinksHere/$2|পূর্ণাঙ্গ তালিকাও]] রয়েছে।",
        "nolinkstoimage": "এই ফাইলে সংযোগ করে এমন কোন পাতা নেই।",
        "morelinkstoimage": "এই ফাইলের [[Special:WhatLinksHere/$1|আরও লিঙ্ক]] দেখাও।",
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 6e5cfb8..66a0816 100644 (file)
        "ns-specialprotected": "Ni ellir golygu tudalennau arbennig.",
        "titleprotected": "Diogelwyd y teitl hwn rhag ei greu gan [[User:$1|$1]].\nRhoddwyd y rheswm hwn - <em>$2</em>.",
        "filereadonlyerror": "Nid oes modd newid y ffeil \"$1\" gan fod ffeil \"$2\" yn y modd 'darllen-yn-unig'.\n\nY rheswm a roddwyd gan y gweinyddwr a roddodd y ffeil dan glo yw \"''$3''\".",
+       "invalidtitle": "Teitl annilys",
        "invalidtitle-knownnamespace": "Teitl annilys o'r enw \"$3\" yn y parth \"$2\"",
        "invalidtitle-unknownnamespace": "Teitl annilys ag iddi'r rhif parth anhysbys $1 a'r enw \"$2\"",
        "exception-nologin": "Nid ydych wedi mewngofnodi",
        "wrongpasswordempty": "Roedd y cyfrinair yn wag. Rhowch gynnig arall arni.",
        "passwordtooshort": "Mae'n rhaid fod gan gyfrinair o leia $1 {{PLURAL:$1|nod}}.",
        "passwordtoolong": "Ni chaiff cyfrinair fod yn hirach na {{PLURAL:$1|1 llythyren|$1 llythyren}}.",
-       "passwordtoopopular": "Chewch chi ddim defnyddio cyfreinair rhy syml, rhy gyffredin. Dewisiwch un unigryw!",
+       "passwordtoopopular": "Chewch chi ddim defnyddio cyfrineiriau cyffredin. Dewisiwch un unigryw a gwahanol!",
        "password-name-match": "Rhaid i'ch cyfrinair a'ch enw defnyddiwr fod yn wahanol i'w gilydd.",
        "password-login-forbidden": "Gwaharddwyd defnyddio'r enw defnyddiwr a'r cyfrinair hwn.",
        "mailmypassword": "Ailosoder y cyfrinair",
        "passwordremindertitle": "Hysbysu cyfrinair dros dro newydd ar gyfer {{SITENAME}}",
-       "passwordremindertext": "Mae rhywun (chi mwy na thebyg, o'r cyfeiriad IP $1) wedi gofyn i ni anfon cyfrinair newydd atoch ar gyfer {{SITENAME}} ($4).\nMae cyfrinair dros dro, sef \"$3\", wedi ei greu ar gyfer y defnyddiwr \"$2\". Os mai dyma oedd y bwriad, yna dylech fewngofnodi a'i newid cyn gynted â phosib. Daw'ch cyfrinair dros dro i ben ymhen {{PLURAL:$5||diwrnod|deuddydd|tridiau|$5 diwrnod|$5 diwrnod}}.\n\nOs mai rhywun arall a holodd am y cyfrinair, ynteu eich bod wedi cofio'r hen gyfrinair, ac nac ydych am newid y cyfrinair, rhydd i chi anwybyddu'r neges hon a pharhau i ddefnyddio'r cyfrinair gwreiddiol.",
+       "passwordremindertext": "Mae rhywun (chi mwy na thebyg, o'r cyfeiriad IP $1) wedi gofyn i ni anfon cyfrinair newydd atoch ar gyfer {{SITENAME}} ($4).\nMae cyfrinair dros dro, sef \"$3\", wedi ei greu ar gyfer y defnyddiwr \"$2\". Os mai dyma oedd y bwriad, yna dylech fewngofnodi a'i newid cyn gynted â phosib. Daw'ch cyfrinair dros dro i ben ymhen {{PLURAL:$5||diwrnod}}.\n\nOs mai rhywun arall a holodd am y cyfrinair, ynteu eich bod wedi cofio'r hen gyfrinair, ac nac ydych am newid y cyfrinair, rhydd i chi anwybyddu'r neges hon a pharhau i ddefnyddio'r cyfrinair gwreiddiol.",
        "noemail": "Does dim cyfeiriad e-bost yng nghofnodion y defnyddiwr '$1'.",
        "noemailcreate": "Mae'n rhaid i chi gynnig cyfeiriad e-bost dilys",
        "passwordsent": "Mae cyfrinair newydd wedi'i ddanfon at gyfeiriad e-bost cofrestredig \"$1\". Mewngofnodwch eto ar ôl i chi dderbyn y cyfrinair, os gwelwch yn dda.",
        "page_last": "olaf",
        "histlegend": "Cymharu dau fersiwn: marciwch y cylchoedd ar y ddau fersiwn i'w cymharu, yna pwyswch ar 'return' neu'r botwm 'Cymharer y fersiynau dewisedig'.<br />\nEglurhad: '''({{int:cur}})''' = gwahaniaethau rhyngddo a'r fersiwn cyfredol,\n'''({{int:last}})''' = gwahaniaethau rhyngddo a'r fersiwn cynt, '''({{int:minoreditletter}})''' = golygiad bychan",
        "history-fieldset-title": "Chwilio drwy'r hanes",
-       "history-show-deleted": "Yr ddalen a adolygwyd yn unig a ddilëwyd",
+       "history-show-deleted": "Dangos y rhai a ddilëwyd yn unig",
        "histfirst": "cynharaf",
        "histlast": "diweddaraf",
        "historysize": "({{PLURAL:$1|$1 beit|$1 beit|$1 feit|$1 beit|$1 beit|$1 beit}})",
        "sp-contributions-blocked-notice-anon": "Mae'r cyfeiriad IP hwn wedi'i rwystro ar hyn o bryd.\nMae'r cofnod diweddaraf yn y lòg blocio i'w weld isod:",
        "sp-contributions-search": "Chwilio am gyfraniadau",
        "sp-contributions-username": "Cyfeiriad IP neu enw defnyddiwr:",
-       "sp-contributions-toponly": "Dangos golygiadau sy'n olygiadau diweddaraf yn unig",
-       "sp-contributions-newonly": "Dangos y golygiadau hynny sy'n dechrau tudalen yn unig",
+       "sp-contributions-toponly": "Dangos y golygiadau diweddaraf yn unig",
+       "sp-contributions-newonly": "Dangos dalennau newydd yn unig",
        "sp-contributions-hideminor": "Cuddio golygiadau bach",
        "sp-contributions-submit": "Chwilier",
        "whatlinkshere": "Beth sy'n cysylltu yma",
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 79e62fe..a97e832 100644 (file)
        "feb": "feb",
        "mar": "mrt",
        "apr": "apr",
-       "may": "maa",
+       "may": "mai",
        "jun": "jun",
        "jul": "jul",
        "aug": "aug",
        "otherlanguages": "In oare talen",
        "redirectedfrom": "(Trochwiisd fan \"$1\")",
        "redirectpagesub": "Trochferwiis-side",
-       "lastmodifiedat": "Lêste kear bewurke op $2, $1.",
+       "lastmodifiedat": "Dizze side is it lêst bewurke op $1 om $2.",
        "viewcount": "Disse side is {{PLURAL:$1|ienris|$1 kear}} iepenslein.",
        "protectedpage": "Skoattele side",
        "jumpto": "Gean nei:",
        "laggedslavemode": "<strong>Warskôging:</strong> Mûglik binne resinte bewurkings noch net trochfierd.",
        "readonly": "Databank is 'Net-skriuwe'.",
        "enterlockreason": "Skriuw wêrom de databank 'net-skriuwe' makke is, en hoenear't men wêr nei alle gedachten wer skriuwe kin.",
-       "readonlytext": "De {{SITENAME}} databank is ôfsletten foar nije siden en oare wizigings,\nnei alle gedachten is it foar ûnderhâld, en kinne jo der letter gewoan wer brûk fan meitsje.\nDe behearder hat dizze útlis jûn:\n<p>$1</p>",
+       "readonlytext": "De databank is op it stuit skoattele foar nije ynbring en oare wizigings, faaks foar gewoan databankûnderhâld, wêrnei't dat wer normaal wurkje sil.\n\nDe systeembehearder dy't it skoattele joech dizze taljochting: $1",
        "missing-article": "Yn de database is gjin ynhâld oantroffen foar de side \"$1\" dy't der wol wêze moatte soe ($2).\n\nDat kin foarkomme as Jo in ferâldere ferwizing nei it ferskil tusken twa ferzjes fan in side folgje of in ferzje opfreegje dy't wiske is.\n\nAs dat net sa is, hawwe Jo faaks in fout yn 'e software fûn.\nMeitsje dêr melding fan by in [[Special:ListUsers/sysop|systeembehearder]] fan {{SITENAME}} en neam dêrby de URL fan dizze side.",
        "missingarticle-rev": "(ferzjenûmer: $1)",
        "missingarticle-diff": "(Feroaring: $1, $2)",
        "nocookiesforlogin": "{{int:nocookieslogin}}",
        "noname": "Jo moatte in meidognamme opjaan.",
        "loginsuccesstitle": "Oanmelden slagge.",
-       "loginsuccess": "<strong>Jo binne no oanmelden op de {{SITENAME}} as: \"$1.\"</strong>",
+       "loginsuccess": "<strong>Jo binne no oanmeld op {{SITENAME}} as \"$1\".</strong>",
        "nosuchuser": "Der is gjin meidogger \"$1\".\nKontrolearje de stavering, of [[Special:CreateAccount|meitsje in nije meidogger oan]].",
        "nosuchusershort": "Der is gjin meidogger mei de namme \"$1\". It is goed skreaun?",
        "nouserspecified": "Jo moatte in brûkersnamme opjaan.",
        "passwordtooshort": "Wachtwurden moatte op syn minst {{PLURAL:$1|1 teken|$1 tekens}} lang wêze.",
        "password-name-match": "Jo wachtwurd mei net itselde wêze as jo meidoggersnamme.",
        "mailmypassword": "E-mail my in nij wachtwurd.",
-       "passwordremindertitle": "Nij wachtwurd foar de {{SITENAME}}",
+       "passwordremindertitle": "Nij tydlik wachtwurd foar {{SITENAME}}",
        "passwordremindertext": "Immen (nei alle gedachten jo, fan ynternetadres $1) had in nij wachtwurd\nfoar {{SITENAME}} ($4) oanfrege. Der is in tydlik wachtwurd foar meidogger\n\"$2\"  makke en ynstelt as \"$3\". As dat jo bedoeling wie, melde jo jo dan\nno oan en kies in nij wachtwurd. Dyn tydlik wachtwurd komt yn {{PLURAL:$5|ien dei|$5 dagen}} te ferfallen.\nDer is in tydlik wachtwurd oanmakke foar brûker \"$2\": \"$3\".\n\nAs immen oars as jo dit fersyk dien hat of at it wachtwurd jo tuskentiidsk wer yn 't sin kommen is en\njo it net langer feroarje wolle, dan kinne jo dit berjocht ferjitte en\nfierdergean mei it brûken fan jo âlde wachtwurd.",
        "noemail": "Der is gjin e-postadres foar meidogger \"$1\".",
        "passwordsent": "In nij wachtwurd is tastjoerd oan it e-postadres foar \"$1\". Jo kinne jo wer oanmelde as jo it wachtwurd ûntfongen hawwe.",
        "searchall": "alle",
        "showingresults": "{{PLURAL:$1|<strong>1</strong> resultaat|<strong>$1</strong> resultaten}} fan #<strong>$2</strong> ôf.",
        "search-showingresults": "{{PLURAL:$4|Resultaat <strong>$1</strong> fan <strong>$3</strong>|Resultaten <strong>$1 - $2</strong> fan <strong>$3</strong>}}",
-       "search-nonefound": "Der binne gjin resultaten foar Jo sykopdracht.",
+       "search-nonefound": "Der binne gjin resultaten foar jo sykopdracht.",
        "powersearch-legend": "Sykje",
-       "powersearch-ns": "Sykje op nammeromten:",
+       "powersearch-ns": "Sykje yn nammeromten:",
        "powersearch-togglelabel": "Oantikje:",
        "powersearch-toggleall": "Alle",
        "powersearch-togglenone": "Gjin",
+       "powersearch-remember": "Seleksje ûnthâlde foar sykopdrachten yn 'e takomst",
        "search-external": "Utwindich sykje",
        "searchdisabled": "<p>Op it stuit stiet it trochsykjen fan tekst út omdat dizze funksje tefolle kompjûterkapasiteit ferget. As we nije apparatuer krije, en dy is ûnderweis, dan wurdt dizze funksje wer aktyf. Oant salang kinne jo sykje fia Google:</p>",
        "preferences": "Foarkarren",
        "enhancedrc-history": "skiednis",
        "recentchanges": "Koartlyn feroare",
        "recentchanges-legend": "Opsjes foar resinte feroarings",
-       "recentchanges-summary": "De lêste feroarings fan de {{SITENAME}}.",
+       "recentchanges-summary": "Folgje de lêste feroarings oan 'e wiki op dizze side.",
        "recentchanges-feed-description": "Mei dizze feed kinne jo de nijste feroarings yn dizze wiki besjen.",
        "recentchanges-label-newpage": "Mei dizze wiziging is in nije side makke",
        "recentchanges-label-minor": "Dit is in tekstwiziging",
        "lockbtn": "Meitsje de database 'Net-skriuwe'",
        "unlockbtn": "Meitsje de databank skriuwber",
        "locknoconfirm": "Jo hawwe jo hanneling net befêstige.",
-       "lockdbsuccesssub": "Databank is 'Net-skriuwe'",
-       "unlockdbsuccesssub": "Database is skriuwber",
-       "lockdbsuccesstext": "De {{SITENAME}} databank is 'Net-skriuwe' makke.\n<br />Tink derom en meitsje de databank skriuwber as jo ûnderhâld ree is.",
-       "unlockdbsuccesstext": "De {{SITENAME}} databank is skriuwber makke.",
+       "lockdbsuccesssub": "De databank is skoattele",
+       "unlockdbsuccesssub": "De databank is ûntskoattele",
+       "lockdbsuccesstext": "De databank is skoattele.<br />\nTink derom en [[Special:UnlockDB|ûntskoattelje]] as jo ûnderhâld ree is.",
+       "unlockdbsuccesstext": "De databank is ûntskoattele.",
        "lockedbyandtime": "(troch {{GENDER:$1|$1}} op $2 om $3)",
        "move-page": "\"$1\" omneame",
        "move-page-legend": "Side omneame",
index 261420e..158c317 100644 (file)
        "userlogin-yourname": "Non di itilizatò",
        "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",
+       "yourpassword": "Modipas :",
+       "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",
+       "yourpasswordagain": "Konfirmen modipas-a :",
+       "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é",
        "passwordtoopopular": "Mo di pas ki tròp kouran pa pouvé fika itilizé. Souplé, chwézi roun mo di pas pli difisil à douviné.",
        "password-name-match": "Zòt mo di pas divèt fika diféran di zòt non d'itilizatò.",
        "password-login-forbidden": "Itilizasyon-an di sa non d'itilizatò oben di sa mo di pas té entèrdit.",
-       "mailmypassword": "Réyinisyalizé mo di pas",
+       "mailmypassword": "Réyinisyalizé modipas-a",
        "passwordremindertitle": "Nouvèl mo di pas tanporèr pou {{SITENAME}}",
        "passwordremindertext": "Tchèk moun (dipi adrès IP-a $1) doumandé roun modipas nòv pou {{SITENAME}} ($4). Oun modipas tanporèr pou itilizatò-a\n« $2 » fin kréyé é sa « $3 ». Si sala té zòt entansyon,\nzòt divèt konnègté zòt kò é chwézi roun modipas nòv.\nZòt modipas tanporèr ké èspiré annan $5 jou{{PLURAL:}}.\n\nSi zòt pa lotò di sa doumann, oben si zòt ka souvni zòt kò atchwèlman di zòt modipas é zòt pli ka swété an chanjé, zòt pouvé ignoré sa mésaj é kontinwé di itilizé zòt ansyen modipas.",
        "noemail": "Pyès adrès di kouryé té anréjistré pou itilizat{{GENDER:$1|ò|ris}}-a « $1 ».",
        "resetpass_announce": "Pou tèrminé zòt enskripsyon, zòt divèt fourni roun mo di pas nòv.",
        "resetpass_text": "<!-- Ajouté tègs-a isi -->",
        "resetpass_header": "Chanjé mo di pas di kont",
-       "oldpassword": "Ansyen mo di pas :",
-       "newpassword": "Mo di pas nòv :",
+       "oldpassword": "Ansyen modipas :",
+       "newpassword": "Nouvèl modipas :",
        "retypenew": "Konfirmé mo di pas nòv :",
        "resetpass_submit": "Chanjé modipas-a é konnègté so kò.",
        "changepassword-success": "Zòt mo di pas té modifyé !",
index 3c447f3..60b5f72 100644 (file)
        "revdelete-show-file-submit": "Sì",
        "revdelete-selected-text": "{{PLURAL:$1|Versione selezionata|Versioni selezionate}} di [[:$2]]:",
        "revdelete-selected-file": "{{PLURAL:$1|Versione selezionata|Versioni selezionate}} del file [[:$2]]:",
-       "logdelete-selected": "{{PLURAL:$1|Evento del registro selezionato|Eventi del registro selezionato}}:",
+       "logdelete-selected": "{{PLURAL:$1|Evento del registro selezionato|Eventi del registro selezionati}}:",
        "revdelete-text-text": "Le versioni cancellate appariranno ancora nella cronologia della pagina, ma parte del loro contenuto sarà inaccessibile al pubblico.",
        "revdelete-text-file": "Le versioni di file cancellati appariranno ancora nella cronologia del file, ma parti del loro contenuto sarà inaccessibile al pubblico.",
        "logdelete-text": "Gli eventi cancellati appariranno ancora nei registri, ma parti del loro contenuto sarà inaccessibile al pubblico.",
index 3849fa2..8356723 100644 (file)
        "aboutpage": "Project:Informaçioìn",
        "copyright": "O contegno o l'è disponibile in base a-a liçensa $1, se no diversamente speçificou.",
        "copyrightpage": "{{ns:project}}:Driti d'autô",
-       "currentevents": "Atualitæ",
-       "currentevents-url": "Project:Atualitæ",
+       "currentevents": "Atoalitæ",
+       "currentevents-url": "Project:Atoalitæ",
        "disclaimers": "Averténse",
        "disclaimerpage": "Project:Avertense generâli",
        "edithelp": "Agiùtto",
        "recentchangeslinked-feed": "Cangiamenti correlæ",
        "recentchangeslinked-toolbox": "Cangiaménti corelæ",
        "recentchangeslinked-title": "Modiffiche correlæ a \"$1\"",
-       "recentchangeslinked-summary": "Scrivi o nomme de 'na pagina pe vèdde i cangiamenti a-e pagine coleghæ a ò da quésta pagina. (Pe védde i menbri de 'na catgorîa, scrive Category:Nomme da catgorîa). Cangiamenti e-e pagine insce [[Special:Watchlist|your Watchlist]] són in <strong>bold</strong>.",
+       "recentchangeslinked-summary": "Scrîvi o nómme de 'na pàgina pe védde e modìfiche a-e pàgine che són colegòu o che colégan a quélle pàgine. (Pe védde i ménbri de 'na catgorîa, scrive {{ns:category}}:Nómme da catgorîa). E moìfiche a-e pàgine in sce [[Special:Watchlist|òservæ speciâli]] són evidençiòu in <strong>grascétto</strong>.",
        "recentchangeslinked-page": "Nómme da pàgina:",
        "recentchangeslinked-to": "Fanni védde sôlo i cangiaménti a-e pàggine conligæ a-a pàggina specificâ",
        "recentchanges-page-added-to-category": "[[:$1]] azonto a-a categoria",
index 584a169..908f0cb 100644 (file)
        "edit-error-long": "Грешки:\n\n$1",
        "revid": "преработка $1",
        "pageid": "назнака на страницата $1",
-       "interfaceadmin-info": "$1\n\nÐ\94озволиÑ\82е Ð·Ð° Ñ\83Ñ\80едÑ\83ваÑ\9aе Ð½Ð° Ð¿Ð¾Ð´Ð°Ñ\82оÑ\82еки Ð¾Ð´ Ñ\82иповиÑ\82е CSS/JS/JSON Ð½Ð¸Ð· Ñ\86ело Ð²Ð¸ÐºÐ¸ Ð½ÐµÐ¾Ð´Ð°Ð¼Ð½Ð° Ð±ÐµÐ° Ð¾Ð´Ð²Ð¾ÐµÐ½Ð¸ Ð¾Ð´ Ð¿Ñ\80авоÑ\82о <code>editinterface</code>. Ð\94околкÑ\83 Ð½Ðµ Ð²Ð¸ Ðµ Ñ\98аÑ\81но Ð·Ð¾Ñ\88Ñ\82о Ð²Ð¸ Ñ\81е Ð¿Ð¾ÐºÐ°Ð¶Ñ\83ва Ð¾Ð²Ð°Ð° Ð³Ñ\80еÑ\88ка, Ð¿Ð¾Ð³Ð»ÐµÐ´Ð°Ñ\98Ñ\82е Ð½а [[mw:MediaWiki_1.32/interface-admin]].",
+       "interfaceadmin-info": "$1\n\nÐ\94озволаÑ\82а Ð·Ð° Ñ\83Ñ\80едÑ\83ваÑ\9aе Ð½Ð° Ð¿Ð¾Ð´Ð°Ñ\82оÑ\82еки Ð¾Ð´ Ñ\82иповиÑ\82е CSS/JS/JSON Ð½Ð¸Ð· Ñ\86ело Ð²Ð¸ÐºÐ¸ Ð½ÐµÐ¾Ð´Ð°Ð¼Ð½Ð° Ðµ Ð¾Ð´Ð²Ð¾ÐµÐ½Ð¾ Ð¾Ð´ Ð¿Ñ\80авоÑ\82о <code>editinterface</code>. Ð\90ко Ð½Ðµ Ð²Ð¸ ÐµÑ\98 Ð°Ñ\81но Ð·Ð¾Ñ\88Ñ\82о Ñ\98а Ð´Ð¾Ð±Ð¸Ð²Ð°Ñ\82е Ð¾Ð²Ð°Ð° Ð³Ñ\80еÑ\88ка, Ð¿Ð¾Ð³Ð»ÐµÐ´Ð°Ñ\98Ñ\82е Ñ\98а Ñ\81Ñ\82Ñ\80аниÑ\86аÑ\82а [[mw:MediaWiki_1.32/interface-admin]].",
        "rawhtml-notallowed": "&lt;html&gt;-ознаките не може да се користат вон нормалните страници.",
        "gotointerwiki": "Го напуштате {{SITENAME}}",
        "gotointerwiki-invalid": "Укажаниот наслов е неважечки.",
index 98c7062..774c4e2 100644 (file)
        "ns-specialprotected": "പ്രത്യേകം എന്ന നാമമേഖലയിൽ വരുന്ന താളുകൾ തിരുത്താനാവുന്നവയല്ല.",
        "titleprotected": "[[User:$1|$1]] എന്ന ഉപയോക്താവ് ഈ താൾ ഉണ്ടാക്കുന്നതു നിരോധിച്ചിരിക്കുന്നു.\n<em>$2</em> എന്നതാണു അതിനു കാണിച്ചിട്ടുള്ള കാരണം.",
        "filereadonlyerror": "പ്രമാണ ശേഖരണി \"$2\" ഇപ്പോൾ \"കാണൽ-മാത്രം\" വിധത്തിൽ ക്രമീകരിച്ചിരിക്കുന്നതിനാൽ \"$1\" എന്ന പ്രമാണത്തിൽ മാറ്റം വരുത്താനാകില്ല.\n\nബന്ധിച്ച സിസ്റ്റം കാര്യ‌നിർവാഹക(ൻ) നൽകിയിരിക്കുന്ന കാരണം \"''$3''\" എന്നാണ്.",
+       "invalidtitle": "അസാധുവായ തലക്കെട്ട്",
        "invalidtitle-knownnamespace": "നാമമേഖല \"$2\", എഴുത്ത് \"$3\" എന്നിവ ഉപയോഗിച്ചുള്ള അസാധുവായ തലക്കെട്ട്",
        "invalidtitle-unknownnamespace": "അപരിചിതമായ നാമമേഖലാ സംഖ്യ $1, എഴുത്ത് \"$2\" എന്നിവ ഉപയോഗിച്ചുള്ള അസാധുവായ തലക്കെട്ട്",
        "exception-nologin": "ലോഗിൻ ചെയ്തിട്ടില്ല",
        "createacct-email-ph": "താങ്കളുടെ ഇമെയിൽ വിലാസം നൽകുക",
        "createacct-another-email-ph": "ഇമെയിൽ വിലാസം നൽകുക",
        "createaccountmail": "തൽക്കാലം ക്രമരഹിതമായി സൃഷ്ടിച്ച ഒരു രഹസ്യവാക്ക് ഉപയോഗിക്കുകയും അത് തന്നിരിക്കുന്ന ഇമെയിൽ വിലാസത്തിലേക്കയക്കുകയും ചെയ്യുക",
+       "createaccountmail-help": "രഹസ്യവാക്ക് മനസ്സിലാക്കാതെ തന്നെ മറ്റൊരാൾക്ക് അംഗത്വം സൃഷ്ടിച്ച് നൽകാൻ ഉപയോഗിക്കാവുന്നതാണ്.",
        "createacct-realname": "ശരിയായ പേര് (നിർബന്ധമില്ല)",
        "createacct-reason": "കാരണം",
        "createacct-reason-ph": "താങ്കൾ എന്തുകൊണ്ടാണ് മറ്റൊരു അംഗത്വം എടുക്കുന്നത്",
+       "createacct-reason-help": "അംഗത്വസൃഷ്ടി രേഖയിൽ കാണിക്കുന്ന സന്ദേശം",
        "createacct-submit": "താങ്കളുടെ അംഗത്വം സൃഷ്ടിക്കുക",
        "createacct-another-submit": "അംഗത്വമെടുക്കുക",
        "createacct-continue-submit": "അംഗത്വം സൃഷ്ടിക്കുന്നത് തുടരുക",
index d727456..28d4714 100644 (file)
        "stub-threshold-disabled": "ꯌꯥꯍꯟꯗꯔꯗ",
        "timezonelegend": "ꯃꯇꯝꯒꯤ ꯃꯐꯝ:",
        "localtime": "ꯂꯩꯀꯥꯏꯒꯤ ꯃꯇꯝ:",
-       "timezoneregion-africa": "ꯑꯐꯔꯤꯀ",
+       "timezoneregion-africa": "ê¯\91ê¯\90꯭ê¯\94ꯤê¯\80",
        "timezoneregion-america": "ꯑꯃꯦꯔꯤꯀ",
        "timezoneregion-antarctica": "ꯑꯟꯇꯥꯔꯇꯤꯀ",
        "timezoneregion-arctic": "ꯑꯥꯔꯇꯤꯛ",
        "filehist-comment": "ꯑꯄꯥꯝꯕꯥ ꯐꯣꯡꯗꯣꯛ ꯎ",
        "imagelinks": "ꯐꯥꯏꯜꯒꯤ ꯁꯤꯖꯤꯟꯅꯐꯝ",
        "linkstoimage": "ꯃꯇꯨꯡ ꯏꯟꯕ {{PLURAL:$1|ꯂꯥꯃꯥꯏꯁꯤꯖꯤꯟꯅꯕ|$1ꯂꯥꯃꯥꯏ ꯁꯤꯖꯤꯟꯅꯕ}} ꯃꯁꯤꯒꯤ ꯐꯥꯏꯜ:",
+       "linkstoimage-more": "$1 ꯗꯒꯤ ꯍꯦꯟꯅ {{PLURAL:$1|ꯂꯃꯥꯏ ꯁꯤꯖꯤꯟꯅꯐꯝ|page use}} ꯃꯁꯤ ꯐꯥꯏꯜ ꯫\nThe following list shows the {{PLURAL:$1|ꯑꯍꯥꯟꯕ ꯂꯃꯥꯏ|first $1 pages}} that use this file only.\nA [[Special:WhatLinksHere/$2|ꯄꯔꯤꯡ ꯄꯨꯂꯞ]] ꯁꯤ ꯐꯪꯉꯦ ꯫",
        "nolinkstoimage": "ꯃꯁꯤꯒꯤ ꯐꯥꯏꯜ ꯁꯤ ꯁꯤꯖꯤꯟꯅꯕ ꯂꯥꯃꯥꯏꯁꯤꯡ ꯂꯩꯇꯦ ꯫",
+       "linkstoimage-redirect": "$1 (ꯐꯥꯏꯜ ꯱ꯗꯒꯤ ꯱ ꯗ ꯂꯥꯛꯍꯟꯕ) $2",
        "sharedupload-desc-here": "This file is from $1 and may be used by other projects.\nThe description on its [$2 file description page] there is shown below.",
        "filepage-nofile": "ꯃꯁꯤꯒꯤ ꯐꯥꯏꯜ ꯃꯃꯤꯡ ꯁꯤ ꯒꯥ ꯃꯥꯟꯅꯕ ꯂꯩꯇꯦ",
        "upload-disallowed-here": "ꯃꯁꯤꯒꯤ ꯐꯥꯏꯜꯁꯤ ꯅꯪꯅꯥ ꯑꯃꯨꯛ ꯍꯟꯅꯥ ꯏꯕꯥ ꯌꯥꯔꯣꯏ",
        "logentry-move-move": "$1 {{GENDER:$2|moved}} page $3 to $4",
        "logentry-newusers-create": "User account $1 was {{GENDER:$2|created}}",
        "logentry-upload-upload": "$1 {{GENDER:$2|uploaded}} $3",
+       "logentry-upload-overwrite": "$1 {{GENDER:$2|ꯊꯥꯒꯠꯂꯦ}} $3 ꯒꯤ ꯑꯅꯧꯕ ꯕꯔꯖꯟ",
        "searchsuggest-search": "ꯊꯤꯔꯣ",
        "duration-days": "$1 {{PLURAL:$1|ꯅꯨꯃꯤꯌ|ꯅꯨꯃꯤꯠꯁꯤꯡ}}",
        "randomrootpage": "ꯆꯥꯡ ꯅꯥꯏꯗꯕ ꯂꯥꯃꯥꯏꯒꯤ ꯃꯔꯥ"
index 50f6e19..8c25263 100644 (file)
@@ -26,7 +26,8 @@
                        "Muhdnurhidayat",
                        "Jeluang Terluang",
                        "Zulfadli51",
-                       "Fitoschido"
+                       "Fitoschido",
+                       "MNH48"
                ]
        },
        "tog-underline": "Garis bawah pautan:",
        "permissionserrorstext-withaction": "Anda tidak mempunyai keizinan untuk $2, atas {{PLURAL:$1|sebab|sebab-sebab}} berikut:",
        "contentmodelediterror": "Anda tidak boleh menyunting semakan ini kerana model kandungannya ialah <code>$1</code> padahal model kandungan semasa laman ini ialah <code>$2</code>.",
        "recreate-moveddeleted-warn": "'''Amaran: Anda sedang mencipta semula sebuah laman yang pernah dihapuskan.'''\n\nAnda harus mempertimbangkan perlunya menyunting laman ini.\nUntuk rujukan, yang berikut ialah log penghapusan bagi laman ini:",
-       "moveddeleted-notice": "Laman ini telah dihapuskan.\nLog penghapusan bagi laman ini dilampirkan di bawah untuk rujukan.",
+       "moveddeleted-notice": "Laman ini telah dihapuskan.\nLog penghapusan, perlindungan dan pemindahan bagi laman ini dilampirkan di bawah untuk rujukan.",
        "moveddeleted-notice-recent": "Maaf, laman ini baru-baru sahaja dihapuskan (dalam 24 jam yang lepas).\nLog penghapusan dan pemindahan untuk laman ini dinyatakan di bawah sebagai rujukan.",
        "log-fulllog": "Lihat log lengkap",
        "edit-hook-aborted": "Suntingan anda telah dibatalkan oleh penyangkuk. Tiada sebab diberikan.",
        "page_first": "awal",
        "page_last": "akhir",
        "histlegend": "Pemilihan perbezaan: tandakan butang radio bagi versi-versi yang ingin dibandingkan dan tekan butang ''enter'' atau butang di bawah.<br />\nPetunjuk: (kini) = perbezaan dengan versi terkini,\n(akhir) = perbezaan dengan versi sebelumnya, K = suntingan kecil.",
-       "history-fieldset-title": "Lihat sejarah",
+       "history-fieldset-title": "Cari semakan",
        "history-show-deleted": "Dihapuskan sahaja",
        "histfirst": "terawal",
        "histlast": "terkini",
        "recentchangeslinked-feed": "Perubahan berkaitan",
        "recentchangeslinked-toolbox": "Perubahan berkaitan",
        "recentchangeslinked-title": "Perubahan berkaitan dengan $1",
-       "recentchangeslinked-summary": "Laman khas ini menyenaraikan perubahan terkini bagi laman-laman yang dipaut. Laman-laman yang terdapat dalam senarai pantau anda ditandakan dengan '''teks tebal'''.",
+       "recentchangeslinked-summary": "Masukkan nama laman untuk melihat perubahan pada laman yang dipautkan ke atau dari laman tersebut. (Untuk melihat ahli kategori, masukkan {{ns:category}}:Nama kategori). Perubahan pada laman dalam [[Special:Watchlist|senarai pantau]] anda ditandakan dengan '''teks tebal'''.",
        "recentchangeslinked-page": "Nama laman:",
        "recentchangeslinked-to": "Paparkan perubahan pada laman yang mengandungi pautan ke laman yang diberikan",
        "recentchanges-page-added-to-category": "[[:$1]] ditambahkan kepada kategori",
        "filehist-filesize": "Saiz fail",
        "filehist-comment": "Komen",
        "imagelinks": "Penggunaan fail",
-       "linkstoimage": "{{PLURAL:$1|Laman|$1 buah laman}} berikut mengandungi pautan ke fail ini:",
+       "linkstoimage": "{{PLURAL:$1|Laman|$1 buah laman}} berikut menggunakan fail ini:",
        "linkstoimage-more": "Lebih daripada $1 laman mengandungi pautan ke fail ini.\nYang berikut ialah {{PLURAL:$1||$1}} pautan pertama ke fail ini.\nAnda boleh melihat [[Special:WhatLinksHere/$2|senarai penuh]].",
-       "nolinkstoimage": "Tiada laman yang mengandungi pautan ke fail ini.",
+       "nolinkstoimage": "Tiada laman yang menggunakan fail ini.",
        "morelinkstoimage": "Lihat [[Special:WhatLinksHere/$1|semua pautan]] ke fail ini.",
        "linkstoimage-redirect": "$1 (lencongan fail) $2",
        "duplicatesoffile": "{{PLURAL:$1|Fail|$1 buah fail}} berikut ialah salinan bagi fail ini ([[Special:FileDuplicateSearch/$2|butiran lanjut]]):",
        "whatlinkshere-prev": "{{PLURAL:$1|sebelumnya|$1 sebelumnya}}",
        "whatlinkshere-next": "{{PLURAL:$1|berikutnya|$1 berikutnya}}",
        "whatlinkshere-links": "← pautan",
-       "whatlinkshere-hideredirs": "$1 pelencongan",
+       "whatlinkshere-hideredirs": "$1 lencongan",
        "whatlinkshere-hidetrans": "$1 penyertaan",
        "whatlinkshere-hidelinks": "$1 pautan",
        "whatlinkshere-hideimages": "$1 pautan fail",
        "javascripttest": "Ujian JavaScript",
        "javascripttest-pagetext-unknownaction": "Tindakan \"$1\" tidak dikenali.",
        "javascripttest-qunit-intro": "Lihat [$1 pendokumenan ujian] di mediawiki.org.",
-       "tooltip-pt-userpage": "Laman pengguna anda",
+       "tooltip-pt-userpage": "Laman {{GENDER:|pengguna anda}}",
        "tooltip-pt-anonuserpage": "Laman pengguna bagi alamat IP anda",
-       "tooltip-pt-mytalk": "Laman perbincangan anda",
+       "tooltip-pt-mytalk": "Laman perbincangan {{GENDER:|anda}}",
        "tooltip-pt-anontalk": "Perbincangan mengenai penyuntingan daripada alamat IP anda",
-       "tooltip-pt-preferences": "Keutamaan saya",
+       "tooltip-pt-preferences": "Keutamaan {{GENDER:|anda}}",
        "tooltip-pt-watchlist": "Senarai laman yang anda pantau",
-       "tooltip-pt-mycontris": "Senarai sumbangan anda",
+       "tooltip-pt-mycontris": "Senarai sumbangan {{GENDER:|anda}}",
        "tooltip-pt-login": "Walaupun tidak wajib, anda digalakkan supaya log masuk.",
        "tooltip-pt-logout": "Log keluar",
        "tooltip-pt-createaccount": "Anda digalakkan untuk membuka akaun dan log masuk; namun begitu ianya tidak diwajibkan",
        "tooltip-t-recentchangeslinked": "Perubahan terkini bagi semua laman yang dipaut dari laman ini",
        "tooltip-feed-rss": "Suapan RSS bagi laman ini",
        "tooltip-feed-atom": "Suapan Atom bagi laman ini",
-       "tooltip-t-contributions": "Lihat senarai sumbangan pengguna ini",
+       "tooltip-t-contributions": "Senarai sumbangan {{GENDER:$1|pengguna ini}}",
        "tooltip-t-emailuser": "Kirim e-mel kepada pengguna ini",
        "tooltip-t-info": "Maklumat lanjut mengenai laman ini",
        "tooltip-t-upload": "Muat naik imej atau fail media",
        "version-libraries-description": "Keterangan",
        "version-libraries-authors": "Pengarang",
        "redirect": "Lencongkan mengikut ID fail, pengguna, halaman atau semakan",
-       "redirect-summary": "Halaman khas ini melencong kepada fail (dengan nama fail), halaman (dengan ID semakan atau ID halaman) atau halaman pengguna (dengan ID pengguna berangka). Penggunaan: [[{{#Special:Redirect}}/file/Example.jpg]], [[{{#Special:Redirect}}/page/64308]], [[{{#Special:Redirect}}/revision/328429]], atau [[{{#Special:Redirect}}/user/101]].",
+       "redirect-summary": "Halaman khas ini melencong kepada fail (dengan nama fail), halaman (dengan ID semakan atau ID halaman) atau halaman pengguna (dengan ID pengguna berangka), atau entri log (dengan ID log). Kegunaan: [[{{#Special:Redirect}}/file/Example.jpg]], [[{{#Special:Redirect}}/page/64308]], [[{{#Special:Redirect}}/revision/328429]], atau [[{{#Special:Redirect}}/user/101]].",
        "redirect-submit": "Pergi",
        "redirect-lookup": "Cari:",
        "redirect-value": "Nilai:",
        "feedback-thanks": "Terima kasih! Maklum balas anda telah dicatatkan pada laman \"[$2 $1]\".",
        "feedback-thanks-title": "Terima kasih!",
        "feedback-useragent": "Ejen pengguna:",
-       "searchsuggest-search": "Cari",
+       "searchsuggest-search": "Cari dalam {{SITENAME}}",
        "searchsuggest-containing": "mengandungi...",
        "api-error-badtoken": "Ralat dalaman: token tak elok.",
        "api-error-emptypage": "Anda tidak dibenarkan membuat laman baru yang kosong.",
index 45bb7b6..96ddca3 100644 (file)
        "sig_tip": "အချိန်ပါပြသော သင့်လက်မှတ်",
        "hr_tip": "မျဉ်းလဲ (စိစစ်သုံးရန်)",
        "summary": "အ​ကျဉ်း​ချုပ်​ -",
-       "subject": "အကြောင်းအရာ -",
+       "subject": "အကြောင်းအရာ:",
        "minoredit": "အရေးမကြီးသော ​ပြင်​ဆင်​မှု ​ဖြစ်​သည်​",
        "watchthis": "ဤစာမျက်နှာကို စောင့်ကြည့်ရန်",
        "savearticle": "ဤစာမျက်နှာကို သိမ်းရန်",
        "sharedupload-desc-create": "ဤဖိုင်သည် $1 မှဖြစ်ပြီး အခြားပရောဂျက်များတွင်လည်း အသုံးပြုနိုင်သည်။ [$2 ဖိုင်ဖော်ပြချက် စာမျက်နှာ]ပေါ်ရှိ ဖော်ပြချက်ကို တည်းဖြတ်နိုင်သည်။",
        "filepage-nofile": "ဤအမည်ဖြင့် မည်သည့်ဖိုင်မှ မရှိပါ။",
        "filepage-nofile-link": "ဤအမည်ဖြင့် မည်သည့်ဖိုင်မှ မရှိပါ။ သိုရာတွင် ယင်းကို [$1 upload တင်]နိုင်သည်။",
-       "uploadnewversion-linktext": "ဤဖိုင်၏ နောက်ဆုံးဗာရှင်းကို အပ်လုပ်တင်ရန်",
+       "uploadnewversion-linktext": "á\80¤á\80\96á\80­á\80¯á\80\84á\80ºá\81\8f á\80\94á\80±á\80¬á\80\80á\80ºá\80\86á\80¯á\80¶á\80¸á\80\97á\80¬á\80¸á\80\9bá\80¾á\80\84á\80ºá\80¸á\80\80á\80­á\80¯ á\80¡á\80\95á\80ºá\80\9cá\80¯á\80\95á\80ºá\80\90á\80\84á\80ºá\80\9bá\80\94á\80º",
        "shared-repo-from": "$1 ထံမှ",
        "shared-repo-name-wikimediacommons": "ဝီကီမီဒီယာ ကွန်မွန်းစ်",
        "upload-disallowed-here": "သင်သည် ဤဖိုင်အား ထပ်၍ ရေးသားမရနိုင်ပါ။",
        "allmessages": "စနစ်၏ သတင်းများ",
        "allmessagesname": "အမည်",
        "allmessagesdefault": "ပုံမှန် အသိပေးချက် စာသား",
+       "allmessagescurrent": "လက်ရှိ မက်ဆေ့စာသား",
        "allmessages-filter-legend": "စစ်ထုတ်ခြင်း",
        "allmessages-filter-unmodified": "မပြုပြင်ထားသော",
        "allmessages-filter-all": "အားလုံး",
        "filedeleteerror-short": "ဖိုင်ဖျက်ရာတွင် အမှားအယွင်း - $1",
        "previousdiff": "← တည်းဖြတ်မူ အဟောင်း",
        "nextdiff": "ပိုသစ်သော တည်းဖြတ်မှု",
+       "imagemaxsize": "ပုံအရွယ်အစား ကန့်သတ်ချက်:<br /><em>(ဖိုင်ဖော်ပြချက် စာမျက်နှာများအတွက်)</em>",
+       "thumbsize": "နမူနာပုံငယ် အရွယ်အစား:",
        "widthheightpage": "$1 × $2, {{PLURAL:$3|စာမျက်နှာ|စာမျက်နှာများ}} $3 ခု",
        "file-info-size": "$1 × $2 pixels, ဖိုင်အရွယ်အစား - $3, MIME အမျိုးအစား $4",
        "file-info-size-pages": "$1 × $2 pixels, ဖိုင်အရွယ်အစား: $3, MIME အမျိုးအစား: $4, {{PLURAL:$5|စာမျက်နှာ|စာမျက်နှာများ}} $5 ခု",
        "exif-lightsource": "အလင်းရင်းမြစ်",
        "exif-flash": "ဖလက်ရှ်",
        "exif-filesource": "ဖိုင်ရင်းမြစ်",
+       "exif-devicesettingdescription": "စက်ပစ္စည်းအပြင်အဆင်များ ဖော်ပြချက်",
+       "exif-gpslatituderef": "မြောက် သို့မဟုတ် တောင်လတ္တီကျု",
        "exif-gpslatitude": "လတ္တီကျု",
+       "exif-gpslongituderef": "အရှေ့ သို့မဟုတ် အနောက်လတ္တီကျု",
        "exif-gpslongitude": "လောင်ဂျီကျု",
        "exif-gpsaltitude": "အမြင့်",
        "exif-gpstimestamp": "ဂျီပီအက်စ်အချိန် (အက်တော့မစ် နာရီ)",
+       "exif-gpstrack": "ရွေ့လျား လားရာ",
        "exif-gpsimgdirection": "ရုပ်ပုံ၏ လမ်းကြောင်း",
+       "exif-gpsareainformation": "ဂျီပီအက်စ် ဧရိယာအမည်",
        "exif-gpsdatestamp": "ဂျီပီအက်စ်ရက်စွဲ",
        "exif-objectname": "ခေါင်းစဉ်တို",
+       "exif-source": "ရင်းမြစ်",
        "exif-contact": "ဆက်သွယ်ရန် လိပ်စာ",
        "exif-languagecode": "ဘာသာစကား",
        "exif-iimcategory": "ကဏ္ဍ",
        "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 dee448f..39f7825 100644 (file)
        "confirm-unwatch-top": "Fjern denne siden fra overvåkningslisten din?",
        "confirm-rollback-button": "OK",
        "confirm-rollback-top": "Tilbakestill redigeringer på denne siden?",
+       "confirm-mcrundo-title": "Fjern en endring",
+       "mcrundofailed": "Fjerning mislyktes",
+       "mcrundo-missingparam": "Manglende parameter ved forespørsel.",
+       "mcrundo-changed": "Siden har blitt endret siden du sist så diffen. Sjekk også den nye endringen.",
        "ellipsis": "…",
        "percent": "$1&nbsp;%",
        "quotation-marks": "«$1»",
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..2900e99 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",
        "logentry-delete-delete": "$1 {{GENDER:$2|sletta}} sida $3",
        "logentry-delete-delete_redir": "$1 {{GENDER:$2|sletta}} omdirigeringa $3 gjennom overskriving",
        "logentry-delete-restore": "$1 {{GENDER:$2|attoppretta}} sida $3 ($4)",
+       "logentry-delete-restore-nocount": "$1 {{GENDER:$2|attoppretta}} sida $3",
        "restore-count-revisions": "{{PLURAL:$1|éin versjon|$1 versjonar}}",
        "logentry-delete-event": "$1 {{GENDER:$2|endra}} synlegdomen av {{PLURAL:$5|éi loggoppføring|$5 loggoppføringar}} på $3: $4",
        "logentry-delete-revision": "$1 {{GENDER:$2|endra}} synlegdomen til {{PLURAL:$5|éin versjon|$5 versjonar}} på sida $3: $4",
index 4296432..f40a847 100644 (file)
        "edit-error-long": "Erros:\n\n$1",
        "revid": "revisão $1",
        "pageid": "identificador de página $1",
-       "interfaceadmin-info": "$1\n\nAs permissões de edição de ficheiros CSS/JS/JSON que afetam todo o ''site'' foram recentemente separadas do privilégio <code>editinterface</code>. Se não compreende porque está a receber este erro, consulte [[mw:MediaWiki_1.32/interface-admin]].",
+       "interfaceadmin-info": "A edição de ficheiros CSS/JS/JSON foi recentemente limitada a membros do grupo [[{{int:grouppage-interface-admin}}|{{int:group-interface-admin}}]]. Para mais informações, consulte [[m:Creation of separate user group for editing sitewide CSS/JS]].",
        "rawhtml-notallowed": "As etiquetas &lt;html&gt; não podem ser utilizadas fora de páginas normais.",
        "gotointerwiki": "A sair da wiki {{SITENAME}}",
        "gotointerwiki-invalid": "O título especificado é inválido.",
index 8c324ed..1a0e8c8 100644 (file)
                        "Avatar6",
                        "Akapochtli",
                        "ديفيد",
-                       "Daimona Eaytoy"
+                       "Daimona Eaytoy",
+                       "A2093064"
                ]
        },
        "sidebar": "{{notranslate}}",
        "edit": "The text of the tab going to the edit form. When the page is protected, you will see {{msg-mw|Viewsource}}. Should be in the infinitive mood.\n\nSee also:\n* {{msg-mw|Edit}}\n* {{msg-mw|Accesskey-ca-edit}}\n* {{msg-mw|Tooltip-ca-edit}}\n{{Identical|Edit}}",
        "edit-local": "The text on the tab going to the edit form for the local description page of a file from a foreign file repository (e.g. Wikimedia Commons). Should be in the infinitive mood.\n\nSee also:\n* {{msg-mw|Edit}}\n* {{msg-mw|Create-local}}",
        "create": "The text on the tab of the edit form on unexisting pages starts editing them. Should be in the infinitive mood.\n\n{{Identical|Create}}",
-       "create-local": "The text on the tab going to the creation form for the (not yet existing) local description page of a file from a foreign file repository (e.g. Wikimedia Commons). Should be in the infinitive mood.\n\nSee also:\n* {{msg-mw|Create}}\n* {{msg-mw|Edit-local}}",
+       "create-local": "The text on the tab going to the creation form for the (not yet existing) local description page of a file from a foreign file repository (e.g. Wikimedia Commons). Should be in the infinitive mood.\n\nSee also:\n* {{msg-mw|Create}}\n* {{msg-mw|Edit-local}}\n* {{msg-mw|Visualeditor-ca-createlocaldescriptionsource}}",
        "delete": "Name of the Delete tab shown for admins. Should be in the infinitive mood.\n\nSee also:\n* {{msg-mw|Delete}}\n* {{msg-mw|Accesskey-ca-delete}}\n* {{msg-mw|Tooltip-ca-delete}}\n{{Identical|Delete}}",
        "undelete_short": "It is tab label. It's really can be named ''nstab-undelete''. Parameters:\n* $1 - number of edits",
        "viewdeleted_short": "Tab label for the undelete button when the user has permission to view the deleted history but not undelete.\n\nParameters:\n* $1 - number of edits",
index 1f509d9..1594fc8 100644 (file)
                        "AttemptToCallNil",
                        "Stjn",
                        "Vlad5250",
-                       "Marshmallych"
+                       "Marshmallych",
+                       "Atsirlin",
+                       "Michgrig"
                ]
        },
        "tog-underline": "Подчёркивание ссылок:",
        "redirectedfrom": "(перенаправлено с «$1»)",
        "redirectpagesub": "Страница-перенаправление",
        "redirectto": "Перенаправление на:",
-       "lastmodifiedat": "Ð\92 Ð¿Ð¾Ñ\81ледний Ñ\80аз Ñ\8dÑ\82а Ñ\81Ñ\82Ñ\80аниÑ\86а Ñ\80едакÑ\82иÑ\80овалаÑ\81Ñ\8c $1, в $2.",
+       "lastmodifiedat": "ЭÑ\82а Ñ\81Ñ\82Ñ\80аниÑ\86а Ð² Ð¿Ð¾Ñ\81ледний Ñ\80аз Ð±Ñ\8bла Ð¾Ñ\82Ñ\80едакÑ\82иÑ\80ована $1 в $2.",
        "viewcount": "К этой странице обращались $1 {{PLURAL:$1|раз|раза|раз}}.",
        "protectedpage": "Защищённая страница",
        "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": "Эта страница защищена для предотвращения её редактирования или совершений других действий.",
        "createacct-error": "Ошибка создания учётной записи",
        "createaccounterror": "Невозможно создать учётную запись: $1",
        "nocookiesnew": "Участник зарегистрирован, но не представлен. {{SITENAME}} использует «cookies» для представления участников. У вас «cookies» запрещены. Пожалуйста, разрешите их, а затем представьтесь со своиим новым именем участника и паролем.",
-       "nocookieslogin": "{{SITENAME}} использует «cookies» для представления участников. Вы их отключили. Пожалуйста, включите их и попробуйте снова.",
+       "nocookieslogin": "{{SITENAME}} использует cookie для представления участников.\nВы отключили использование cookie.\nВключите их и попробуйте снова.",
        "nocookiesfornew": "Учётная запись участника не была создана из-за невозможности проверить её источник. \nУбедитесь, что включены «cookies», обновите страницу и попробуйте ещё раз.",
        "nocookiesforlogin": "{{int:nocookieslogin}}",
        "createacct-loginerror": "Учётная запись была успешно создана, но вы не смогли войти в систему автоматически. Пожалуйста, [[Special:UserLogin|авторизуйтесь вручную]].",
        "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 не существует или расположена не локально.",
        "rcfilters-highlighted-filters-list": "Подсвечено: $1",
        "rcfilters-quickfilters": "Сохранённые фильтры",
        "rcfilters-quickfilters-placeholder-title": "Сохранённых фильтров ещё нет",
-       "rcfilters-quickfilters-placeholder-description": "ЧÑ\82обÑ\8b Ñ\81оÑ\85Ñ\80аниÑ\82Ñ\8c Ð½Ð°Ñ\81Ñ\82Ñ\80ойки Ñ\84илÑ\8cÑ\82Ñ\80а Ð¸ Ð¿Ð¾Ð²Ñ\82оÑ\80но Ð¸Ñ\81полÑ\8cзоваÑ\82Ñ\8c Ð¸Ñ\85 Ð¿Ð¾Ð·Ð¶Ðµ, Ñ\89елкниÑ\82е Ð·Ð½Ð°Ñ\87ок Ð·Ð°ÐºÐ»Ð°Ð´ÐºÐ¸ Ð² Ð¾Ð±Ð»Ð°Ñ\81Ñ\82и Â«Ð\90кÑ\82ивнÑ\8bй Ñ\84илÑ\8cÑ\82Ñ\80» ниже.",
+       "rcfilters-quickfilters-placeholder-description": "ЧÑ\82обÑ\8b Ñ\81оÑ\85Ñ\80аниÑ\82Ñ\8c Ð½Ð°Ñ\81Ñ\82Ñ\80ойки Ñ\84илÑ\8cÑ\82Ñ\80а Ð¸ Ð¿Ð¾Ð²Ñ\82оÑ\80но Ð¸Ñ\81полÑ\8cзоваÑ\82Ñ\8c Ð¸Ñ\85 Ð¿Ð¾Ð·Ð¶Ðµ, Ñ\89елкниÑ\82е Ð·Ð½Ð°Ñ\87ок Ð·Ð°ÐºÐ»Ð°Ð´ÐºÐ¸ Ð² Ð¾Ð±Ð»Ð°Ñ\81Ñ\82и Â«Ð\90кÑ\82ивнÑ\8bе Ñ\84илÑ\8cÑ\82Ñ\80Ñ\8b» ниже.",
        "rcfilters-savedqueries-defaultlabel": "Сохранённые фильтры",
        "rcfilters-savedqueries-rename": "Переименовать",
        "rcfilters-savedqueries-setdefault": "Установить по умолчанию",
        "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": "Исправить перенаправления, указывающие на прежнее название",
        "anonymous": "{{PLURAL:$1|1=Анонимный участник|Анонимные участники}} {{grammar:genitive|{{SITENAME}}}}",
        "siteuser": "{{GENDER:$2|участник|участница}} {{grammar:genitive|{{SITENAME}}}} $1",
        "anonuser": "анонимный участник {{grammar:genitive|{{SITENAME}}}} $1",
-       "lastmodifiedatby": "Ð\92 Ð¿Ð¾Ñ\81ледний Ñ\80аз Ñ\8dÑ\82а Ñ\81Ñ\82Ñ\80аниÑ\86а Ñ\80едакÑ\82иÑ\80овалаÑ\81Ñ\8c $1, Ð² $2 Ð°Ð²Ñ\82оÑ\80ом $3.",
+       "lastmodifiedatby": "ЭÑ\82а Ñ\81Ñ\82Ñ\80аниÑ\86а Ð² Ð¿Ð¾Ñ\81ледний Ñ\80аз Ð±Ñ\8bла Ð¾Ñ\82Ñ\80едакÑ\82иÑ\80ована $1 Ð² $2, Ð°Ð²Ñ\82оÑ\80 Ð¸Ð·Ð¼ÐµÐ½ÐµÐ½Ð¸Ñ\8f â\80\94 $3.",
        "othercontribs": "В создании приняли участие: $1.",
        "others": "другие",
        "siteusers": "{{PLURAL:$2|1={{GENDER:$1|участник|участница}}|участники}} {{grammar:genitive|{{SITENAME}}}} $1",
        "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 22d343e..16aaf59 100644 (file)
        "viewsourcetext": "توهان هن صفحي جو ڪوڊ ڏسي ۽ نقل ڪري سگھو ٿا.",
        "protectedinterface": "هي صفحو سافٽ ويئر جو انٽرفيس متعين ڪري ٿو ۽ غلط استعال کان بچڻ لاءِ ان کي تحفظيو ويو آهي.\nتمام وڪي ۾ ترجمو شامل ڪرڻ لاءِ يا هن ۾ تبديلي ڪرڻ لاءِ ميڊياوڪي ترجمو [https://translatewiki.net/ translatewiki.net] استعمال ڪيو.",
        "namespaceprotected": "توهان کي نانءُپولار <strong>$1</strong> جا صفحا سنوارڻ جا اختيار ناهن.",
+       "sitecssprotected": "اوهان وٽ CSS صفحي کي ترميم ڪرڻ جا اختيار ناهن، ڇو ته اهڙيون ترميمون سڄي سائيٽ کي متاثر ڪري سگھن ٿيون.",
        "mycustomcssprotected": "توهان کي هيءُ CSS صفحو سنوارڻ جي اجازت نہ آهي.",
        "mycustomjsprotected": "توهان کي هيءُ جاوا اسڪرپٽ صفحو سنوارڻ جي اجازت حاصل ڪانهي.",
        "myprivateinfoprotected": "توهان کي پنهنجي ذاتي معلومات سنوارڻ جي اجازت حاصل نہ آهي.",
        "group-autoconfirmed": "خودبخود پڪ ڪيل واپرائيندڙَ",
        "group-bot": "بوٽس",
        "group-sysop": "منتظم",
+       "group-interface-admin": "منتظمين براءِ حليو",
        "group-bureaucrat": "ڪامورا",
        "group-all": "(سڀ)",
        "group-user-member": "{{GENDER:$1|واپرائيندڙ}}",
        "group-bot-member": "{{GENDER:$1|بوٽ}}",
        "group-sysop-member": "{{GENDER:$1|منتظم}}",
+       "group-interface-admin-member": "{{GENDER:$1|منتظم براءِ حليو}}",
        "group-bureaucrat-member": "{{GENDER:$1|ڪامورو}}",
        "group-suppress-member": "{{GENDER:$1|دٻائيندڙ}}",
        "grouppage-user": "{{ns:project}}:واپرائيندڙ",
        "grouppage-autoconfirmed": "{{ns:project}}:خودڪارنموني پڪ ڪيل رڪن",
        "grouppage-bot": "{{ns:project}}:بوٽس",
        "grouppage-sysop": "{{ns:project}}:منتظمين",
+       "grouppage-interface-admin": "{{ns:project}}:منتظمين براءِ حليو",
        "grouppage-bureaucrat": "{{ns:project}}:ڪامورا",
        "grouppage-suppress": "{{ns:project}}:دٻايو",
        "right-read": "صفحا پڙهو",
index 72e7ea5..d6b68a7 100644 (file)
        "virus-badscanner": "Konfiguracion i parregullt: Skaner i panjohur virusesh: ''$1''",
        "virus-scanfailed": "skanimi dështoi (code $1)",
        "virus-unknownscanner": "antivirus i pa njohur:",
-       "logouttext": "'''Ju keni dalë jashtë.''' \n \n Kini parasysh që disa faqe mund të shfaqen sikur të ishit i identifikuar derisa të fshini ''cache''-in e shfletuesit tuaj.",
+       "logouttext": "'''Ju keni dalë jashtë.''' \n \nKini parasysh që disa faqe mund të shfaqen sikur të ishit i/e identifikuar derisa të fshini ''cache''-in e shfletuesit tuaj.",
        "cannotlogoutnow-title": "Nuk mund të çkyçeni tani",
        "cannotlogoutnow-text": "Çregjistrimi nuk është i mundur kur përdorni $1.",
        "welcomeuser": "Mirë se vini, $1!",
index 32399b9..543617d 100644 (file)
@@ -70,7 +70,7 @@
        "tog-oldsig": "Ваш постојећи потпис:",
        "tog-fancysig": "Сматрај потпис као викитекст (без аутоматског линка)",
        "tog-uselivepreview": "Прикажи претпреглед без поновног учитавања странице",
-       "tog-forceeditsummary": "Упозори ме када не унесем резиме измене",
+       "tog-forceeditsummary": "Упозори ме када не унесем опис измене",
        "tog-watchlisthideown": "Сакриј моје измене са списка надгледања",
        "tog-watchlisthidebots": "Сакриј измене ботова са списка надгледања",
        "tog-watchlisthideminor": "Сакриј мање измене са списка надгледања",
        "enterlockreason": "Унесите разлог за закључавање, укључујући и време откључавања",
        "readonlytext": "База података је тренутно закључана, што значи да је није могуће мењати.\n\nСистемски администратор је навео следеће објашњење: $1",
        "missing-article": "Текст странице под називом „$1“ ($2) није пронађен.\n\nУзрок ове грешке је обично застарела измена или линк до избрисане странице.\n\nАко се не ради о томе, онда сте вероватно пронашли грешку у софтверу.\nПријавите је [[Special:ListUsers/sysop|администратору]] уз одговарајући линк.",
-       "missingarticle-rev": "(ревизија#: $1)",
+       "missingarticle-rev": "(измена#: $1)",
        "missingarticle-diff": "(разлика: $1, $2)",
        "readonly_lag": "База података је аутоматски закључана да би се секундарни сервери базе података ускладили с главним.",
        "internalerror": "Унутрашња грешка",
        "cannotdelete": "Не могу да избришем страницу или датотеку „$1“.\nМогуће је да ју је неко већ избрисао.",
        "cannotdelete-title": "Не могу да избришем страницу „$1“",
        "delete-hook-aborted": "Брисање је прекинула кука.\nНије дато никакво образложење.",
-       "no-null-revision": "Не могу да направим нову ништавну ревизију странице „$1“",
+       "no-null-revision": "Не могу да направим нову ништавну измену странице „$1“",
        "badtitle": "Лош наслов",
        "badtitletext": "Тражени наслов странице је неважећи, празан или је погрешно повезан међујезички или међувики наслов.\nМожда садржи један или више знакова који се не могу користити у насловима.",
        "title-invalid-empty": "Тражено име странице је празно или садржи само назив именског простора.",
        "media_tip": "Линк до датотеке",
        "sig_tip": "Ваш потпис са временском ознаком",
        "hr_tip": "Водоравна линија (користите ретко)",
-       "summary": "Резиме:",
+       "summary": "Ð\9eпиÑ\81 Ð¸Ð·Ð¼ÐµÐ½е:",
        "subject": "Тема:",
        "minoredit": "Ово је мања измена",
        "watchthis": "Надгледај ову страницу",
        "blankarticle": "<strong>Упозорење:</strong> Страница коју правите је празна.\nАко још једном притиснете „$1”, страница ће бити направљена без икаквог садржаја.",
        "anoneditwarning": "<strong>Упозорење:</strong> Нисте пријављени. Ако објавите страницу, ваша IP адреса ће бити јавно видљива у њеној историји измена и другде. Ако се <strong>[$1 пријавите]</strong> или <strong>[$2 отворите налог]</strong>, поред осталих погодности које добијате ваше измене ће бити приписиване вашем корисничком имену.",
        "anonpreviewwarning": "<em>Нисте пријављени. Ако објавите страницу, ваша IP адреса ће бити јавно видљива у њеној историји измена и другде.</em>",
-       "missingsummary": "<strong>Подсетник:</strong> нисте навели резиме измене.\nАко поново кликнете на „$1”, ваша измена ће бити сачувана без резимеа.",
+       "missingsummary": "<strong>Подсетник:</strong> нисте навели опис измене.\nАко поново кликнете на „$1”, ваша измена ће бити сачувана без њега.",
        "selfredirect": "<strong>Упозорење:</strong> Преусмеравате ову страницу на њу саму.\nМожда вам је одредишна страница за преусмерење погрешна или уређујете погрешну страницу.\nАко још једном притиснете „$1”, преусмерење ће свеједно бити направљено.",
        "missingcommenttext": "Молимо унесите коментар.",
        "missingcommentheader": "<strong>Напомена:</strong> Нисте унели наслов теме овог коментара.\nАко поново кликнете на „$1”, измена ће бити сачувана без наслова.",
-       "summary-preview": "Преглед резимеа измене:",
+       "summary-preview": "Преглед описа измене:",
        "subject-preview": "Преглед теме:",
        "previewerrortext": "Дошло је до грешке при покушају прегледа промена.",
        "blockedtitle": "Корисник је блокиран",
        "anontalkpagetext": "----\n<em>Ово је страница за разговор с анонимним корисником који још нема налог или га не користи.</em>\nЗбог тога морамо да користимо бројчану IP адресу како бисмо га препознали.\nТакву адресу може делити више корисника.\nАко сте анонимни корисник и мислите да су вам упућене примедбе, [[Special:CreateAccount|отворите налог]] или се [[Special:UserLogin|пријавите]] да бисте избегли будућу забуну с осталим анонимним корисницима.",
        "noarticletext": "На овој страници тренутно нема текста.\nМожете [[Special:Search/{{PAGENAME}}|потражити овај наслов]] на другим страницама,\n<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} претражити сродне извештаје] или [{{fullurl:{{FULLPAGENAME}}|action=edit}} направити ову страницу]</span>.",
        "noarticletext-nopermission": "Тренутно нема текста на овој страници.\nМожете да [[Special:Search/{{PAGENAME}}|потражите овај наслов странице]] на другим страницама или <span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} претражите сродне евиденције]</span>, али немате дозволу да направите ову страницу.",
-       "missing-revision": "РевизиÑ\98а бр. $1 на страници под именом „{{FULLPAGENAME}}“ не постоји.\n\nОво се обично дешава када пратите застарели линк до странице која је избрисана.\nВише информација можете да пронађете у [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} евиденцији брисања].",
+       "missing-revision": "Ð\98змена бр. $1 на страници под именом „{{FULLPAGENAME}}“ не постоји.\n\nОво се обично дешава када пратите застарели линк до странице која је избрисана.\nВише информација можете да пронађете у [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} евиденцији брисања].",
        "userpage-userdoesnotexist": "Кориснички налог „<nowiki>$1</nowiki>“ није отворен.\nРазмислите да ли заиста желите да направите/уредите ову страницу.",
        "userpage-userdoesnotexist-view": "Кориснички налог „$1“ није отворен.",
        "blocked-notice-logextract": "Овај корисник је тренутно блокиран.\nПоследњи унос у евиденцији блокирања је наведен испод као референца:",
        "editconflict": "Сукобљене измене: $1",
        "explainconflict": "Неко други је у међувремену променио ову страницу.\nГорњи оквир садржи садашњи текст странице.\nВаше измене су приказане у доњем оквиру.\nМораћете да унесете своје промене у садашњи текст странице.\n<strong>Само</strong> ће текст у горњем оквиру за уређивање бити сачуван када кликнете на „$1”.",
        "yourtext": "Ваш текст",
-       "storedversion": "Ускладиштена ревизија",
-       "editingold": "<strong>Упозорење: уређујете застарелу ревизију ове странице.</strong>\nАко је сачувате, све промене направљене од ове ревизије ће бити изгубљене.",
+       "storedversion": "Ускладиштена измена",
+       "editingold": "<strong>Упозорење: уређујете застарелу измену ове странице.</strong>\nАко је сачувате, све промене направљене од ове измене ће бити изгубљене.",
        "unicode-support-fail": "Ваш прегледач не подржава Unicode. Он је неопоходан за уређивање страница, па зато не могу сачувати измену.",
        "yourdiff": "Разлике",
        "copyrightwarning": "Имајте на уму да се сви доприноси на овом викију сматрају као објављени под лиценцом $2 (више на $1).\nАко не желите да се ваши текстови мењају и размењују без ограничења, онда их не шаљите овде.<br />\nИсто тако обећавате да сте Ви аутор текста, или да сте га умножили са извора који је у јавном власништву.\n<strong>Не шаљите радове заштићене ауторским правима без дозволе!</strong>",
        "permissionserrors": "Грешка у дозволи",
        "permissionserrorstext": "Немате дозволу за ову радњу из {{PLURAL:$1|следећег|следећих}} разлога:",
        "permissionserrorstext-withaction": "Немате дозволу да $2 из {{PLURAL:$1|следећег|следећих}} разлога:",
-       "contentmodelediterror": "Не можете уредити ову ревизију јер је њен модел садржаја <code>$1</code>, што се разликује од актуелног модела садржаја странице <code>$2</code>.",
+       "contentmodelediterror": "Не можете уредити ову измену јер је њен модел садржаја <code>$1</code>, што се разликује од актуелног модела садржаја странице <code>$2</code>.",
        "recreate-moveddeleted-warn": "<strong>Упозорење: Поново правите страницу која је претходно избрисана.</strong>\n\nРазмотрите да ли је прикладно да наставите са уређивањем ове странице.\nОвде је наведена евиденција брисања и премештања са образложењем:",
        "moveddeleted-notice": "Ова страница је избрисана.\nЕвиденција брисања, заштите и премештања странице је наведена испод као референца.",
        "moveddeleted-notice-recent": "Нажалост, ова страница је недавно избрисана (у последњих 24 сата).\nЕвиденција брисања, заштите и премештања странице наведена је испод као референца:",
        "undo-failure": "Ова измена се не може поништити због сукоба измена.",
        "undo-norev": "Не могу да вратим измену јер не постоји или је избрисана.",
        "undo-nochange": "Изгледа да је измена већ поништена.",
-       "undo-summary": "Поништена ревизија $1 {{GENDER:$2|корисника|кориснице}} [[Special:Contributions/$2|$2]] ([[User talk:$2|разговор]])",
+       "undo-summary": "Поништена измена $1 {{GENDER:$2|корисника|кориснице}} [[Special:Contributions/$2|$2]] ([[User talk:$2|разговор]])",
        "undo-summary-username-hidden": "Поништи измену $1 скривеног корисника",
        "cantcreateaccount-text": "Отварање налога с ове IP адресе (<strong>$1</strong>) је блокирао/ла [[User:$3|$3]].\n\nРазлог који је навео/ла $3 је <em>$2</em>",
        "cantcreateaccount-range-text": "Отварање налога са IP адреса у распону <strong>$1</strong>, који укључује и вашу IP адресу (<strong>$4</strong>) је блокирао/ла [[User:$3|$3]].\n\nРазлог који је навео/ла $3 је <em>$2</em>",
        "viewpagelogs": "Евиденције ове странице",
        "nohistory": "Не постоји историја измена ове странице.",
-       "currentrev": "Ð\90кÑ\82Ñ\83елна Ñ\80евизиÑ\98а",
-       "currentrev-asof": "Најновија ревизија на датум $2 у $3",
-       "revisionasof": "РевизиÑ\98а на датум $2 у $3",
-       "revision-info": "РевизиÑ\98а од $1 од стране {{GENDER:$6|корисника $2|кориснице $2}}$7",
-       "previousrevision": "← Старија ревизија",
-       "nextrevision": "Новија ревизија →",
-       "currentrevisionlink": "Ð\90кÑ\82Ñ\83елна Ñ\80евизиÑ\98а",
+       "currentrev": "Ð\9dаÑ\98новиÑ\98а Ð¸Ð·Ð¼ÐµÐ½а",
+       "currentrev-asof": "Најновија измена на датум $2 у $3",
+       "revisionasof": "Ð\98змена на датум $2 у $3",
+       "revision-info": "Ð\98змена од $1 од стране {{GENDER:$6|корисника $2|кориснице $2}}$7",
+       "previousrevision": "← Старија измена",
+       "nextrevision": "Новија измена →",
+       "currentrevisionlink": "Ð\9dаÑ\98новиÑ\98а Ð¸Ð·Ð¼ÐµÐ½а",
        "cur": "трен",
        "next": "след",
        "last": "разл",
        "page_first": "прва",
        "page_last": "последња",
-       "histlegend": "Избор разлика: означите кутијице ревизија за упоређивање и притисните enter или дугме на дну.<br />\nОбјашњење: <strong>({{int:cur}})</strong> = разлика са актуелном ревизијом, <strong>({{int:last}})</strong> = разлика са претходном ревизијом, <strong>{{int:minoreditletter}}</strong> = мања измена",
+       "histlegend": "Избор разлика: означите кутијице измена за упоређивање и притисните enter или дугме на дну.<br />\nОбјашњење: <strong>({{int:cur}})</strong> = разлика са најновијом изменом, <strong>({{int:last}})</strong> = разлика са претходном изменом, <strong>{{int:minoreditletter}}</strong> = мања измена",
        "history-fieldset-title": "Претрага измена",
-       "history-show-deleted": "Само избрисане ревизије",
+       "history-show-deleted": "Само избрисане измене",
        "histfirst": "најстарије",
        "histlast": "најновије",
        "historysize": "({{PLURAL:$1|1 бајт|$1 бајта|$1 бајтова}})",
        "historyempty": "(празно)",
-       "history-feed-title": "Историја ревизија",
+       "history-feed-title": "Историја измена",
        "history-feed-description": "Историја измена ове странице на викију",
        "history-feed-item-nocomment": "$1 у $2",
        "history-feed-empty": "Тражена страница не постоји.\nМогуће да је избрисана са викија или је преименована.\nПокушајте да [[Special:Search|претражите вики]] за релевантне нове странице.",
-       "history-edit-tags": "Уреди ознаке изабраних ревизија",
+       "history-edit-tags": "Уреди ознаке изабраних измена",
        "rev-deleted-comment": "(опис измене уклоњен)",
        "rev-deleted-user": "(корисничко име уклоњено)",
        "rev-deleted-event": "(детаљи уноса уклоњени)",
        "rev-deleted-user-contribs": "[корисничко име или IP адреса је уклоњена – измена је сакривена са списка доприноса]",
-       "rev-deleted-text-permission": "РевизиÑ\98а Ð¾Ð²Ðµ Ñ\81Ñ\82Ñ\80аниÑ\86е Ñ\98е <strong>обрисана</strong>.\nДетаље можете да пронађете у [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} евиденцији брисања].",
+       "rev-deleted-text-permission": "Ð\98змена Ð¾Ð²Ðµ Ñ\81Ñ\82Ñ\80аниÑ\86е Ñ\98е <strong>избрисана</strong>.\nДетаље можете да пронађете у [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} евиденцији брисања].",
        "rev-suppressed-text-permission": "Измена ове странице је <strong>сакривена</strong>. Више детаља можете наћи у [{{fullurl:{{#Special:Log}}/suppress|page={{FULLPAGENAMEE}}}} историји сакривања].",
-       "rev-deleted-text-unhide": "РевизиÑ\98а Ð¾Ð²Ðµ Ñ\81Ñ\82Ñ\80аниÑ\86е Ñ\98е <strong>обÑ\80иÑ\81ана</strong>.\nÐ\94еÑ\82аÑ\99е Ð¼Ð¾Ð¶ÐµÑ\82е Ð´Ð° Ð¿Ñ\80онаÑ\92еÑ\82е Ñ\83 [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} ÐµÐ²Ð¸Ð´ÐµÐ½Ñ\86иÑ\98и Ð±Ñ\80иÑ\81аÑ\9aа].\nÐ\98пак Ð¼Ð¾Ð¶ÐµÑ\82е Ð´Ð° [$1 Ð¿Ð¾Ð³Ð»ÐµÐ´Ð°Ñ\82е Ð¾Ð²Ñ\83 Ñ\80евизиÑ\98у] ако желите да наставите.",
-       "rev-suppressed-text-unhide": "РевизиÑ\98а Ð¾Ð²Ðµ Ñ\81Ñ\82Ñ\80аниÑ\86е Ñ\98е <strong>Ñ\81акÑ\80ивена</strong>.\nÐ\94еÑ\82аÑ\99е Ð¼Ð¾Ð¶ÐµÑ\82е Ð´Ð° Ð¿Ñ\80онаÑ\92еÑ\82е Ñ\83 [{{fullurl:{{#Special:Log}}/suppress|page={{FULLPAGENAMEE}}}} ÐµÐ²Ð¸Ð´ÐµÐ½Ñ\86иÑ\98и Ñ\81акÑ\80иваÑ\9aа].\nÐ\98пак Ð¼Ð¾Ð¶ÐµÑ\82е Ð´Ð° [$1 Ð¿Ð¾Ð³Ð»ÐµÐ´Ð°Ñ\82е Ð¾Ð²Ñ\83 Ñ\80евизиÑ\98у] ако желите да наставите.",
+       "rev-deleted-text-unhide": "Ð\98змена Ð¾Ð²Ðµ Ñ\81Ñ\82Ñ\80аниÑ\86е Ñ\98е <strong>избÑ\80иÑ\81ана</strong>.\nÐ\94еÑ\82аÑ\99е Ð¼Ð¾Ð¶ÐµÑ\82е Ð´Ð° Ð¿Ñ\80онаÑ\92еÑ\82е Ñ\83 [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} ÐµÐ²Ð¸Ð´ÐµÐ½Ñ\86иÑ\98и Ð±Ñ\80иÑ\81аÑ\9aа].\nÐ\98пак Ð¼Ð¾Ð¶ÐµÑ\82е Ð´Ð° [$1 Ð¿Ð¾Ð³Ð»ÐµÐ´Ð°Ñ\82е Ð¾Ð²Ñ\83 Ð¸Ð·Ð¼ÐµÐ½у] ако желите да наставите.",
+       "rev-suppressed-text-unhide": "Ð\98змена Ð¾Ð²Ðµ Ñ\81Ñ\82Ñ\80аниÑ\86е Ñ\98е <strong>Ñ\81акÑ\80ивена</strong>.\nÐ\94еÑ\82аÑ\99е Ð¼Ð¾Ð¶ÐµÑ\82е Ð´Ð° Ð¿Ñ\80онаÑ\92еÑ\82е Ñ\83 [{{fullurl:{{#Special:Log}}/suppress|page={{FULLPAGENAMEE}}}} ÐµÐ²Ð¸Ð´ÐµÐ½Ñ\86иÑ\98и Ñ\81акÑ\80иваÑ\9aа].\nÐ\98пак Ð¼Ð¾Ð¶ÐµÑ\82е Ð´Ð° [$1 Ð¿Ð¾Ð³Ð»ÐµÐ´Ð°Ñ\82е Ð¾Ð²Ñ\83 Ð¸Ð·Ð¼ÐµÐ½у] ако желите да наставите.",
        "rev-deleted-text-view": "Измена ове странице је '''обрисана'''.\nМожете је погледати; више детаља можете наћи у [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} историји брисања].",
-       "rev-suppressed-text-view": "РевизиÑ\98а ове странице је <strong>сакривена</strong>.\nМожете је погледати; детаље можете да пронађете у [{{fullurl:{{#Special:Log}}/suppress|page={{FULLPAGENAMEE}}}} евиденцији сакривања].",
-       "rev-deleted-no-diff": "Не можете да видете ову разлику јер је једна од ревизија <strong>обрисана</strong>.\nДетаљи можете да пронађете у [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} евиденцији брисања].",
+       "rev-suppressed-text-view": "Ð\98змена ове странице је <strong>сакривена</strong>.\nМожете је погледати; детаље можете да пронађете у [{{fullurl:{{#Special:Log}}/suppress|page={{FULLPAGENAMEE}}}} евиденцији сакривања].",
+       "rev-deleted-no-diff": "Не можете да видете ову разлику јер је једна од измена <strong>избрисана</strong>.\nДетаљи можете да пронађете у [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} евиденцији брисања].",
        "rev-suppressed-no-diff": "Не можете видети ову разлику јер је једна од измена '''обрисана'''.",
-       "rev-deleted-unhide-diff": "Једна од ревизија у овој разлици је <strong>обрисана</strong>.\nДетаље можете да пронађете у [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} евиденцији брисања].\nИпак можете да [$1 погледате ову разлику] ако желите да наставите.",
+       "rev-deleted-unhide-diff": "Једна од измена у овој разлици је <strong>обрисана</strong>.\nДетаље можете да пронађете у [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} евиденцији брисања].\nИпак можете да [$1 погледате ову разлику] ако желите да наставите.",
        "rev-suppressed-unhide-diff": "Једна од измена у овој разлици је <strong>сакривена</strong>.\nВише информација можете да пронађете у [{{fullurl:{{#Special:Log}}/suppress|page={{FULLPAGENAMEE}}}} евиденцији сакривања].\nИпак можете да [$1 погледате ову разлику] ако желите да наставите.",
-       "rev-deleted-diff-view": "Једна од ревизија у овој разлици је <strong>обрисана</strong>.\nИпак можете да погледате ову разлику; детаљњ можете да пронађете у [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} евиденцији брисања].",
+       "rev-deleted-diff-view": "Једна од измена у овој разлици је <strong>избрисана</strong>.\nИпак можете да погледате ову разлику; детаље можете да пронађете у [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} евиденцији брисања].",
        "rev-suppressed-diff-view": "Једна од измена у овој разлици је <strong>сакривена</strong>.\nИпак можете да погледате ову разлику; више информација можете да пронађете у [{{fullurl:{{#Special:Log}}/suppress|page={{FULLPAGENAMEE}}}} евиденцији сакривања].",
        "rev-delundel": "промени видљивост",
        "rev-showdeleted": "прикажи",
-       "revisiondelete": "Брисање/враћање ревизија",
-       "revdelete-nooldid-title": "Неважећа одредишна ревизија",
-       "revdelete-nooldid-text": "Нисте навели одредишну ревизију на којој треба да се изврши ова функција, та ревизија не постоји, или покушавате да сакријете актуелну ревизију.",
+       "revisiondelete": "Брисање/враћање измена",
+       "revdelete-nooldid-title": "Неважећа одредишна измена",
+       "revdelete-nooldid-text": "Нисте навели одредишну измену на којој треба да се изврши ова функција, та измена не постоји, или покушавате да сакријете актуелну измену.",
        "revdelete-no-file": "Тражена датотека не постоји.",
-       "revdelete-show-file-confirm": "Јесте ли сигурни да желите да видите избрисану ревизију датотеке „<nowiki>$1</nowiki>“ од $2; $3?",
+       "revdelete-show-file-confirm": "Јесте ли сигурни да желите да видите избрисану измену датотеке „<nowiki>$1</nowiki>“ од $2; $3?",
        "revdelete-show-file-submit": "Да",
-       "revdelete-selected-text": "{{PLURAL:$1|Изабрана ревизија|Изабране ревизије|Изабраних ревизија}} [[:$2]]:",
+       "revdelete-selected-text": "{{PLURAL:$1|Изабрана измена|Изабране измене|Изабраних измена}} [[:$2]]:",
        "revdelete-selected-file": "{{PLURAL:$1|Изабрана верзија датотеке|Изабране верзије датотеке}} [[:$2]]:",
        "logdelete-selected": "{{PLURAL:$1|Изабрана ставка у историји|Изабране ставке у историји}}:",
-       "revdelete-text-text": "Избрисане ревизије ће и даље бити видљиве у историји странице, али делови њиховог садржаја неће бити јавно доступни.",
+       "revdelete-text-text": "Избрисане измене ће и даље бити видљиве у историји странице, али делови њиховог садржаја неће бити јавно доступни.",
        "revdelete-text-file": "Избрисане верзије датотеке ће и даље бити видљиве у историји датотеке, али делови њиховог садржаја неће бити јавно доступни.",
        "logdelete-text": "Избрисани догађаји у евиденцијама ће се идаље појављивати у евиденцији, али ће делови њиховог садржаја бити недоступни јавности.",
        "revdelete-text-others": "Остали администратори ће и даље моћи да приступе скривеном садржају и врате га, осим ако се поставе додатна ограничења.",
        "revdelete-confirm": "Потврдите да намеравате ово урадити, да разумете последице и да то чините у складу са [[{{MediaWiki:Policy-url}}|правилима]].",
        "revdelete-suppress-text": "Сакривање измена би требало користити <strong>само</strong> у следећим случајевима:\n* злонамерни или погрдни подаци\n* неприкладни лични подаци\n*: <em>кућна адреса и број телефона, број кредитне картице, ЈМБГ итд.</em>",
        "revdelete-legend": "Ограничења видљивости",
-       "revdelete-hide-text": "Текст ревизије",
+       "revdelete-hide-text": "Текст измене",
        "revdelete-hide-image": "Сакриј садржај датотеке",
        "revdelete-hide-name": "Циљ и параметре",
-       "revdelete-hide-comment": "Резиме измене",
+       "revdelete-hide-comment": "Ð\9eпиÑ\81 измене",
        "revdelete-hide-user": "Корисничко име/IP адреса",
        "revdelete-hide-restricted": "Сакриј податке од администратора и других корисника",
        "revdelete-radio-same": "(не мењај)",
        "revdelete-radio-set": "Сакривено",
        "revdelete-radio-unset": "Видљиво",
        "revdelete-suppress": "Сакриј податке од администратора и других корисника",
-       "revdelete-unsuppress": "Уклони ограничења на враћеним ревизијама",
+       "revdelete-unsuppress": "Уклони ограничења на враћеним изменама",
        "revdelete-log": "Разлог:",
-       "revdelete-submit": "Примени на {{PLURAL:$1|изабрану ревизију|изабране ревизије}}",
+       "revdelete-submit": "Примени на {{PLURAL:$1|изабрану измену|изабране измене}}",
        "revdelete-success": "Видљивост измене је ажурирана.",
-       "revdelete-failure": "Не могу да ажурирам видљивост ревизије:\n$1",
+       "revdelete-failure": "Не могу да ажурирам видљивост измене:\n$1",
        "logdelete-success": "Постављена је видљивост уноса у евиденцији.",
        "logdelete-failure": "'''Не могу да поставим видљивост историје:'''\n$1",
        "revdel-restore": "промени видљивост",
        "pagehist": "Историја странице",
        "deletedhist": "Избрисана историја",
-       "revdelete-hide-current": "Ð\93Ñ\80еÑ\88ка Ð¿Ñ\80и Ñ\81акÑ\80иваÑ\9aÑ\83 Ñ\81Ñ\82авке Ð¾Ð´ $1, $2: Ð¾Ð²Ð¾ Ñ\98е Ð°ÐºÑ\82Ñ\83елна Ñ\80евизиÑ\98а.\nНе може да буде сакривена.",
+       "revdelete-hide-current": "Ð\93Ñ\80еÑ\88ка Ð¿Ñ\80и Ñ\81акÑ\80иваÑ\9aÑ\83 Ñ\81Ñ\82авке Ð¾Ð´ $1, $2: Ð\9eво Ñ\98е Ð°ÐºÑ\82Ñ\83елна Ð¸Ð·Ð¼ÐµÐ½а.\nНе може да буде сакривена.",
        "revdelete-show-no-access": "Грешка при приказивању ставке од $1, $2: означена је као „ограничена“.\nНемате приступ до ње.",
        "revdelete-modify-no-access": "Грешка при мењању ставке од $1, $2: означена је као „ограничена“.\nНемате приступ до ње.",
        "revdelete-modify-missing": "Грешка при мењању ИБ ставке $1: она не постоји у бази података.",
        "revdelete-otherreason": "Други/додатни разлог:",
        "revdelete-reasonotherlist": "Други разлог",
        "revdelete-edit-reasonlist": "Уреди разлоге за брисање",
-       "revdelete-offender": "Аутор ревизије:",
+       "revdelete-offender": "Аутор измене:",
        "suppressionlog": "Евиденција сакривања",
        "suppressionlogtext": "Испод се налази списак брисања и блокирања који укључује садржај сакривен од администратора. Погледајте [[Special:BlockList|списак блокирања]] за списак актуелних операција забрана и блокирања.",
        "mergehistory": "Спајање историја странице",
-       "mergehistory-header": "Ова страница вам омогућава да спојите ревизије неке изворне странице у нову страницу.\nЗапамтите да ће ова промена оставити непромењен садржај историје странице.",
+       "mergehistory-header": "Ова страница вам омогућава да спојите измене неке изворне странице у нову страницу.\nЗапамтите да ће ова промена оставити непромењен садржај историје странице.",
        "mergehistory-box": "Споји измене две странице:",
        "mergehistory-from": "Изворна страница:",
        "mergehistory-into": "Одредишна страница:",
        "mergehistory-list": "Спојива историја измена",
-       "mergehistory-merge": "Следеће ревизије странице [[:$1]] могу се спојити са [[:$2]].\nКористите дугмиће у колони да бисте спојили ревизије које су направљене пре наведеног времена.\nКоришћење навигационих линкова ће поништити ову колону.",
+       "mergehistory-merge": "Следеће измене странице [[:$1]] могу се спојити са [[:$2]].\nКористите дугмиће у колони да бисте спојили измене које су направљене пре наведеног времена.\nКоришћење навигационих линкова ће поништити ову колону.",
        "mergehistory-go": "Прикажи измене које се могу спојити",
-       "mergehistory-submit": "Споји ревизије",
+       "mergehistory-submit": "Споји измене",
        "mergehistory-empty": "Нема измена за спајање.",
-       "mergehistory-done": "$3 {{PLURAL:$3|ревизија странице $1 је спојена|ревизије странице $1 су спојене|ревизија странице $1 је спојено}} у [[:$2]].",
+       "mergehistory-done": "$3 {{PLURAL:$3|измена странице $1 је спојена|измене странице $1 су спојене|измена странице $1 је спојено}} у [[:$2]].",
        "mergehistory-fail": "Не могу да спојим историје. Проверите страницу и временске параметре.",
        "mergehistory-fail-bad-timestamp": "Временска ознака је неважећа.",
        "mergehistory-fail-invalid-source": "Изворна страница није валидна.",
        "mergehistory-fail-invalid-dest": "Одредишна страница је неважећа.",
-       "mergehistory-fail-no-change": "Спајање историје није спојило ниједну ревизију. Проверите параметре странице и времена.",
+       "mergehistory-fail-no-change": "Спајање историје није спојило ниједну измену. Проверите параметре странице и времена.",
        "mergehistory-fail-permission": "Немате овлашћење за спајање историје.",
        "mergehistory-fail-self-merge": "Изворна и одредишна страница не могу бити исте.",
-       "mergehistory-fail-timestamps-overlap": "Изворне ревизије се преклапају или долазе након одредишних ревизија.",
-       "mergehistory-fail-toobig": "Не могу да извршим спајање историје јер ће више од $1 {{PLURAL:$1|ревизије бити премештене|ревизија бити премештено}}.",
+       "mergehistory-fail-timestamps-overlap": "Изворне измене се преклапају или долазе након одредишних измена.",
+       "mergehistory-fail-toobig": "Не могу да извршим спајање историје јер ће више од $1 {{PLURAL:$1|измене бити премештене|измена бити премештено}}.",
        "mergehistory-no-source": "Изворна страница $1 не постоји.",
        "mergehistory-no-destination": "Одредишна страница $1 не постоји.",
        "mergehistory-invalid-source": "Изворна страница мора имати валидан наслов.",
        "mergelog": "Евиденција спајања",
        "revertmerge": "растави",
        "mergelogpagetext": "Испод је списак најскоријих спајања историја двеју страница.",
-       "history-title": "Историја ревизија странице „$1“",
-       "difference-title": "Разлика између ревизија на страници „$1”",
+       "history-title": "Историја измена странице „$1“",
+       "difference-title": "Разлика између измена на страници „$1”",
        "difference-title-multipage": "Разлика између страница „$1“ и „$2“",
        "difference-multipage": "(разлике између страница)",
        "lineno": "Ред $1:",
-       "compareselectedversions": "Упореди изабране ревизије",
-       "showhideselectedversions": "Промени видљивост изабраних ревизија",
+       "compareselectedversions": "Упореди изабране измене",
+       "showhideselectedversions": "Промени видљивост изабраних измена",
        "editundo": "поништи",
        "diff-empty": "(нема разлике)",
-       "diff-multi-sameuser": "({{PLURAL:$1|Једна међуревизија истог корисника није приказана|$1 међуревизија истог корисника нису приказане|$1 међуревизија истог корисника није приказано}})",
-       "diff-multi-otherusers": "({{PLURAL:$1|Једна међуревизија|$1 међуревизије|$1 међуревизија}} од стране {{PLURAL:$2|још једног корисника није приказана|$2 корисника није приказано}})",
+       "diff-multi-sameuser": "({{PLURAL:$1|Једна међуизмена истог корисника није приказана|$1 међуизмена истог корисника нису приказане|$1 међуизмена истог корисника није приказано}})",
+       "diff-multi-otherusers": "({{PLURAL:$1|Једна међуизмена|$1 међуизмене|$1 међуизмена}} од стране {{PLURAL:$2|још једног корисника није приказана|$2 корисника није приказано}})",
        "diff-multi-manyusers": "({{PLURAL:$1|Није приказана међуизмена|Нису приказане $1 међуизмене|Није приказано $1 међуизмена}} од више од $2 корисника)",
        "diff-paragraph-moved-tonew": "Пасус је премештен. Кликните да пређете на нову локацију.",
        "diff-paragraph-moved-toold": "Пасус је премештен. Кликните да пређете на стару локацију.",
-       "difference-missing-revision": "{{PLURAL:$2|Једна ревизија|$2 ревизије}} ове разлике ($1) не {{PLURAL:$2|постоји|постоје}}.\n\nОво се обично дешава када пратите застарели линк до странице која је избрисана.\nДетаље можете да пронађете у [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} евиденцији брисања].",
+       "difference-missing-revision": "{{PLURAL:$2|Једна измена|$2 измене}} ове разлике ($1) не {{PLURAL:$2|постоји|постоје}}.\n\nОво се обично дешава када пратите застарели линк до странице која је избрисана.\nДетаље можете да пронађете у [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} евиденцији брисања].",
        "searchresults": "Резултати претраге",
        "search-filter-title-prefix-reset": "Претражи све странице",
        "searchresults-title": "Резултати претраге за „$1“",
        "right-delete": "брисање страница",
        "right-bigdelete": "брисање страница са великом историјом",
        "right-deletelogentry": "брисање и враћање одређених уноса у евиденцији",
-       "right-deleterevision": "брисање и враћање одређених ревизија страница",
+       "right-deleterevision": "брисање и враћање одређених измена страница",
        "right-deletedhistory": "прегледање избрисаних ставки историје без повезаног текста",
-       "right-deletedtext": "прегледање избрисаног текста и промена између избрисаних ревизија",
+       "right-deletedtext": "прегледање избрисаног текста и промена између избрисаних измена",
        "right-browsearchive": "претрага избрисаних страница",
        "right-undelete": "враћање избрисаних страница",
-       "right-suppressrevision": "прегледање, скривање и враћање одређених ревизија страница од свих корисника",
+       "right-suppressrevision": "прегледање, скривање и враћање одређених измена страница од свих корисника",
        "right-viewsuppressed": "прегледање измена скривених од свих корисника",
        "right-suppressionlog": "прегледање приватних евиденција",
        "right-block": "блокирање даљих измена других корисника",
        "right-sendemail": "слање имејла другим корисницима",
        "right-managechangetags": "прављење и (де)активирање [[Special:Tags|ознака]]",
        "right-applychangetags": "примењивање [[Special:Tags|ознака]] на нечије промене",
-       "right-changetags": "додавање и уклањање разних [[Special:Tags|ознака]] на појединачним ревизијама и уносима у евиденцијама",
+       "right-changetags": "додавање и уклањање разних [[Special:Tags|ознака]] на појединачним изменама и уносима у евиденцијама",
        "right-deletechangetags": "брисање [[Special:Tags|ознака]] из базе података",
        "grant-generic": "Скуп права „$1“",
        "grant-group-page-interaction": "Уређивање страница",
        "grant-blockusers": "Блокирање и деблокирање корисника",
        "grant-createaccount": "Отварање налога",
        "grant-createeditmovepage": "Прављење, уређивање и премештање страница",
-       "grant-delete": "Брисање страница, ревизија и уноса у евиденцијама",
+       "grant-delete": "Брисање страница, измена и уноса у евиденцијама",
        "grant-editinterface": "Уређивање именског простора Медијавики и JSON-а сајта/корисника",
        "grant-editmycssjs": "Уређивање вашег CSS/JSON/Јаваскрипта",
        "grant-editmyoptions": "Уређивање ваших корисничких подешавања",
        "grant-editpage": "Уређивање постојећих страница",
        "grant-editprotected": "Уређивање заштићених страница",
        "grant-highvolume": "Масовно уређивање",
-       "grant-oversight": "Скривање корисника и ревизија",
+       "grant-oversight": "Скривање корисника и измена",
        "grant-patrol": "Патролирање промена на страницама",
        "grant-privateinfo": "Приступи приватним информацијама",
        "grant-protect": "Закључавање и откључавање страница",
        "action-upload_by_url": "отпремите ову датотеку путем УРЛ-а",
        "action-writeapi": "користите API за писање",
        "action-delete": "избришете ову страницу",
-       "action-deleterevision": "бришете ревизије",
+       "action-deleterevision": "бришете измене",
        "action-deletelogentry": "бришете уносе у евиденцијама",
        "action-deletedhistory": "прегледате избрисану историју странице",
-       "action-deletedtext": "прегледате избрисани текст ревизије",
+       "action-deletedtext": "прегледате избрисани текст измене",
        "action-browsearchive": "претражујете избрисане странице",
        "action-undelete": "враћате странице",
-       "action-suppressrevision": "прегледате и враћате сакривене ревизије",
+       "action-suppressrevision": "прегледате и враћате сакривене измене",
        "action-suppressionlog": "прегледате ову приватну евиденције",
        "action-block": "блокирате уређивање овом кориснику",
        "action-protect": "промените нивое заштите ове странице",
        "action-editcontentmodel": "уређујете модел садржаја странице",
        "action-managechangetags": "правите и (де)активирате ознаке",
        "action-applychangetags": "додате ознаке уз сопствене промене",
-       "action-changetags": "додате и уклоните разне ознаке на појединачним ревизијама и уносима у евиденцијама",
+       "action-changetags": "додате и уклоните разне ознаке на појединачним изменама и уносима у евиденцијама",
        "action-deletechangetags": "бришете ознаке из базе података",
        "action-purge": "освежите ову страницу",
        "nchanges": "$1 {{PLURAL:$1|промена|промене|промена}}",
        "rcfilters-hideminor-conflicts-typeofchange-global": "Филтер за „мање” измене је у сукобу са једним или више филтера типа промена, зато што одређени типови промена не могу да се означе као „мање”. Сукобљени филтери су означени у подручју Активни филтери, изнад.",
        "rcfilters-hideminor-conflicts-typeofchange": "Одређени типови промена не могу да се означе као „мање”, тако да је овај филтер у сукобу са следећим филтерима типа промена: $1",
        "rcfilters-typeofchange-conflicts-hideminor": "Овај филтер типа измене је у сукобу са филтером за „мање” измене. Одређени типови измена не могу да се означе као „мање”.",
-       "rcfilters-filtergroup-lastRevision": "Најновије ревизије",
-       "rcfilters-filter-lastrevision-label": "Најновија ревизија",
+       "rcfilters-filtergroup-lastRevision": "Најновије измене",
+       "rcfilters-filter-lastrevision-label": "Најновија измена",
        "rcfilters-filter-lastrevision-description": "Само најновија промена на страници.",
-       "rcfilters-filter-previousrevision-label": "Није најновија ревизија",
-       "rcfilters-filter-previousrevision-description": "Све промене које нису „последње ревизије”.",
+       "rcfilters-filter-previousrevision-label": "Није најновија измена",
+       "rcfilters-filter-previousrevision-description": "Све промене које нису „последње измене”.",
        "rcfilters-filter-excluded": "Изузето",
        "rcfilters-tag-prefix-namespace-inverted": "<strong>:није</strong> $1",
        "rcfilters-exclude-button-off": "Изузми изабрано",
        "uploadlogpage": "Евиденција отпремања",
        "uploadlogpagetext": "Испод је списак недавних отпремања.\nПогледајте [[Special:NewFiles|галерију нових датотека]] за лепши преглед.",
        "filename": "Назив датотеке",
-       "filedesc": "Резиме",
-       "fileuploadsummary": "Резиме:",
+       "filedesc": "Ð\9eпиÑ\81 Ð¸Ð·Ð¼ÐµÐ½е",
+       "fileuploadsummary": "Ð\9eпиÑ\81 Ð¸Ð·Ð¼ÐµÐ½е:",
        "filereuploadsummary": "Промене датотеке:",
        "filestatus": "Статус ауторског права:",
        "filesource": "Извор:",
        "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 датотекама.",
        "withoutinterwiki-summary": "Следеће странице немају линкове према верзијама на другим језицима.",
        "withoutinterwiki-legend": "Префикс",
        "withoutinterwiki-submit": "Прикажи",
-       "fewestrevisions": "Странице са најмање ревизија",
+       "fewestrevisions": "Странице са најмање измена",
        "nbytes": "$1 {{PLURAL:$1|бајт|бајта|бајтова}}",
        "ncategories": "$1 {{PLURAL:$1|категорија|категорије|категорија}}",
        "ninterwikis": "$1 {{PLURAL:$1|међувики|међувикија|међувикија}}",
        "nlinks": "$1 {{PLURAL:$1|линк|линка|линкова}}",
        "nmembers": "$1 {{PLURAL:$1|члан|члана|чланова}}",
        "nmemberschanged": "$1 → $2 {{PLURAL:$2|члан|члана|чланова}}",
-       "nrevisions": "$1 {{PLURAL:$1|ревизија|ревизије|ревизија}}",
+       "nrevisions": "$1 {{PLURAL:$1|измена|измене|измена}}",
        "nimagelinks": "Користи се на $1 {{PLURAL:$1|страници|странице|страница}}",
        "ntransclusions": "користи се на $1 {{PLURAL:$1|страници|странице|страница}}",
        "specialpage-empty": "Нема резултата за овај извештај.",
        "mostcategories": "Странице са највише категорија",
        "mostimages": "Датотеке са највише линкова",
        "mostinterwikis": "Странице са највише међувикија",
-       "mostrevisions": "Странице са највише ревизија",
+       "mostrevisions": "Странице са највише измена",
        "prefixindex": "Све странице са префиксом",
        "prefixindex-namespace": "Све странице с предметком (именски простор $1)",
        "prefixindex-submit": "Прикажи",
        "unwatch": "Прекини надгледање",
        "unwatchthispage": "Прекини надгледање",
        "notanarticle": "Није страница са садржајем",
-       "notvisiblerev": "Последња ревизија другог корисника је избрисана.",
+       "notvisiblerev": "Последња измена другог корисника је избрисана.",
        "watchlist-details": "Имате {{PLURAL:$1|$1 страницу|$1 странице|$1 страница}} на свом списку надгледања (плус странице за разговор).",
        "wlheader-enotif": "Обавештење имејлом је омогућено.",
        "wlheader-showupdated": "Странице које су промењене откад сте их последњи пут посетили су <strong>подебљане</strong>.",
        "enotif_subject_restored": "Страницу $1 на {{SITENAME}} {{GENDER:$2|вратио је|вратила је|вратио је}} $2",
        "enotif_subject_changed": "Страницу $1 на {{SITENAME}} {{GENDER:$2|променио|променила}} је $2",
        "enotif_body_intro_deleted": "Страницу $1 на {{SITENAME}} {{GENDER:$2|обрисао|обрисала}} је $2 дана $PAGEEDITDATE Погледајте $3.",
-       "enotif_body_intro_created": "Страницу $1 на пројекту {{SITENAME}} је {{GENDER:$2|направио корисник|направила корисница}} $2 на датум $PAGEEDITDATE Актуелна ревизија се налази на $3.",
-       "enotif_body_intro_moved": "Страницу $1 на {{SITENAME}} је {{GENDER:$2|преместио корисник|преместила корисница}} $2 на датум $PAGEEDITDATE Актуелна ревизија се налази на $3.",
-       "enotif_body_intro_restored": "Страницу $1 на пројекту {{SITENAME}} је {{GENDER:$2|вратио корисник|вратила корисница}} $2 на датум $PAGEEDITDATE Актуелна ревизија се налази на $3.",
-       "enotif_body_intro_changed": "Страницу $1 на пројекту {{SITENAME}} је {{GENDER:$2|променио корисник|променила корисница}} $2 на датум $PAGEEDITDATE Актуелна ревизија се налази на $3.",
+       "enotif_body_intro_created": "Страницу $1 на пројекту {{SITENAME}} је {{GENDER:$2|направио корисник|направила корисница}} $2 на датум $PAGEEDITDATE Актуелна измена се налази на $3.",
+       "enotif_body_intro_moved": "Страницу $1 на {{SITENAME}} је {{GENDER:$2|преместио корисник|преместила корисница}} $2 на датум $PAGEEDITDATE Актуелна измена се налази на $3.",
+       "enotif_body_intro_restored": "Страницу $1 на пројекту {{SITENAME}} је {{GENDER:$2|вратио корисник|вратила корисница}} $2 на датум $PAGEEDITDATE Актуелна измена се налази на $3.",
+       "enotif_body_intro_changed": "Страницу $1 на пројекту {{SITENAME}} је {{GENDER:$2|променио корисник|променила корисница}} $2 на датум $PAGEEDITDATE Актуелна измена се налази на $3.",
        "enotif_lastvisited": "За све промене од последње посете, погледајте $1.",
        "enotif_lastdiff": "Да бисте видели ову промену, погледајте $1.",
        "enotif_anon_editor": "анониман корисник $1",
        "exbeforeblank": "садржај пре брисања је био: „$1“",
        "delete-confirm": "Брисање странице „$1“",
        "delete-legend": "Брисање",
-       "historywarning": "<strong>Упозорење:</strong> Страница коју желите да избришете има историју са $1 {{PLURAL:$1|ревизијом|ревизије|ревизија}}:",
+       "historywarning": "<strong>Упозорење:</strong> Страница коју желите да избришете има историју са $1 {{PLURAL:$1|ревизијом|измене|измена}}:",
        "historyaction-submit": "Прикажи",
        "confirmdeletetext": "Управо ћете избрисати страницу, укључујући и њену историју.\nПотврдите своју намеру, да разумете последице и да ово радите у складу са [[{{MediaWiki:Policy-url}}|правилима]].",
        "actioncomplete": "Радња је завршена",
        "log-name-create": "Евиденција прављења страница",
        "log-description-create": "Испод је списак недавних прављења страница.",
        "logentry-create-create": "$1 је {{GENDER:$2|направио|направила}} страницу $3",
-       "reverted": "Враћено на ранију ревизију",
+       "reverted": "Враћено на ранију измену",
        "deletecomment": "Разлог:",
        "deleteotherreason": "Други/додатни разлог:",
        "deletereasonotherlist": "Други разлог",
        "deletereason-dropdown": "* Уобичајени разлози за брисање\n** Непожељан садржај\n** Вандализам\n** Кршење ауторских права\n** Захтев аутора\n** Покварено преусмерење",
        "delete-edit-reasonlist": "Уреди разлоге брисања",
-       "delete-toobig": "Ова страница има велику историју измена, преко $1 {{PLURAL:$1|ревизија|ревизије|ревизија}}.\nБрисање таквих страница је ограничено да би се спречило случајно оптерећење сервера.",
-       "delete-warning-toobig": "Ова страница има велику историју измена, преко $1 {{PLURAL:$1|ревизија|ревизије|ревизија}}.\nЊено брисање може да поремети базу података, стога поступајте с опрезом.",
+       "delete-toobig": "Ова страница има велику историју измена, преко $1 {{PLURAL:$1|измена|измене|измена}}.\nБрисање таквих страница је ограничено да би се спречило случајно оптерећење сервера.",
+       "delete-warning-toobig": "Ова страница има велику историју измена, преко $1 {{PLURAL:$1|измена|измене|измена}}.\nЊено брисање може да поремети базу података, стога поступајте с опрезом.",
        "deleteprotected": "Не можете да избришете ову страницу јер је заштићена.",
        "deleting-backlinks-warning": "<strong>Упозорење:</strong> бришете страницу која је укључена у [[Special:WhatLinksHere/{{FULLPAGENAME}}|друге странице]] или друге странице воде на њу.",
        "deleting-subpages-warning": "<strong>Упозорење:</strong> Страница коју желите избрисати има [[Special:PrefixIndex/{{FULLPAGENAME}}/|{{PLURAL:$1|подстраницу|$1 подстранице|$1 подстраница|51=преко 50 подстраница}}]].",
        "rollbacklinkcount-morethan": "врати више од $1 {{PLURAL:$1|измене|измене|измена}}",
        "rollbackfailed": "Враћање није успело",
        "rollback-missingparam": "Недостаје потребан параметар на захтеву.",
-       "rollback-missingrevision": "Не могу да учитам податке о ревизији.",
+       "rollback-missingrevision": "Не могу да учитам податке о измени.",
        "cantrollback": "Не могу да вратим измену.\nПоследњи аутор је уједно и једини.",
        "alreadyrolled": "Враћање последње измене странице [[:$1]] од стране {{GENDER:$2|корисника|кориснице|корисника}} [[User:$2|$2]] ([[User talk:$2|разговор]]{{int:pipe-separator}}[[Special:Contributions/$2|{{int:contribslink}}]]) није успело; неко други је у међувремену изменио или вратио страницу.\n\nПоследњу измену је {{GENDER:$3|направио|направила|направио}} [[User:$3|$3]] ([[User talk:$3|разговор]]{{int:pipe-separator}}[[Special:Contributions/$3|{{int:contribslink}}]]).",
        "editcomment": "Резиме измене је био: <em>$1</em>.",
-       "revertpage": "Враћене измене {{GENDER:$2|корисника|кориснице}} [[Special:Contributions/$2|$2]] ([[User talk:$2|разговор]]) на последњу ревизију {{GENDER:$1|корисника|кориснице}} [[User:$1|$1]]",
-       "revertpage-nouser": "Враћене измене скривеног корисника на последњу ревизију {{GENDER:$1|корисника|кориснице}} [[User:$1|$1]]",
-       "rollback-success": "Враћене измене {{GENDER:$1|корисника|кориснице}} {{GENDER:$3|$1}}  на последњу ревизију {{GENDER:$2|корисника|кориснице}} {{GENDER:$4|$2}}.",
-       "rollback-success-notify": "Враћене измене корисника $1;\nвраћено на последњу ревизију корисника $2. [$3 Прикажи промене]",
+       "revertpage": "Враћене измене {{GENDER:$2|корисника|кориснице}} [[Special:Contributions/$2|$2]] ([[User talk:$2|разговор]]) на последњу измену {{GENDER:$1|корисника|кориснице}} [[User:$1|$1]]",
+       "revertpage-nouser": "Враћене измене скривеног корисника на последњу измену {{GENDER:$1|корисника|кориснице}} [[User:$1|$1]]",
+       "rollback-success": "Враћене измене {{GENDER:$1|корисника|кориснице}} {{GENDER:$3|$1}}  на последњу измену {{GENDER:$2|корисника|кориснице}} {{GENDER:$4|$2}}.",
+       "rollback-success-notify": "Враћене измене корисника $1;\nвраћено на последњу измену корисника $2. [$3 Прикажи промене]",
        "sessionfailure-title": "Сесија је окончана",
        "sessionfailure": "Изгледа да постоји проблем с вашом сесијом;\nова радња је отказана да би се избегла злоупотреба.\nМолимо, поново пошаљите образац.",
        "changecontentmodel": "Промена модела садржаја странице",
        "restriction-level-all": "сви нивои",
        "undelete": "Преглед избрисаних страница",
        "undeletepage": "Преглед и враћање избрисаних страница",
-       "undeletepagetitle": "<strong>Следећи садржај се састоји од избрисаних ревизија странице [[:$1|$1]]</strong>.",
+       "undeletepagetitle": "<strong>Следећи садржај се састоји од избрисаних измена странице [[:$1|$1]]</strong>.",
        "viewdeletedpage": "Преглед избрисаних страница",
        "undeletepagetext": "{{PLURAL:$1|Следећа страница је избрисана, али је још у архиви и може бити враћена|Следеће $1 странице су избрисане, али су још у архиви и могу бити враћене|Следећих $1 страница је избрисано, али су још у архиви и могу бити враћене}}.\nАрхива се повремено чисти од оваквих страница.",
-       "undelete-fieldset-title": "Враћање ревизија",
-       "undeleteextrahelp": "Да бисте вратили целу историју странице, оставите све кућице неозначене и кликните на дугме <strong><em>{{int:undeletebtn}}</em></strong>.\nАко желите да вратите одређене ревизије, означите их и кликните на <strong><em>{{int:undeletebtn}}</em></strong>.",
-       "undeleterevisions": "{{PLURAL:$1|Избрисана је|Избрисане су|Избрисано је}} $1 {{PLURAL:$1|ревизија|ревизије|ревизија}}",
-       "undeletehistory": "Ако вратите страницу, све ревизије ће бити враћене њеној историји.\nАко је у међувремену направљена нова страница с истим називом, враћене ревизије ће се појавити у њеној ранијој историји.",
-       "undeleterevdel": "Враћање неће бити извршено ако је резултат тога делимично брисање последње ревизије.\nУ таквим случајевима морате искључити или открити најновије избрисане ревизије.",
-       "undeletehistorynoadmin": "Ова страница је избрисана.\nРазлог за брисање се налази испод, заједно са детаљима о кориснику који је уредио ову страницу пре брисања.\nТекст избрисаних ревизија је доступан само администраторима.",
-       "undelete-revision": "Избрисана ревизија странице $1 (дана $4; $5) од стране {{GENDER:$3|корисника|кориснице}} $3:",
-       "undeleterevision-missing": "Неважећа или недостајућа ревизија.\nМожда сте унели лош линк или је ревизија враћена или уклоњена из архиве.",
-       "undeleterevision-duplicate-revid": "Не могу вратити {{PLURAL:$1|ревизију|$1 ревизије|$1 ревизија}} јер се {{PLURAL:$1|њен|њихов}} <code>rev_id</code> већ користи.",
+       "undelete-fieldset-title": "Враћање измена",
+       "undeleteextrahelp": "Да бисте вратили целу историју странице, оставите све кућице неозначене и кликните на дугме <strong><em>{{int:undeletebtn}}</em></strong>.\nАко желите да вратите одређене измене, означите их и кликните на <strong><em>{{int:undeletebtn}}</em></strong>.",
+       "undeleterevisions": "{{PLURAL:$1|Избрисана је|Избрисане су|Избрисано је}} $1 {{PLURAL:$1|измена|измене|измена}}",
+       "undeletehistory": "Ако вратите страницу, све измене ће бити враћене њеној историји.\nАко је у међувремену направљена нова страница с истим називом, враћене измене ће се појавити у њеној ранијој историји.",
+       "undeleterevdel": "Враћање неће бити извршено ако је резултат тога делимично брисање последње измене.\nУ таквим случајевима морате искључити или открити најновије избрисане измене.",
+       "undeletehistorynoadmin": "Ова страница је избрисана.\nРазлог за брисање се налази испод, заједно са детаљима о кориснику који је уредио ову страницу пре брисања.\nТекст избрисаних измена је доступан само администраторима.",
+       "undelete-revision": "Избрисана измена странице $1 (дана $4; $5) од стране {{GENDER:$3|корисника|кориснице}} $3:",
+       "undeleterevision-missing": "Неважећа или недостајућа измена.\nМожда сте унели лош линк или је измена враћена или уклоњена из архиве.",
+       "undeleterevision-duplicate-revid": "Не могу вратити {{PLURAL:$1|измену|$1 измене|$1 измена}} јер се {{PLURAL:$1|њен|њихов}} <code>rev_id</code> већ користи.",
        "undelete-nodiff": "Претходне измене нису пронађене.",
        "undeletebtn": "Врати",
        "undeletelink": "погледај/врати",
        "undelete-search-full": "Прикажи наслове који садрже:",
        "undelete-search-submit": "Претражи",
        "undelete-no-results": "Није пронађена одговарајућа страница у архиви брисања.",
-       "undelete-filename-mismatch": "Не могу да вратим ревизију датотеке од $1: назив датотеке се не поклапа.",
+       "undelete-filename-mismatch": "Не могу да вратим измену датотеке од $1: назив датотеке се не поклапа.",
        "undelete-bad-store-key": "Не могу да вратим измену датотеке од $1: датотека је недостајала пре брисања.",
        "undelete-cleanup-error": "Грешка при брисању некоришћене архиве „$1“.",
        "undelete-missing-filearchive": "Не могу да вратим архиву с ИБ $1 јер се она не налази у бази података.\nМожда је већ била враћена.",
        "undelete-error": "Дошло је до грешке при враћању избрисане странице",
        "undelete-error-short": "Грешка при враћању датотеке: $1",
        "undelete-error-long": "Дошло је до грешке при враћању датотеке:\n\n$1",
-       "undelete-show-file-confirm": "Јесте ли сигурни да желите да погледате избрисану ревизију датотеке „<nowiki>$1</nowiki>“ од $2 у $3?",
+       "undelete-show-file-confirm": "Јесте ли сигурни да желите да погледате избрисану измену датотеке „<nowiki>$1</nowiki>“ од $2 у $3?",
        "undelete-show-file-submit": "Да",
        "namespace": "Именски простор:",
        "invert": "Обрни избор",
        "sp-contributions-blocked-notice-anon": "Ова IP адреса је тренутно блокирана.\nПоследњи унос у евиденцији блокирања је наведен испод као референца:",
        "sp-contributions-search": "Претрага доприноса",
        "sp-contributions-username": "IP адреса или корисничко име:",
-       "sp-contributions-toponly": "Прикажи само измене које су најновије ревизије",
+       "sp-contributions-toponly": "Прикажи само измене које су најновије измене",
        "sp-contributions-newonly": "Само измене којима су направљене нове странице",
        "sp-contributions-hideminor": "Сакриј мање измене",
        "sp-contributions-submit": "Претражи",
        "move-over-sharedrepo": "[[:$1]] се налази на дељеном складишту. Ако преместите датотеку на овај наслов, то ће заменити дељену датотеку.",
        "file-exists-sharedrepo": "Наведени назив датотеке се већ користи у дељеном складишту.\nИзаберите други назив.",
        "export": "Извоз страница",
-       "exporttext": "Можете да извезете текст и историју измена одређене странице или скупа страница уклљених у XML формату.\nОво онда може да буде увезено у други вики који користи Медијавики софтвер преко [[Special:Import|странице за увоз]].\n\nДа бисте извезли странице, унесите називе у оквиру испод, с једним насловом по реду, и изаберите да ли желите актуелну ревизију и све остале, или само актуелну ревизију с подацима о последњој измени.\n\nУ другом случају, можете користити и линк, на пример [[{{#Special:Export}}/{{MediaWiki:Mainpage}}]] за страницу [[{{MediaWiki:Mainpage}}]].",
+       "exporttext": "Можете да извезете текст и историју измена одређене странице или скупа страница уклљених у XML формату.\nОво онда може да буде увезено у други вики који користи Медијавики софтвер преко [[Special:Import|странице за увоз]].\n\nДа бисте извезли странице, унесите називе у оквиру испод, с једним насловом по реду, и изаберите да ли желите актуелну измену и све остале, или само актуелну измену с подацима о последњој измени.\n\nУ другом случају, можете користити и линк, на пример [[{{#Special:Export}}/{{MediaWiki:Mainpage}}]] за страницу [[{{MediaWiki:Mainpage}}]].",
        "exportall": "Извези све странице",
-       "exportcuronly": "Укључи само актуелну ревизију, не целу историју",
+       "exportcuronly": "Укључи само актуелну измену, не целу историју",
        "exportnohistory": "----\n'''Напомена:''' извоз пуне историје страница преко овог обрасца је онемогућено из техничких разлога.",
        "exportlistauthors": "Укључи целокупан списак доприносилаца за сваку страницу",
        "export-submit": "Извези",
        "thumbnail_image-failure-limit": "Било је превише недавних неуспелих покушаја ($1 или више) рендеровања ове сличице. Покушајте поново касније.",
        "import": "Увоз страница",
        "importinterwiki": "Увоз са другог викија",
-       "import-interwiki-text": "Изаберите вики и наслов странице за увоз.\nДатуми ревизија и имена уредника ће бити сачувани.\nСве радње при увозу с других викија су евидентиране у [[Special:Log/import|евиденцији увоза]].",
+       "import-interwiki-text": "Изаберите вики и наслов странице за увоз.\nДатуми измена и имена уредника ће бити сачувани.\nСве радње при увозу с других викија су евидентиране у [[Special:Log/import|евиденцији увоза]].",
        "import-interwiki-sourcewiki": "Изворна вики:",
        "import-interwiki-sourcepage": "Изворна страница:",
-       "import-interwiki-history": "Копирај све ревизије историје за ову страницу",
+       "import-interwiki-history": "Копирај све измене историје за ову страницу",
        "import-interwiki-templates": "Укључи све шаблоне",
        "import-interwiki-submit": "Увези",
        "import-mapping-default": "Исто као и изворне странице",
        "import-comment": "Коментар:",
        "importtext": "Извезите датотеку сa изворног викија користећи [[Special:Export|алат за извоз]].\nСачувајте је на рачунар и оптремите овде.",
        "importstart": "Увозим странице…",
-       "import-revision-count": "$1 {{PLURAL:$1|ревизија|ревизије|ревизија}}",
+       "import-revision-count": "$1 {{PLURAL:$1|измена|измене|измена}}",
        "importnopages": "Нема страница за увоз.",
        "imported-log-entries": "{{PLURAL:$1|Увезена је $1 ставка извештаја|Увезене су $1 ставке извештаја|Увезено је $1 ставки извештаја}}.",
        "importfailed": "Неуспешан увоз: <nowiki>$1</nowiki>",
        "importuploaderrortemp": "Не могу да пошаљем датотеку за увоз.\nНедостаје привремена фасцикла.",
        "import-parse-failure": "Погрешно рашчлањивање XML-а.",
        "import-noarticle": "Нема странице за увоз!",
-       "import-nonewrevisions": "Ниједна ревизија није увезена (све су већ присутне или су прескочене због грешака).",
+       "import-nonewrevisions": "Ниједна измена није увезена (све су већ присутне или су прескочене због грешака).",
        "xml-error-string": "$1 у реду $2, колона $3 (бајт $4): $5",
        "import-upload": "Отпремање XML података",
        "import-token-mismatch": "Губитак података о сесији.\n\nМожда сте одјављени. '''Молимо Вас проверите да ли сте још увек пријављени и покушајте поново'''.\n\nАко и даље не ради, покушајте се [[Special:UserLogout|одјавити]] и поново пријавити и проверите да ли ваш веб-претраживач дозвољава колачиће са овог сајта.",
        "import-error-interwiki": "Не могу да увезем страницу „$1“ јер је њен назив резервисан за спољно повезивање (међувики).",
        "import-error-special": "Не могу да увезем страницу „$1“ јер она припада посебном именском простору које не прихвата странице.",
        "import-error-invalid": "Страница „$1“ није увезена јер је име под којим се треба увости неважеће на овом викију.",
-       "import-error-unserialize": "Не могу да десеријализујем ревизију $2 странице $1. Записано је да ревизија користи $3 модел садржаја у $4 формату.",
+       "import-error-unserialize": "Не могу да десеријализујем измену $2 странице $1. Записано је да измена користи $3 модел садржаја у $4 формату.",
        "import-options-wrong": "{{PLURAL:$2|Погрешна опција|Погрешне опције}}: <nowiki>$1</nowiki>",
        "import-rootpage-invalid": "Наведена основна страница има неважећи наслов.",
        "import-rootpage-nosubpage": "Именски простор „$1“ основне странице не дозвољава подстранице.",
        "importlogpage": "Евиденција увоза",
        "importlogpagetext": "Административни увози страница с историјама измена с других викија.",
-       "import-logentry-upload-detail": "$1 {{PLURAL:$1|ревизија увезена|ревизије увезене|ревизија увезено}}",
-       "import-logentry-interwiki-detail": "$1 {{PLURAL:$1|ревизија увезена|ревизије увезене|ревизија увезено}} из $2",
+       "import-logentry-upload-detail": "$1 {{PLURAL:$1|измена увезена|измене увезене|измена увезено}}",
+       "import-logentry-interwiki-detail": "$1 {{PLURAL:$1|измена увезена|измене увезене|измена увезено}} из $2",
        "javascripttest": "Тестирање јавасктипта",
        "javascripttest-pagetext-unknownaction": "Непозната радња „$1“.",
        "javascripttest-qunit-intro": "Погледајте [$1 документацију за тестирање] на mediawiki.org.",
        "tooltip-ca-edit": "Уредите ову страницу",
        "tooltip-ca-addsection": "Започните нови одељак",
        "tooltip-ca-viewsource": "Ова страница је закључана. \nМожете да погледате њен изворник",
-       "tooltip-ca-history": "Претходне ревизије ове странице",
+       "tooltip-ca-history": "Претходне измене ове странице",
        "tooltip-ca-protect": "Заштитите ову страницу",
        "tooltip-ca-unprotect": "Промени заштиту ове странице",
        "tooltip-ca-delete": "Избришите ову страницу",
        "tooltip-t-upload": "Отпремите датотеке",
        "tooltip-t-specialpages": "Списак свих посебних страница",
        "tooltip-t-print": "Верзија ове странице за штампање",
-       "tooltip-t-permalink": "Трајни линк ка овој ревизији странице",
+       "tooltip-t-permalink": "Трајни линк ка овој измени странице",
        "tooltip-ca-nstab-main": "Погледајте страницу са садржајем",
        "tooltip-ca-nstab-user": "Погледајте корисничку страницу",
        "tooltip-ca-nstab-media": "Погледајте медијску страницу",
        "tooltip-publish": "Објавите своје измене",
        "tooltip-preview": "Прегледајте своје промене. Користите ово дугме пре чувања.",
        "tooltip-diff": "Погледајте које промене сте направили на тексту",
-       "tooltip-compareselectedversions": "Погледаjте разлике између две изабране ревизије ове странице",
+       "tooltip-compareselectedversions": "Погледаjте разлике између две изабране измене ове странице",
        "tooltip-watch": "Додајте ову страницу на свој списак надгледања",
        "tooltip-watchlistedit-normal-submit": "Уклоните наслове",
        "tooltip-watchlistedit-raw-submit": "Ажурирај списак",
        "tooltip-rollback": "„Врати“ враћа измене последњег доприносиоца ове странице једним кликом",
        "tooltip-undo": "„Поништи” враћа ову измену и отвара образац за уређивање у претпрегледном моду. Дозвољава додавање разлога у резимеу.",
        "tooltip-preferences-save": "Сачувај подешавања",
-       "tooltip-summary": "Унесите кратак резиме",
+       "tooltip-summary": "Унесите кратак опис",
        "interlanguage-link-title": "$1 — $2",
        "interlanguage-link-title-nonlang": "$1 — $2",
        "common.css": "/* CSS постављен овде ће се одразити на све теме */",
        "spamprotectiontext": "Филтера против нежељених порука је блокирао чување ове странице.\nОво је вероватно изазвано линком до спољашњег сајта који се налази на црном списку.",
        "spamprotectionmatch": "Следећи текст је активирао наш филтер за нежељене поруке: $1",
        "spambot_username": "Чишћење непожељних порука у Медијавикији",
-       "spam_reverting": "Враћам на последњу ревизију која не садржи линкове до $1",
-       "spam_blanking": "Све ревизије садрже линкове до $1. Празним",
-       "spam_deleting": "Све ревизије садрже линкове до $1. Бришем",
+       "spam_reverting": "Враћам на последњу измену која не садржи линкове до $1",
+       "spam_blanking": "Све измене садрже линкове до $1. Празним",
+       "spam_deleting": "Све измене садрже линкове до $1. Бришем",
        "simpleantispam-label": "Провера против нежељеног садржаја. \n<strong>Не</strong> попуњавајте ово!",
        "pageinfo-title": "Информације за „$1“",
-       "pageinfo-not-current": "Нажалост, немогуће је навести ове инфомације за старије ревизије.",
+       "pageinfo-not-current": "Нажалост, немогуће је навести ове инфомације за старије измене.",
        "pageinfo-header-basic": "Основне информације",
        "pageinfo-header-edits": "Историја измена",
        "pageinfo-header-restrictions": "Заштита странице",
        "markaspatrolledtext": "Означи страницу као патролирану",
        "markaspatrolledtext-file": "Означи ову верзију датотеке као патролирану",
        "markedaspatrolled": "Означено као патролирано",
-       "markedaspatrolledtext": "Изабрана ревизија странице [[:$1]] означена је као патролирана.",
+       "markedaspatrolledtext": "Изабрана измена странице [[:$1]] означена је као патролирана.",
        "rcpatroldisabled": "Патролирање скорашњих измена је онемогућено",
        "rcpatroldisabledtext": "Могућност патролирања скорашњих измена је актуелно онемогућена.",
        "markedaspatrollederror": "Не могу да означим као патролирано.",
-       "markedaspatrollederrortext": "Морате навести ревизију да бисте је означили као патролирану.",
+       "markedaspatrollederrortext": "Морате навести измену да бисте је означили као патролирану.",
        "markedaspatrollederror-noautopatrol": "Не можете да означите своје промене као патролиране.",
        "markedaspatrollednotify": "Ова измена на страници „$1” означена је као патролирана.",
        "markedaspatrollederrornotify": "Означавање ове измене патролираном није успело.",
        "patrol-log-page": "Евиденција патролирања",
-       "patrol-log-header": "Ово је евиденција патролираних ревизија.",
+       "patrol-log-header": "Ово је евиденција патролираних измена.",
        "confirm-markpatrolled-button": "У реду",
-       "confirm-markpatrolled-top": "Означити ревизију $3 странице $2 као патролирану?",
-       "deletedrevision": "Избрисана стара ревизија $1.",
+       "confirm-markpatrolled-top": "Означити измену $3 странице $2 као патролирану?",
+       "deletedrevision": "Избрисана стара измена $1.",
        "filedeleteerror-short": "Грешка при брисању датотеке: $1",
        "filedeleteerror-long": "Дошло је до грешака при брисању датотеке:\n\n$1",
        "filedelete-missing": "Не могу да избришем датотеку „$1“ јер не постоји.",
-       "filedelete-old-unregistered": "Наведена ревизија датотеке „$1“ не постоји у бази података.",
+       "filedelete-old-unregistered": "Наведена измена датотеке „$1“ не постоји у бази података.",
        "filedelete-current-unregistered": "Наведена датотека „$1“ не постоји у бази података.",
        "filedelete-archive-read-only": "Сервер не може да пише по складишној фасцикли ($1).",
        "previousdiff": "← Старија измена",
        "confirm-purge-title": "Освежи ову страницу",
        "confirm_purge_button": "У реду",
        "confirm-purge-top": "Очистити кеш ове странице?",
-       "confirm-purge-bottom": "Освежавање странице чисти кеш и намеће најновију ревизију.",
+       "confirm-purge-bottom": "Освежавање странице чисти кеш и намеће најновију измену.",
        "confirm-watch-button": "У реду",
        "confirm-watch-top": "Додати ову страницу у списак надгледања?",
        "confirm-unwatch-button": "У реду",
        "version-libraries-license": "Лиценца",
        "version-libraries-description": "Опис",
        "version-libraries-authors": "Аутори",
-       "redirect": "Преусмерење на датотеку, корисника, страницу, ревизију или евиденцију (ID)",
-       "redirect-summary": "Ова посебна страница преусмерава до датотеке (с датим именом датотеке), странице (с датим ID-ом ревизије или ID-ом странице), корисничке странице (с датим нумеричким корисничким ID-ом), или уноса у дневнику (с датим дневничким ID-ом). Употреба: [[{{#Special:Redirect}}/file/Example.jpg]], [[{{#Special:Redirect}}/page/64308]], [[{{#Special:Redirect}}/revision/328429]], [[{{#Special:Redirect}}/user/101]], or [[{{#Special:Redirect}}/logid/186]].",
+       "redirect": "Преусмерење на датотеку, корисника, страницу, измену или евиденцију (ID)",
+       "redirect-summary": "Ова посебна страница преусмерава до датотеке (с датим именом датотеке), странице (с датим ID-ом измене или ID-ом странице), корисничке странице (с датим нумеричким корисничким ID-ом), или уноса у дневнику (с датим дневничким ID-ом). Употреба: [[{{#Special:Redirect}}/file/Example.jpg]], [[{{#Special:Redirect}}/page/64308]], [[{{#Special:Redirect}}/revision/328429]], [[{{#Special:Redirect}}/user/101]], or [[{{#Special:Redirect}}/logid/186]].",
        "redirect-submit": "Иди",
        "redirect-lookup": "Тип вредности:",
        "redirect-value": "Вредност:",
        "tags-delete-reason": "Разлог:",
        "tags-delete-submit": "Неповратно избриши ову ознаку",
        "tags-delete-not-found": "Ознака „$1“ не постоји.",
-       "tags-delete-too-many-uses": "Ознака „$1” је примењена на више од $2 {{PLURAL:$2|ревизије|ревизија}}, што значи да се не може избрисати.",
+       "tags-delete-too-many-uses": "Ознака „$1” је примењена на више од $2 {{PLURAL:$2|измене|измена}}, што значи да се не може избрисати.",
        "tags-delete-no-permission": "Немате дозволу да бришете ознаке промена.",
        "tags-activate-title": "Активирање ознака",
        "tags-activate-question": "Активирате ознаку „$1“.",
        "tags-deactivate-not-allowed": "Није могуће деактивирати ознаку „$1“.",
        "tags-deactivate-submit": "Декативирај",
        "tags-apply-no-permission": "Немате дозволу да примените ознаке промена заједно са својим променама.",
-       "tags-update-no-permission": "Немате дозволу да додате или уклоните ознаке промена из појединачних ревизија или уноса у евиденцији.",
+       "tags-update-no-permission": "Немате дозволу да додате или уклоните ознаке промена из појединачних измена или уноса у евиденцији.",
        "tags-update-blocked": "Не можете додавати нити уклањати ознаке измена док {{GENDER:$1|сте}} блокирани.",
        "tags-update-add-not-allowed-one": "Није дозвољено да се ознака „$1” додаје ручно.",
        "tags-edit-title": "Уреди ознаке",
        "tags-edit-manage-link": "Управљај ознакама",
-       "tags-edit-revision-selected": "{{PLURAL:$1|Изабрана ревизија|Изабране ревизије}} странице [[:$2]]:",
-       "tags-edit-revision-legend": "Додајте или уклоните ознаке са {{PLURAL:$1|ове ревизије|свих $1 ревизија}}",
+       "tags-edit-revision-selected": "{{PLURAL:$1|Изабрана измена|Изабране измене}} странице [[:$2]]:",
+       "tags-edit-revision-legend": "Додајте или уклоните ознаке са {{PLURAL:$1|ове измене|свих $1 измена}}",
        "tags-edit-existing-tags": "Постојеће ознаке:",
        "tags-edit-existing-tags-none": "<em>Нема</em>",
        "tags-edit-new-tags": "Нове ознаке:",
        "tags-edit-chosen-placeholder": "Изабери неке ознаке",
        "tags-edit-chosen-no-results": "Одговарајуће ознаке нису пронађене",
        "tags-edit-reason": "Разлог:",
-       "tags-edit-revision-submit": "Примени промене {{PLURAL:$1|овој ревизији|$1 ревизијама}}",
+       "tags-edit-revision-submit": "Примени промене {{PLURAL:$1|овој измени|$1 изменама}}",
        "tags-edit-success": "Промене су примењене.",
        "tags-edit-failure": "Не могу да применим измене:\n$1",
-       "tags-edit-nooldid-title": "Неважећа одредишна ревизија",
+       "tags-edit-nooldid-title": "Неважећа одредишна измена",
        "tags-edit-none-selected": "Изаберите бар једну ознаку коју треба додати или уклонити.",
        "comparepages": "Упоређивање страница",
        "compare-page1": "Страница 1",
        "compare-title-not-exists": "Наведени наслов не постоји.",
        "compare-revision-not-exists": "Ревизија коју сте навели не постоји.",
        "diff-form": "Разлике",
-       "diff-form-oldid": "ID старе ревизије (опционално)",
+       "diff-form-oldid": "ID старе измене (опционално)",
        "diff-form-revid": "ID измене или разлике",
        "diff-form-submit": "Прикажи разлике",
        "permanentlink": "Трајни линк",
-       "permanentlink-revid": "ID ревизије",
+       "permanentlink-revid": "ID измене",
        "permanentlink-submit": "Иди на измену",
        "dberr-problems": "Дошло је до техничких проблема.",
        "dberr-again": "Сачекајте неколико минута и поново учитајте страницу.",
        "logentry-delete-delete_redir": "$1 је {{GENDER:$2|обрисао|обрисала}} преусмерење $3 преписивањем",
        "logentry-delete-restore": "$1 је {{GENDER:$2|вратио|вратила}} страницу $3 ($4)",
        "logentry-delete-restore-nocount": "$1 је {{GENDER:$2|вратио|вратила}} страницу $3",
-       "restore-count-revisions": "{{PLURAL:$1|1 ревизија|$1 ревизије|$1 ревизија}}",
+       "restore-count-revisions": "{{PLURAL:$1|1 измена|$1 измене|$1 измена}}",
        "restore-count-files": "{{PLURAL:$1|1 датотека|$1 датотеке|$1 датотека}}",
        "logentry-delete-event": "$1 је {{GENDER:$2|променио|променила}} видљивост {{PLURAL:$5|догађаја|$5 догађаја}} у евиденцији на страници „$3”: $4",
-       "logentry-delete-revision": "$1 је {{GENDER:$2|променио|променила}} видљивост {{PLURAL:$5|ревизије|$5 ревизије|$5 ревизија}} на страници $3: $4",
+       "logentry-delete-revision": "$1 је {{GENDER:$2|променио|променила}} видљивост {{PLURAL:$5|измене|$5 измене|$5 измена}} на страници $3: $4",
        "logentry-delete-event-legacy": "$1 је {{GENDER:$2|променио|променила}} видљивост догађаја у евиденцији на страници „$3”",
-       "logentry-delete-revision-legacy": "$1 је {{GENDER:$2|променио|променила}} видљивост ревизија на страници $3",
+       "logentry-delete-revision-legacy": "$1 је {{GENDER:$2|променио|променила}} видљивост измена на страници $3",
        "logentry-suppress-delete": "$1 је {{GENDER:$2|потиснуо|потиснула}} страницу $3",
        "logentry-suppress-event": "$1 је тајно {{GENDER:$2|променио|променила}} видљивост {{PLURAL:$5|догађаја|$5 догађаја}} у евиденцији на страници „$3”: $4",
-       "logentry-suppress-revision": "$1 је тајно {{GENDER:$2|променио|променила}} видљивост {{PLURAL:$5|ревизије|$5 ревизија}} на страници $3: $4",
+       "logentry-suppress-revision": "$1 је тајно {{GENDER:$2|променио|променила}} видљивост {{PLURAL:$5|измене|$5 измена}} на страници $3: $4",
        "logentry-suppress-event-legacy": "$1 је потајно {{GENDER:$2|променио|променила}} видљивост догађаја у евиденцији на страници „$3”",
-       "logentry-suppress-revision-legacy": "$1 је тајно {{GENDER:$2|променио|променила}} видљивост ревизија на страници $3",
+       "logentry-suppress-revision-legacy": "$1 је тајно {{GENDER:$2|променио|променила}} видљивост измена на страници $3",
        "revdelete-content-hid": "садржај је сакривен",
-       "revdelete-summary-hid": "резиме измене је сакривен",
+       "revdelete-summary-hid": "опис измене је сакривен",
        "revdelete-uname-hid": "корисничко име је сакривено",
        "revdelete-content-unhid": "садржај је откривен",
-       "revdelete-summary-unhid": "резиме измене је откривен",
+       "revdelete-summary-unhid": "опис измене је откривен",
        "revdelete-uname-unhid": "корисничко име је откривено",
        "revdelete-restricted": "примењена ограничења за администраторе",
        "revdelete-unrestricted": "уклоњена ограничења за администраторе",
        "logentry-suppress-block": "$1 је {{GENDER:$2|блокирао|блокирала}} {{GENDER:$4|$3}} у трајању од $5 $6",
        "logentry-suppress-reblock": "$1 је {{GENDER:$2|променио|променила}} подешавања за блокирање {{GENDER:$4|корисника|кориснице}} {{GENDER:$4|$3}} у трајању од $5 $6",
        "logentry-import-upload": "$1 је {{GENDER:$2|увезао|увезла}} $3 отпремањем датотеке",
-       "logentry-import-upload-details": "$1 је {{GENDER:$2|увезао|увезла}} $3 отпремањем датотеке ($4 {{PLURAL:$4|ревизија|ревизије|ревизија}})",
+       "logentry-import-upload-details": "$1 је {{GENDER:$2|увезао|увезла}} $3 отпремањем датотеке ($4 {{PLURAL:$4|измена|измене|измена}})",
        "logentry-import-interwiki": "$1 је {{GENDER:$2|увезао|увезла}} $3 с другог викија",
-       "logentry-import-interwiki-details": "$1 је {{GENDER:$2|увезао|увезла}} $3 из $5 ($4 {{PLURAL:$4|ревизија|ревизије|ревизија}})",
+       "logentry-import-interwiki-details": "$1 је {{GENDER:$2|увезао|увезла}} $3 из $5 ($4 {{PLURAL:$4|измена|измене|измена}})",
        "logentry-merge-merge": "$1 је {{GENDER:$2|спојио|спојила}} $3 у $4 (све до измене $5)",
        "logentry-move-move": "$1 је {{GENDER:$2|преместио|преместила}} страницу $3 на $4",
        "logentry-move-move-noredirect": "$1 је {{GENDER:$2|преместио|преместила}} страницу $3 на $4 без остављања преусмерења",
        "logentry-move-move_redir": "$1 је {{GENDER:$2|преместио|преместила}} страницу $3 на $4 преко преусмерења",
        "logentry-move-move_redir-noredirect": "$1 је {{GENDER:$2|преместио|преместила}} страницу $3 на $4 преко преусмерења без остављања преусмерења",
-       "logentry-patrol-patrol": "$1 је {{GENDER:$2|означио|означила}} ревизију $4 странице $3 као патролирану",
-       "logentry-patrol-patrol-auto": "$1 је аутоматски {{GENDER:$2|означио|означила}} ревизију $4 странице $3 као патролирану",
+       "logentry-patrol-patrol": "$1 је {{GENDER:$2|означио|означила}} измену $4 странице $3 као патролирану",
+       "logentry-patrol-patrol-auto": "$1 је аутоматски {{GENDER:$2|означио|означила}} измену $4 странице $3 као патролирану",
        "logentry-newusers-newusers": "$1 је {{GENDER:$2|отворио|отворила}} кориснички налог",
        "logentry-newusers-create": "$1 је {{GENDER:$2|отворио|отворила}} кориснички налог",
        "logentry-newusers-create2": "$1 је {{GENDER:$2|отворио|отворила}} кориснички налог $3",
        "log-name-managetags": "Евиденција управљања ознакама",
        "log-description-managetags": "На овој страници се налази списак измена у вези [[Special:Tags|ознака]]. Евиденција садржи само радње које су ручно извршили администратори; уноси за ознаке које је направио или избрисао вики софтвера се не налазе у овој евиденцији.",
        "logentry-managetags-create": "$1 је {{GENDER:$2|направио|направила}} ознаку „$4“",
-       "logentry-managetags-delete": "$1 је {{GENDER:$2|обрисао|обрисала}} ознаку „$4“ (уклоњена је из $5 {{PLURAL:$5|ревизије или уноса у евиденцији|ревизија и/или уноса у евиденцији}})",
+       "logentry-managetags-delete": "$1 је {{GENDER:$2|обрисао|обрисала}} ознаку „$4“ (уклоњена је из $5 {{PLURAL:$5|измене или уноса у евиденцији|измена и/или уноса у евиденцији}})",
        "logentry-managetags-activate": "$1 је {{GENDER:$2|активирао|активирала}} ознаку „$4“ за употребу од стране корисника и ботова",
        "logentry-managetags-deactivate": "$1 је {{GENDER:$2|деактивирао|деактивирала}} ознаку „$4“ за употребу од стране корисника и ботова",
        "log-name-tag": "Евиденција ознака",
-       "log-description-tag": "Ова страница приказује када су корисници додали/уклонили [[Special:Tags|ознаке]] с појединачних ревизија или уноса у евиденцијама. Евиденција не приказује радње означавања када су се догодиле приликом уређивања, брисања или сличне радње.",
+       "log-description-tag": "Ова страница приказује када су корисници додали/уклонили [[Special:Tags|ознаке]] с појединачних измена или уноса у евиденцијама. Евиденција не приказује радње означавања када су се догодиле приликом уређивања, брисања или сличне радње.",
        "rightsnone": "(нема)",
        "rightslogentry-temporary-group": "$1 (привремено, до $2)",
        "feedback-adding": "Додајем повратне информације на страницу…",
        "log-action-filter-delete-delete_redir": "преснимавање преусмерења",
        "log-action-filter-delete-restore": "враћање странице",
        "log-action-filter-delete-event": "брисање евиденције",
-       "log-action-filter-delete-revision": "брисање ревизија",
+       "log-action-filter-delete-revision": "брисање измена",
        "log-action-filter-import-interwiki": "Међувики увоз",
        "log-action-filter-import-upload": "Увоз постављањем XML-а",
        "log-action-filter-managetags-create": "прављење ознаке",
        "log-action-filter-rights-rights": "ручно",
        "log-action-filter-rights-autopromote": "аутоматски",
        "log-action-filter-suppress-event": "Скривање уноса у евиденцији",
-       "log-action-filter-suppress-revision": "скривање ревизија",
+       "log-action-filter-suppress-revision": "скривање измена",
        "log-action-filter-suppress-delete": "Скривање странице",
        "log-action-filter-suppress-block": "Скривање корисника блокирањем",
        "log-action-filter-suppress-reblock": "Скривање корисника поновним блокирањем",
        "restrictionsfield-label": "Дозвољени IP опсези:",
        "edit-error-short": "Грешка: $1",
        "edit-error-long": "Грешке:\n\n$1",
-       "revid": "ревизија $1",
+       "revid": "измена $1",
        "pageid": "ID странице: $1",
        "rawhtml-notallowed": "&lt;html&gt; тагови не могу да се користе ван нормалних страница.",
        "gotointerwiki": "Напуштање пројекта {{SITENAME}}",
index ad7a331..92ce940 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:",
        "unblocked-id": "$1 engeli çıkarıldı",
        "unblocked-ip": "[[Special:Contributions/$1|$1]] adlı kullanıcının engeli kaldırıldı.",
        "blocklist": "Engellenmiş kullanıcılar",
+       "autoblocklist-submit": "Ara",
+       "autoblocklist-legend": "Otomatik engellenenleri listele",
+       "autoblocklist-total-autoblocks": "Toplam otomatik engellenen kişi sayısı: $1",
+       "autoblocklist-empty": "Otomatik engellenenler listesi boş.",
        "ipblocklist": "Engellenmiş kullanıcılar",
        "ipblocklist-legend": "Engellenen kullanıcı ara",
        "blocklist-userblocks": "Hesap engellemelerini gizle",
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 8a52c64..830734a 100644 (file)
        "userrights-nodatabase": "El database $1 no l'esiste mìa o no l'è un database local.",
        "userrights-changeable-col": "Grupi che te pol canbiar",
        "userrights-unchangeable-col": "Grupi che no te pol canbiar",
+       "userrights-expiry-none": "No scade mai",
        "userrights-conflict": "Conflito de diriti utente! Aplica de novo le to modifiche.",
        "group": "Grupo:",
        "group-user": "Utenti",
        "group-autoconfirmed": "Utenti autoconvalidà",
        "group-bot": "Bot",
-       "group-sysop": "Aministradori",
+       "group-sysop": "'Ministradori",
+       "group-interface-admin": "'Ministradori de l'interfasa",
        "group-bureaucrat": "Burocrati",
        "group-suppress": "Supervisioni",
        "group-all": "(utenti)",
        "group-autoconfirmed-member": "utente autoconvalidà",
        "group-bot-member": "bot",
        "group-sysop-member": "aministrador",
+       "group-interface-admin-member": "{{GENDER:$1|'ministrador|'ministradora}} de l'interfasa",
        "group-bureaucrat-member": "burocrate",
-       "group-suppress-member": "supervision",
+       "group-suppress-member": "{{GENDER:$1|sopresor|sopresora}}",
        "grouppage-user": "{{ns:project}}:Utenti",
        "grouppage-autoconfirmed": "{{ns:project}}:Utenti autoconvalidà",
        "grouppage-bot": "{{ns:project}}:Bot",
-       "grouppage-sysop": "{{ns:project}}:Aministradori",
+       "grouppage-sysop": "{{ns:project}}:'Ministradori",
+       "grouppage-interface-admin": "{{ns:project}}:'Ministradori de l'interfasa",
        "grouppage-bureaucrat": "{{ns:project}}:Burocrati",
        "grouppage-suppress": "{{ns:project}}:Supervision",
        "right-read": "Lèzi pagine",
index ac26098..b09e609 100644 (file)
        "confirm-unwatch-top": "从监视列表中删除此页吗?",
        "confirm-rollback-button": "确定",
        "confirm-rollback-top": "回退此页面的编辑么?",
+       "confirm-mcrundo-title": "撤销一次更改",
+       "mcrundofailed": "撤销失败",
+       "mcrundo-missingparam": "请求中缺少必需参数。",
+       "mcrundo-changed": "在您访问的差异以来,此页面已更新。请复核新的更改。",
        "semicolon-separator": ";",
        "comma-separator": "、",
        "colon-separator": ":",
index 5f57a44..ff830f9 100644 (file)
        "view": "檢視",
        "view-foreign": "在 $1 檢視",
        "edit": "編輯",
-       "edit-local": "編輯本地說明",
+       "edit-local": "編輯本地描述",
        "create": "建立",
-       "create-local": "新增本地說明",
+       "create-local": "新增本地描述",
        "delete": "刪除",
        "undelete_short": "取消刪除 $1 項修訂",
        "viewdeleted_short": "檢視 {{PLURAL:$1|1 項已刪除的修訂|$1 項已刪除的修訂}}",
        "tags-edit-chosen-placeholder": "選擇一些標籤",
        "tags-edit-chosen-no-results": "沒有符合條件的標籤",
        "tags-edit-reason": "原因:",
-       "tags-edit-revision-submit": "套用變更至{{PLURAL:$1|此修訂|$1 筆修訂}}",
+       "tags-edit-revision-submit": "套用變更至{{PLURAL:$1|此修訂|$1筆修訂}}",
        "tags-edit-logentry-submit": "套用變更至{{PLURAL:$1|此日誌項目|$1 筆日誌項目}}",
        "tags-edit-success": "已套用變更。",
        "tags-edit-failure": "變更被無法套用:\n$1",
        "authmanager-create-disabled": "已關閉帳號自動建立。",
        "authmanager-create-from-login": "要建立您的帳號,請先填寫此欄位。",
        "authmanager-create-not-in-progress": "帳號建立尚未進行或連線階段資料已遺失,請重頭再開始。",
-       "authmanager-create-no-primary": "提供的憑證無使用在帳號建立。",
+       "authmanager-create-no-primary": "提供的憑證不能用於帳號建立。",
        "authmanager-link-no-primary": "提供的憑證無使用在帳號連結。",
        "authmanager-link-not-in-progress": "帳號連結尚未進行或連線階段資料已遺失,請重頭再開始。",
        "authmanager-authplugin-setpass-failed-title": "密碼變更失敗",
        "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 adfc2f8..d75c151 100644 (file)
@@ -6,6 +6,7 @@
     "doc": "jsduck",
     "postdoc": "grunt copy:jsduck",
     "selenium": "bash ./tests/selenium/selenium.sh",
+    "selenium-daily": "npm run selenium-test",
     "selenium-test": "wdio ./tests/selenium/wdio.conf.js"
   },
   "devDependencies": {
index c40ef93..14dab33 100644 (file)
@@ -286,6 +286,7 @@ return [
        ],
        'jquery.localize' => [
                'scripts' => 'resources/src/jquery/jquery.localize.js',
+               'deprecated' => 'Please use "jquery.i18n" instead.',
        ],
        'jquery.makeCollapsible' => [
                'dependencies' => [ 'jquery.makeCollapsible.styles' ],
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..f927aad 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 ) {
                                        init: function () {
                                                var raw, data;
 
-                                               if ( mw.loader.store.enabled !== null ) {
+                                               if ( this.enabled !== null ) {
                                                        // Init already ran
                                                        return;
                                                }
                                                        !mw.config.get( 'wgResourceLoaderStorageEnabled' )
                                                ) {
                                                        // Clear any previous store to free up space. (T66721)
-                                                       mw.loader.store.clear();
-                                                       mw.loader.store.enabled = false;
+                                                       this.clear();
+                                                       this.enabled = false;
                                                        return;
                                                }
                                                if ( mw.config.get( 'debug' ) ) {
                                                        // Disable module store in debug mode
-                                                       mw.loader.store.enabled = false;
+                                                       this.enabled = false;
                                                        return;
                                                }
 
                                                try {
                                                        // This a string we stored, or `null` if the key does not (yet) exist.
-                                                       raw = localStorage.getItem( mw.loader.store.getStoreKey() );
+                                                       raw = localStorage.getItem( this.getStoreKey() );
                                                        // If we get here, localStorage is available; mark enabled
-                                                       mw.loader.store.enabled = true;
+                                                       this.enabled = true;
                                                        // If null, JSON.parse() will cast to string and re-parse, still null.
                                                        data = JSON.parse( raw );
-                                                       if ( data && typeof data.items === 'object' && data.vary === mw.loader.store.getVary() ) {
-                                                               mw.loader.store.items = data.items;
+                                                       if ( data && typeof data.items === 'object' && data.vary === this.getVary() ) {
+                                                               this.items = data.items;
                                                                return;
                                                        }
                                                } catch ( e ) {
                                                //    We will disable the store below.
                                                if ( raw === undefined ) {
                                                        // localStorage failed; disable store
-                                                       mw.loader.store.enabled = false;
+                                                       this.enabled = false;
                                                }
                                        },
 
                                        get: function ( module ) {
                                                var key;
 
-                                               if ( !mw.loader.store.enabled ) {
+                                               if ( !this.enabled ) {
                                                        return false;
                                                }
 
                                                key = getModuleKey( module );
-                                               if ( key in mw.loader.store.items ) {
-                                                       mw.loader.store.stats.hits++;
-                                                       return mw.loader.store.items[ key ];
+                                               if ( key in this.items ) {
+                                                       this.stats.hits++;
+                                                       return this.items[ key ];
                                                }
-                                               mw.loader.store.stats.misses++;
+                                               this.stats.misses++;
                                                return false;
                                        },
 
                                        set: function ( module, descriptor ) {
                                                var args, key, src;
 
-                                               if ( !mw.loader.store.enabled ) {
+                                               if ( !this.enabled ) {
                                                        return;
                                                }
 
 
                                                if (
                                                        // Already stored a copy of this exact version
-                                                       key in mw.loader.store.items ||
+                                                       key in this.items ||
                                                        // Module failed to load
                                                        descriptor.state !== 'ready' ||
                                                        // Unversioned, private, or site-/user-specific
                                                }
 
                                                src = 'mw.loader.implement(' + args.join( ',' ) + ');';
-                                               if ( src.length > mw.loader.store.MODULE_SIZE_MAX ) {
+                                               if ( src.length > this.MODULE_SIZE_MAX ) {
                                                        return;
                                                }
-                                               mw.loader.store.items[ key ] = src;
-                                               mw.loader.store.update();
+                                               this.items[ key ] = src;
+                                               this.update();
                                        },
 
                                        /**
                                        prune: function () {
                                                var key, module;
 
-                                               for ( key in mw.loader.store.items ) {
+                                               for ( key in this.items ) {
                                                        module = key.slice( 0, key.indexOf( '@' ) );
                                                        if ( getModuleKey( module ) !== key ) {
-                                                               mw.loader.store.stats.expired++;
-                                                               delete mw.loader.store.items[ key ];
-                                                       } else if ( mw.loader.store.items[ key ].length > mw.loader.store.MODULE_SIZE_MAX ) {
+                                                               this.stats.expired++;
+                                                               delete this.items[ key ];
+                                                       } else if ( this.items[ key ].length > this.MODULE_SIZE_MAX ) {
                                                                // This value predates the enforcement of a size limit on cached modules.
-                                                               delete mw.loader.store.items[ key ];
+                                                               delete this.items[ key ];
                                                        }
                                                }
                                        },
                                         * Clear the entire module store right now.
                                         */
                                        clear: function () {
-                                               mw.loader.store.items = {};
+                                               this.items = {};
                                                try {
-                                                       localStorage.removeItem( mw.loader.store.getStoreKey() );
+                                                       localStorage.removeItem( this.getStoreKey() );
                                                } catch ( e ) {}
                                        },
 
index ee72166..03141b9 100644 (file)
@@ -55,7 +55,7 @@ window.isCompatible = function ( str ) {
                // https://caniuse.com/#feat=json / https://phabricator.wikimedia.org/T141344#2784065
                ( function () {
                        'use strict';
-                       return !this && !!Function.prototype.bind && !!window.JSON;
+                       return !this && Function.prototype.bind && window.JSON;
                }() ) &&
 
                // https://caniuse.com/#feat=queryselector
index 1037b37..0f241cd 100644 (file)
@@ -4,28 +4,37 @@
  */
 class GitInfoTest extends MediaWikiTestCase {
 
+       private static $tempDir;
+
        public static function setUpBeforeClass() {
-               mkdir( __DIR__ . '/../data/gitrepo' );
-               mkdir( __DIR__ . '/../data/gitrepo/1' );
-               mkdir( __DIR__ . '/../data/gitrepo/2' );
-               mkdir( __DIR__ . '/../data/gitrepo/3' );
-               mkdir( __DIR__ . '/../data/gitrepo/1/.git' );
-               mkdir( __DIR__ . '/../data/gitrepo/1/.git/refs' );
-               mkdir( __DIR__ . '/../data/gitrepo/1/.git/refs/heads' );
-               file_put_contents( __DIR__ . '/../data/gitrepo/1/.git/HEAD',
+               self::$tempDir = wfTempDir() . '/mw-phpunit-' . wfRandomString( 8 );
+               if ( !mkdir( self::$tempDir ) ) {
+                       self::$tempDir = null;
+                       throw new Exception( 'Unable to create temporary directory' );
+               }
+               mkdir( self::$tempDir . '/gitrepo' );
+               mkdir( self::$tempDir . '/gitrepo/1' );
+               mkdir( self::$tempDir . '/gitrepo/2' );
+               mkdir( self::$tempDir . '/gitrepo/3' );
+               mkdir( self::$tempDir . '/gitrepo/1/.git' );
+               mkdir( self::$tempDir . '/gitrepo/1/.git/refs' );
+               mkdir( self::$tempDir . '/gitrepo/1/.git/refs/heads' );
+               file_put_contents( self::$tempDir . '/gitrepo/1/.git/HEAD',
                        "ref: refs/heads/master\n" );
-               file_put_contents( __DIR__ . '/../data/gitrepo/1/.git/refs/heads/master',
+               file_put_contents( self::$tempDir . '/gitrepo/1/.git/refs/heads/master',
                        "0123456789012345678901234567890123abcdef\n" );
-               file_put_contents( __DIR__ . '/../data/gitrepo/1/.git/packed-refs',
+               file_put_contents( self::$tempDir . '/gitrepo/1/.git/packed-refs',
                        "abcdef6789012345678901234567890123456789 refs/heads/master\n" );
-               file_put_contents( __DIR__ . '/../data/gitrepo/2/.git',
+               file_put_contents( self::$tempDir . '/gitrepo/2/.git',
                        "gitdir: ../1/.git\n" );
-               file_put_contents( __DIR__ . '/../data/gitrepo/3/.git',
-                       'gitdir: ' . __DIR__ . "/../data/gitrepo/1/.git\n" );
+               file_put_contents( self::$tempDir . '/gitrepo/3/.git',
+                       'gitdir: ' . self::$tempDir . "/gitrepo/1/.git\n" );
        }
 
        public static function tearDownAfterClass() {
-               wfRecursiveRemoveDir( __DIR__ . '/../data/gitrepo' );
+               if ( self::$tempDir ) {
+                       wfRecursiveRemoveDir( self::$tempDir );
+               }
        }
 
        protected function setUp() {
@@ -68,7 +77,7 @@ class GitInfoTest extends MediaWikiTestCase {
        }
 
        public function testReadingHead() {
-               $dir = __DIR__ . '/../data/gitrepo/1';
+               $dir = self::$tempDir . '/gitrepo/1';
                $fixture = new GitInfo( $dir );
 
                $this->assertEquals( 'refs/heads/master', $fixture->getHead() );
@@ -76,7 +85,7 @@ class GitInfoTest extends MediaWikiTestCase {
        }
 
        public function testIndirection() {
-               $dir = __DIR__ . '/../data/gitrepo/2';
+               $dir = self::$tempDir . '/gitrepo/2';
                $fixture = new GitInfo( $dir );
 
                $this->assertEquals( 'refs/heads/master', $fixture->getHead() );
@@ -84,7 +93,7 @@ class GitInfoTest extends MediaWikiTestCase {
        }
 
        public function testIndirection2() {
-               $dir = __DIR__ . '/../data/gitrepo/3';
+               $dir = self::$tempDir . '/gitrepo/3';
                $fixture = new GitInfo( $dir );
 
                $this->assertEquals( 'refs/heads/master', $fixture->getHead() );
@@ -92,8 +101,8 @@ class GitInfoTest extends MediaWikiTestCase {
        }
 
        public function testReadingPackedRefs() {
-               $dir = __DIR__ . '/../data/gitrepo/1';
-               unlink( __DIR__ . '/../data/gitrepo/1/.git/refs/heads/master' );
+               $dir = self::$tempDir . '/gitrepo/1';
+               unlink( self::$tempDir . '/gitrepo/1/.git/refs/heads/master' );
                $fixture = new GitInfo( $dir );
 
                $this->assertEquals( 'refs/heads/master', $fixture->getHead() );
index 070a029..62094b6 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 
 class HtmlTest extends MediaWikiTestCase {
+       private $restoreWarnings;
 
        protected function setUp() {
                parent::setUp();
@@ -36,6 +37,15 @@ class HtmlTest extends MediaWikiTestCase {
                ] );
                $this->setUserLang( $langObj );
                $this->setContentLang( $langObj );
+               $this->restoreWarnings = false;
+       }
+
+       protected function tearDown() {
+               if ( $this->restoreWarnings ) {
+                       $this->restoreWarnings = false;
+                       Wikimedia\restoreWarnings();
+               }
+               parent::tearDown();
        }
 
        /**
@@ -802,21 +812,30 @@ class HtmlTest extends MediaWikiTestCase {
                        ],
                        'Ampersand' => [
                                'EXAMPLE.is(a && b);',
-                               '<script>/*<![CDATA[*/EXAMPLE.is(a && b);/*]]>*/</script>'
+                               '<script>EXAMPLE.is(a && b);</script>'
                        ],
                        'HTML' => [
                                'EXAMPLE.label("<a>");',
-                               '<script>/*<![CDATA[*/EXAMPLE.label("<a>");/*]]>*/</script>'
+                               '<script>EXAMPLE.label("<a>");</script>'
                        ],
-                       'Script closing string' => [
+                       'Script closing string (lower)' => [
                                'EXAMPLE.label("</script>");',
-                               // Broken: First </script> ends the script in HTML
-                               '<script>/*<![CDATA[*/EXAMPLE.label("</script>");/*]]>*/</script>'
+                               '<script>/* ERROR: Invalid script */</script>',
+                               true,
                        ],
-                       'CDATA string' => [
-                               'EXAMPLE.label("&> CDATA ]]>");',
-                               // Broken: Works in HTML, but is invalid XML.
-                               '<script>/*<![CDATA[*/EXAMPLE.label("&> CDATA ]]>");/*]]>*/</script>'
+                       'Script closing with non-standard attributes (mixed)' => [
+                               'EXAMPLE.label("</SCriPT and STyLE>");',
+                               '<script>/* ERROR: Invalid script */</script>',
+                               true,
+                       ],
+                       'HTML-comment-open and script-open' => [
+                               // In HTML, <script> contents aren't just plain CDATA until </script>,
+                               // there are levels of escaping modes, and the below sequence puts an
+                               // HTML parser in a state where </script> would *not* close the script.
+                               // https://html.spec.whatwg.org/multipage/parsing.html#script-data-double-escape-end-state
+                               'var a = "<!--<script>";',
+                               '<script>/* ERROR: Invalid script */</script>',
+                               true,
                        ],
                ];
        }
@@ -825,7 +844,11 @@ class HtmlTest extends MediaWikiTestCase {
         * @dataProvider provideInlineScript
         * @covers Html::inlineScript
         */
-       public function testInlineScript( $code, $expected ) {
+       public function testInlineScript( $code, $expected, $error = false ) {
+               if ( $error ) {
+                       Wikimedia\suppressWarnings();
+                       $this->restoreWarnings = true;
+               }
                $this->assertSame( Html::inlineScript( $code ), $expected );
        }
 }
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
         */
diff --git a/tests/phpunit/includes/Revision/RenderedRevisionTest.php b/tests/phpunit/includes/Revision/RenderedRevisionTest.php
new file mode 100644 (file)
index 0000000..a2a9d09
--- /dev/null
@@ -0,0 +1,454 @@
+<?php
+
+namespace MediaWiki\Tests\Revision;
+
+use Content;
+use Language;
+use MediaWiki\Revision\RenderedRevision;
+use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\SuppressedDataException;
+use MediaWiki\User\UserIdentityValue;
+use MediaWikiTestCase;
+use ParserOptions;
+use ParserOutput;
+use PHPUnit\Framework\MockObject\MockObject;
+use Title;
+use User;
+use WikitextContent;
+
+/**
+ * @covers \MediaWiki\Revision\RenderedRevision
+ */
+class RenderedRevisionTest extends MediaWikiTestCase {
+
+       /** @var callable */
+       private $combinerCallback;
+
+       public function setUp() {
+               parent::setUp();
+
+               $this->combinerCallback = function ( RenderedRevision $rr, array $hints = [] ) {
+                       return $this->combineOutput( $rr, $hints );
+               };
+       }
+
+       private function combineOutput( RenderedRevision $rrev, array $hints = [] ) {
+               // NOTE: the is a slightly simplified version of RevisionRenderer::combineSlotOutput
+
+               $withHtml = $hints['generate-html'] ?? true;
+
+               $revision = $rrev->getRevision();
+               $slots = $revision->getSlots()->getSlots();
+
+               $combinedOutput = new ParserOutput( null );
+               $slotOutput = [];
+               foreach ( $slots as $role => $slot ) {
+                       $out = $rrev->getSlotParserOutput( $role, $hints );
+                       $slotOutput[$role] = $out;
+
+                       $combinedOutput->mergeInternalMetaDataFrom( $out );
+                       $combinedOutput->mergeTrackingMetaDataFrom( $out );
+               }
+
+               if ( $withHtml ) {
+                       $html = '';
+                       /** @var ParserOutput $out */
+                       foreach ( $slotOutput as $role => $out ) {
+
+                               if ( $html !== '' ) {
+                                       // skip header for the first slot
+                                       $html .= "(($role))";
+                               }
+
+                               $html .= $out->getRawText();
+                               $combinedOutput->mergeHtmlMetaDataFrom( $out );
+                       }
+
+                       $combinedOutput->setText( $html );
+               }
+
+               return $combinedOutput;
+       }
+
+       /**
+        * @param $articleId
+        * @param $revisionId
+        * @return Title
+        */
+       private function getMockTitle( $articleId, $revisionId ) {
+               /** @var Title|MockObject $mock */
+               $mock = $this->getMockBuilder( Title::class )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+               $mock->expects( $this->any() )
+                       ->method( 'getNamespace' )
+                       ->will( $this->returnValue( NS_MAIN ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getText' )
+                       ->will( $this->returnValue( __CLASS__ ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getPrefixedText' )
+                       ->will( $this->returnValue( __CLASS__ ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getDBkey' )
+                       ->will( $this->returnValue( __CLASS__ ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getArticleID' )
+                       ->will( $this->returnValue( $articleId ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getLatestRevId' )
+                       ->will( $this->returnValue( $revisionId ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getContentModel' )
+                       ->will( $this->returnValue( CONTENT_MODEL_WIKITEXT ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getPageLanguage' )
+                       ->will( $this->returnValue( Language::factory( 'en' ) ) );
+               $mock->expects( $this->any() )
+                       ->method( 'isContentPage' )
+                       ->will( $this->returnValue( true ) );
+               $mock->expects( $this->any() )
+                       ->method( 'equals' )
+                       ->willReturnCallback( function ( Title $other ) use ( $mock ) {
+                               return $mock->getArticleID() === $other->getArticleID();
+                       } );
+               $mock->expects( $this->any() )
+                       ->method( 'userCan' )
+                       ->willReturnCallback( function ( $perm, User $user ) use ( $mock ) {
+                               return $user->isAllowed( $perm );
+                       } );
+
+               return $mock;
+       }
+
+       public function testGetRevisionParserOutput_new() {
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+               $text .= "* [[Link It]]\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
+
+               $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
+
+               $this->assertSame( $rev, $rr->getRevision() );
+               $this->assertSame( $options, $rr->getOptions() );
+
+               $html = $rr->getRevisionParserOutput()->getText();
+
+               $this->assertContains( 'page:' . __CLASS__, $html );
+               $this->assertContains( 'user:Frank', $html );
+               $this->assertContains( 'time:20180101000003', $html );
+       }
+
+       public function testGetRevisionParserOutput_current() {
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( 21 ); // current!
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
+
+               $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
+
+               $this->assertSame( $rev, $rr->getRevision() );
+               $this->assertSame( $options, $rr->getOptions() );
+
+               $html = $rr->getRevisionParserOutput()->getText();
+
+               $this->assertContains( 'page:' . __CLASS__, $html );
+               $this->assertContains( 'rev:21', $html );
+               $this->assertContains( 'user:Frank', $html );
+               $this->assertContains( 'time:20180101000003', $html );
+
+               $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+       }
+
+       public function testGetRevisionParserOutput_old() {
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( 11 ); // old!
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
+
+               $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
+
+               $this->assertSame( $rev, $rr->getRevision() );
+               $this->assertSame( $options, $rr->getOptions() );
+
+               $html = $rr->getRevisionParserOutput()->getText();
+
+               $this->assertContains( 'page:' . __CLASS__, $html );
+               $this->assertContains( 'rev:11', $html );
+               $this->assertContains( 'user:Frank', $html );
+               $this->assertContains( 'time:20180101000003', $html );
+
+               $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+       }
+
+       public function testGetRevisionParserOutput_suppressed() {
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( 11 ); // old!
+               $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
+
+               $this->setExpectedException( SuppressedDataException::class );
+               $rr->getRevisionParserOutput();
+       }
+
+       public function testGetRevisionParserOutput_privileged() {
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( 11 ); // old!
+               $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $sysop = $this->getTestUser( [ 'sysop' ] )->getUser(); // privileged!
+               $rr = new RenderedRevision(
+                       $title,
+                       $rev,
+                       $options,
+                       $this->combinerCallback,
+                       RevisionRecord::FOR_THIS_USER,
+                       $sysop
+               );
+
+               $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' );
+
+               $this->assertSame( $rev, $rr->getRevision() );
+               $this->assertSame( $options, $rr->getOptions() );
+
+               $html = $rr->getRevisionParserOutput()->getText();
+
+               // Suppressed content should be visible for sysops
+               $this->assertContains( 'page:' . __CLASS__, $html );
+               $this->assertContains( 'rev:11', $html );
+               $this->assertContains( 'user:Frank', $html );
+               $this->assertContains( 'time:20180101000003', $html );
+
+               $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+       }
+
+       public function testGetRevisionParserOutput_raw() {
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( 11 ); // old!
+               $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = new RenderedRevision(
+                       $title,
+                       $rev,
+                       $options,
+                       $this->combinerCallback,
+                       RevisionRecord::RAW
+               );
+
+               $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' );
+
+               $this->assertSame( $rev, $rr->getRevision() );
+               $this->assertSame( $options, $rr->getOptions() );
+
+               $html = $rr->getRevisionParserOutput()->getText();
+
+               // Suppressed content should be visible for sysops
+               $this->assertContains( 'page:' . __CLASS__, $html );
+               $this->assertContains( 'rev:11', $html );
+               $this->assertContains( 'user:Frank', $html );
+               $this->assertContains( 'time:20180101000003', $html );
+
+               $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+       }
+
+       public function testGetRevisionParserOutput_multi() {
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $rev->setContent( 'main', new WikitextContent( '[[Kittens]]' ) );
+               $rev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
+
+               $combinedOutput = $rr->getRevisionParserOutput();
+               $mainOutput = $rr->getSlotParserOutput( 'main' );
+               $auxOutput = $rr->getSlotParserOutput( 'aux' );
+
+               $combinedHtml = $combinedOutput->getText();
+               $mainHtml = $mainOutput->getText();
+               $auxHtml = $auxOutput->getText();
+
+               $this->assertContains( 'Kittens', $mainHtml );
+               $this->assertContains( 'Goats', $auxHtml );
+               $this->assertNotContains( 'Goats', $mainHtml );
+               $this->assertNotContains( 'Kittens', $auxHtml );
+               $this->assertContains( 'Kittens', $combinedHtml );
+               $this->assertContains( 'Goats', $combinedHtml );
+               $this->assertContains( 'aux', $combinedHtml, 'slot section header' );
+
+               $combinedLinks = $combinedOutput->getLinks();
+               $mainLinks = $mainOutput->getLinks();
+               $auxLinks = $auxOutput->getLinks();
+               $this->assertTrue( isset( $combinedLinks[NS_MAIN]['Kittens'] ), 'links from main slot' );
+               $this->assertTrue( isset( $combinedLinks[NS_MAIN]['Goats'] ), 'links from aux slot' );
+               $this->assertFalse( isset( $mainLinks[NS_MAIN]['Goats'] ), 'no aux links in main' );
+               $this->assertFalse( isset( $auxLinks[NS_MAIN]['Kittens'] ), 'no main links in aux' );
+       }
+
+       public function testNoHtml() {
+               /** @var MockObject|Content $mockContent */
+               $mockContent = $this->getMockBuilder( WikitextContent::class )
+                       ->setMethods( [ 'getParserOutput' ] )
+                       ->setConstructorArgs( [ 'Whatever' ] )
+                       ->getMock();
+               $mockContent->method( 'getParserOutput' )
+                       ->willReturnCallback( function ( Title $title, $revId = null,
+                               ParserOptions $options = null, $generateHtml = true
+                       ) {
+                               if ( !$generateHtml ) {
+                                       return new ParserOutput( null );
+                               } else {
+                                       $this->fail( 'Should not be called with $generateHtml == true' );
+                                       return null; // never happens, make analyzer happy
+                               }
+                       } );
+
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setContent( 'main', $mockContent );
+               $rev->setContent( 'aux', $mockContent );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
+
+               $output = $rr->getSlotParserOutput( 'main', [ 'generate-html' => false ] );
+               $this->assertFalse( $output->hasText(), 'hasText' );
+
+               $output = $rr->getRevisionParserOutput( [ 'generate-html' => false ] );
+               $this->assertFalse( $output->hasText(), 'hasText' );
+       }
+
+       public function testUpdateRevision() {
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+               $rev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
+
+               $firstOutput = $rr->getRevisionParserOutput();
+               $mainOutput = $rr->getSlotParserOutput( 'main' );
+               $auxOutput = $rr->getSlotParserOutput( 'aux' );
+
+               // emulate a saved revision
+               $savedRev = new MutableRevisionRecord( $title );
+               $savedRev->setContent( 'main', new WikitextContent( $text ) );
+               $savedRev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) );
+               $savedRev->setId( 23 ); // saved, new
+               $savedRev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $savedRev->setTimestamp( '20180101000003' );
+
+               $rr->updateRevision( $savedRev );
+
+               $this->assertNotSame( $mainOutput, $rr->getSlotParserOutput( 'main' ), 'Reset main' );
+               $this->assertSame( $auxOutput, $rr->getSlotParserOutput( 'aux' ), 'Keep aux' );
+
+               $updatedOutput = $rr->getRevisionParserOutput();
+               $html = $updatedOutput->getText();
+
+               $this->assertNotSame( $firstOutput, $updatedOutput, 'Reset merged' );
+               $this->assertContains( 'page:' . __CLASS__, $html );
+               $this->assertContains( 'rev:23', $html );
+               $this->assertContains( 'user:Frank', $html );
+               $this->assertContains( 'time:20180101000003', $html );
+               $this->assertContains( 'Goats', $html );
+
+               $rr->updateRevision( $savedRev ); // should do nothing
+               $this->assertSame( $updatedOutput, $rr->getRevisionParserOutput(), 'no more reset needed' );
+       }
+
+}
diff --git a/tests/phpunit/includes/Revision/RevisionRendererTest.php b/tests/phpunit/includes/Revision/RevisionRendererTest.php
new file mode 100644 (file)
index 0000000..ea195f1
--- /dev/null
@@ -0,0 +1,460 @@
+<?php
+
+namespace MediaWiki\Tests\Revision;
+
+use Content;
+use Language;
+use LogicException;
+use MediaWiki\Revision\RevisionRenderer;
+use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\User\UserIdentityValue;
+use MediaWikiTestCase;
+use ParserOptions;
+use ParserOutput;
+use PHPUnit\Framework\MockObject\MockObject;
+use Title;
+use User;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\ILoadBalancer;
+use WikitextContent;
+
+/**
+ * @covers \MediaWiki\Revision\RevisionRenderer
+ */
+class RevisionRendererTest extends MediaWikiTestCase {
+
+       /**
+        * @param $articleId
+        * @param $revisionId
+        * @return Title
+        */
+       private function getMockTitle( $articleId, $revisionId ) {
+               /** @var Title|MockObject $mock */
+               $mock = $this->getMockBuilder( Title::class )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+               $mock->expects( $this->any() )
+                       ->method( 'getNamespace' )
+                       ->will( $this->returnValue( NS_MAIN ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getText' )
+                       ->will( $this->returnValue( __CLASS__ ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getPrefixedText' )
+                       ->will( $this->returnValue( __CLASS__ ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getDBkey' )
+                       ->will( $this->returnValue( __CLASS__ ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getArticleID' )
+                       ->will( $this->returnValue( $articleId ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getLatestRevId' )
+                       ->will( $this->returnValue( $revisionId ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getContentModel' )
+                       ->will( $this->returnValue( CONTENT_MODEL_WIKITEXT ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getPageLanguage' )
+                       ->will( $this->returnValue( Language::factory( 'en' ) ) );
+               $mock->expects( $this->any() )
+                       ->method( 'isContentPage' )
+                       ->will( $this->returnValue( true ) );
+               $mock->expects( $this->any() )
+                       ->method( 'equals' )
+                       ->willReturnCallback(
+                               function ( Title $other ) use ( $mock ) {
+                                       return $mock->getArticleID() === $other->getArticleID();
+                               }
+                       );
+               $mock->expects( $this->any() )
+                       ->method( 'userCan' )
+                       ->willReturnCallback(
+                               function ( $perm, User $user ) use ( $mock ) {
+                                       return $user->isAllowed( $perm );
+                               }
+                       );
+
+               return $mock;
+       }
+
+       /**
+        * @param int $maxRev
+        * @param int $linkCount
+        *
+        * @return IDatabase
+        */
+       private function getMockDatabaseConnection( $maxRev = 100, $linkCount = 0 ) {
+               /** @var IDatabase|MockObject $db */
+               $db = $this->getMock( IDatabase::class );
+               $db->method( 'selectField' )
+                       ->willReturnCallback(
+                               function ( $table, $fields, $cond ) use ( $maxRev, $linkCount ) {
+                                       return $this->selectFieldCallback(
+                                               $table,
+                                               $fields,
+                                               $cond,
+                                               $maxRev,
+                                               $linkCount
+                                       );
+                               }
+                       );
+
+               return $db;
+       }
+
+       /**
+        * @return RevisionRenderer
+        */
+       private function newRevisionRenderer( $maxRev = 100, $useMaster = false ) {
+               $dbIndex = $useMaster ? DB_MASTER : DB_REPLICA;
+
+               $db = $this->getMockDatabaseConnection( $maxRev );
+
+               /** @var ILoadBalancer|MockObject $lb */
+               $lb = $this->getMock( ILoadBalancer::class );
+               $lb->method( 'getConnection' )
+                       ->with( $dbIndex )
+                       ->willReturn( $db );
+               $lb->method( 'getConnectionRef' )
+                       ->with( $dbIndex )
+                       ->willReturn( $db );
+               $lb->method( 'getLazyConnectionRef' )
+                       ->with( $dbIndex )
+                       ->willReturn( $db );
+
+               return new RevisionRenderer( $lb );
+       }
+
+       private function selectFieldCallback( $table, $fields, $cond, $maxRev ) {
+               if ( [ $table, $fields, $cond ] === [ 'revision', 'MAX(rev_id)', [] ] ) {
+                       return $maxRev;
+               }
+
+               $this->fail( 'Unexpected call to selectField' );
+               throw new LogicException( 'Ooops' ); // Can't happen, make analyzer happy
+       }
+
+       public function testGetRenderedRevision_new() {
+               $renderer = $this->newRevisionRenderer( 100 );
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+               $text .= "* [[Link It]]\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = $renderer->getRenderedRevision( $rev, $options );
+
+               $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
+
+               $this->assertSame( $rev, $rr->getRevision() );
+               $this->assertSame( $options, $rr->getOptions() );
+
+               $html = $rr->getRevisionParserOutput()->getText();
+
+               $this->assertContains( 'page:' . __CLASS__, $html );
+               $this->assertContains( 'rev:101', $html ); // from speculativeRevIdCallback
+               $this->assertContains( 'user:Frank', $html );
+               $this->assertContains( 'time:20180101000003', $html );
+
+               $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+       }
+
+       public function testGetRenderedRevision_current() {
+               $renderer = $this->newRevisionRenderer( 100 );
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( 21 ); // current!
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = $renderer->getRenderedRevision( $rev, $options );
+
+               $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
+
+               $this->assertSame( $rev, $rr->getRevision() );
+               $this->assertSame( $options, $rr->getOptions() );
+
+               $html = $rr->getRevisionParserOutput()->getText();
+
+               $this->assertContains( 'page:' . __CLASS__, $html );
+               $this->assertContains( 'rev:21', $html );
+               $this->assertContains( 'user:Frank', $html );
+               $this->assertContains( 'time:20180101000003', $html );
+
+               $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+       }
+
+       public function testGetRenderedRevision_master() {
+               $renderer = $this->newRevisionRenderer( 100, true ); // use master
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( 21 ); // current!
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = $renderer->getRenderedRevision( $rev, $options, null, [ 'use-master' => true ] );
+
+               $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
+
+               $html = $rr->getRevisionParserOutput()->getText();
+
+               $this->assertContains( 'rev:21', $html );
+
+               $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+       }
+
+       public function testGetRenderedRevision_old() {
+               $renderer = $this->newRevisionRenderer( 100 );
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( 11 ); // old!
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = $renderer->getRenderedRevision( $rev, $options );
+
+               $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
+
+               $this->assertSame( $rev, $rr->getRevision() );
+               $this->assertSame( $options, $rr->getOptions() );
+
+               $html = $rr->getRevisionParserOutput()->getText();
+
+               $this->assertContains( 'page:' . __CLASS__, $html );
+               $this->assertContains( 'rev:11', $html );
+               $this->assertContains( 'user:Frank', $html );
+               $this->assertContains( 'time:20180101000003', $html );
+
+               $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+       }
+
+       public function testGetRenderedRevision_suppressed() {
+               $renderer = $this->newRevisionRenderer( 100 );
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( 11 ); // old!
+               $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = $renderer->getRenderedRevision( $rev, $options );
+
+               $this->assertNull( $rr, 'getRenderedRevision' );
+       }
+
+       public function testGetRenderedRevision_privileged() {
+               $renderer = $this->newRevisionRenderer( 100 );
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( 11 ); // old!
+               $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $sysop = $this->getTestUser( [ 'sysop' ] )->getUser(); // privileged!
+               $rr = $renderer->getRenderedRevision( $rev, $options, $sysop );
+
+               $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' );
+
+               $this->assertSame( $rev, $rr->getRevision() );
+               $this->assertSame( $options, $rr->getOptions() );
+
+               $html = $rr->getRevisionParserOutput()->getText();
+
+               // Suppressed content should be visible for sysops
+               $this->assertContains( 'page:' . __CLASS__, $html );
+               $this->assertContains( 'rev:11', $html );
+               $this->assertContains( 'user:Frank', $html );
+               $this->assertContains( 'time:20180101000003', $html );
+
+               $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+       }
+
+       public function testGetRenderedRevision_raw() {
+               $renderer = $this->newRevisionRenderer( 100 );
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( 11 ); // old!
+               $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = $renderer->getRenderedRevision(
+                       $rev,
+                       $options,
+                       null,
+                       [ 'audience' => RevisionRecord::RAW ]
+               );
+
+               $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' );
+
+               $this->assertSame( $rev, $rr->getRevision() );
+               $this->assertSame( $options, $rr->getOptions() );
+
+               $html = $rr->getRevisionParserOutput()->getText();
+
+               // Suppressed content should be visible in raw mode
+               $this->assertContains( 'page:' . __CLASS__, $html );
+               $this->assertContains( 'rev:11', $html );
+               $this->assertContains( 'user:Frank', $html );
+               $this->assertContains( 'time:20180101000003', $html );
+
+               $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+       }
+
+       public function testGetRenderedRevision_multi() {
+               $renderer = $this->newRevisionRenderer();
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $rev->setContent( 'main', new WikitextContent( '[[Kittens]]' ) );
+               $rev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) );
+
+               $rr = $renderer->getRenderedRevision( $rev );
+
+               $combinedOutput = $rr->getRevisionParserOutput();
+               $mainOutput = $rr->getSlotParserOutput( 'main' );
+               $auxOutput = $rr->getSlotParserOutput( 'aux' );
+
+               $combinedHtml = $combinedOutput->getText();
+               $mainHtml = $mainOutput->getText();
+               $auxHtml = $auxOutput->getText();
+
+               $this->assertContains( 'Kittens', $mainHtml );
+               $this->assertContains( 'Goats', $auxHtml );
+               $this->assertNotContains( 'Goats', $mainHtml );
+               $this->assertNotContains( 'Kittens', $auxHtml );
+               $this->assertContains( 'Kittens', $combinedHtml );
+               $this->assertContains( 'Goats', $combinedHtml );
+               $this->assertContains( '>aux<', $combinedHtml, 'slot header' );
+               $this->assertNotContains( '<mw:slotheader', $combinedHtml, 'slot header placeholder' );
+
+               // make sure output wrapping works right
+               $this->assertContains( 'class="mw-parser-output"', $mainHtml );
+               $this->assertContains( 'class="mw-parser-output"', $auxHtml );
+               $this->assertContains( 'class="mw-parser-output"', $combinedHtml );
+
+               // there should be only one wrapper div
+               $this->assertSame( 1, preg_match_all( '#class="mw-parser-output"#', $combinedHtml ) );
+               $this->assertNotContains( 'class="mw-parser-output"', $combinedOutput->getRawText() );
+
+               $combinedLinks = $combinedOutput->getLinks();
+               $mainLinks = $mainOutput->getLinks();
+               $auxLinks = $auxOutput->getLinks();
+               $this->assertTrue( isset( $combinedLinks[NS_MAIN]['Kittens'] ), 'links from main slot' );
+               $this->assertTrue( isset( $combinedLinks[NS_MAIN]['Goats'] ), 'links from aux slot' );
+               $this->assertFalse( isset( $mainLinks[NS_MAIN]['Goats'] ), 'no aux links in main' );
+               $this->assertFalse( isset( $auxLinks[NS_MAIN]['Kittens'] ), 'no main links in aux' );
+       }
+
+       public function testGetRenderedRevision_noHtml() {
+               /** @var MockObject|Content $mockContent */
+               $mockContent = $this->getMockBuilder( WikitextContent::class )
+                       ->setMethods( [ 'getParserOutput' ] )
+                       ->setConstructorArgs( [ 'Whatever' ] )
+                       ->getMock();
+               $mockContent->method( 'getParserOutput' )
+                       ->willReturnCallback( function ( Title $title, $revId = null,
+                               ParserOptions $options = null, $generateHtml = true
+                       ) {
+                               if ( !$generateHtml ) {
+                                       return new ParserOutput( null );
+                               } else {
+                                       $this->fail( 'Should not be called with $generateHtml == true' );
+                                       return null; // never happens, make analyzer happy
+                               }
+                       } );
+
+               $renderer = $this->newRevisionRenderer();
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setContent( 'main', $mockContent );
+               $rev->setContent( 'aux', $mockContent );
+
+               // NOTE: we are testing the private combineSlotOutput() callback here.
+               $rr = $renderer->getRenderedRevision( $rev );
+
+               $output = $rr->getSlotParserOutput( 'main', [ 'generate-html' => false ] );
+               $this->assertFalse( $output->hasText(), 'hasText' );
+
+               $output = $rr->getRevisionParserOutput( [ 'generate-html' => false ] );
+               $this->assertFalse( $output->hasText(), 'hasText' );
+       }
+
+}
index c7f83de..0e0d609 100644 (file)
@@ -73,14 +73,22 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                $user = $this->getTestUser()->getUser();
                $comment = CommentStoreComment::newUnsavedComment( $summary );
 
-               if ( !$content instanceof Content ) {
+               if ( $content === null || is_string( $content ) ) {
                        $content = new WikitextContent( $content ?? $summary );
                }
 
+               if ( !is_array( $content ) ) {
+                       $content = [ 'main' => $content ];
+               }
+
                $this->getDerivedPageDataUpdater( $page ); // flush cached instance before.
 
                $updater = $page->newPageUpdater( $user );
-               $updater->setContent( 'main', $content );
+
+               foreach ( $content as $role => $c ) {
+                       $updater->setContent( $role, $c );
+               }
+
                $rev = $updater->saveRevision( $comment );
 
                $this->getDerivedPageDataUpdater( $page ); // flush cached instance after.
@@ -110,7 +118,7 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                $this->assertSame( MediaWikiServices::getInstance()->getContentLanguage(),
                        $options1->getUserLangObj() );
 
-               $speculativeId = call_user_func( $options1->getSpeculativeRevIdCallback(), $page->getTitle() );
+               $speculativeId = $options1->getSpeculativeRevId();
                $this->assertSame( $parentRev->getId() + 1, $speculativeId );
 
                $rev = $this->makeRevision(
@@ -123,7 +131,6 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                $updater->prepareUpdate( $rev );
 
                $options2 = $updater->getCanonicalParserOptions();
-               $this->assertNotSame( $options1, $options2 );
 
                $currentRev = call_user_func( $options2->getCurrentRevisionCallback(), $page->getTitle() );
                $this->assertSame( $rev->getId(), $currentRev->getId() );
@@ -167,7 +174,7 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
         * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOutput()
         */
        public function testPrepareContent() {
-               $user = $this->getTestUser()->getUser();
+               $sysop = $this->getTestUser( [ 'sysop' ] )->getUser();
                $updater = $this->getDerivedPageDataUpdater( __METHOD__ );
 
                $this->assertFalse( $updater->isContentPrepared() );
@@ -186,10 +193,10 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                $update->modifySlot( SlotRecord::newInherited( $auxSlot ) );
                // TODO: MCR: test removing slots!
 
-               $updater->prepareContent( $user, $update, false );
+               $updater->prepareContent( $sysop, $update, false );
 
                // second be ok to call again with the same params
-               $updater->prepareContent( $user, $update, false );
+               $updater->prepareContent( $sysop, $update, false );
 
                $this->assertNull( $updater->grabCurrentRevision() );
                $this->assertTrue( $updater->isContentPrepared() );
@@ -197,7 +204,10 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                $this->assertFalse( $updater->pageExisted() );
                $this->assertTrue( $updater->isCreation() );
                $this->assertTrue( $updater->isChange() );
-               $this->assertTrue( $updater->isContentPublic() );
+               $this->assertFalse( $updater->isContentDeleted() );
+
+               $this->assertNotNull( $updater->getRevision() );
+               $this->assertNotNull( $updater->getRenderedRevision() );
 
                $this->assertEquals( [ 'main', 'aux' ], $updater->getSlots()->getSlotRoles() );
                $this->assertEquals( [ 'main' ], array_keys( $updater->getSlots()->getOriginalSlots() ) );
@@ -208,7 +218,7 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                $mainSlot = $updater->getRawSlot( 'main' );
                $this->assertInstanceOf( SlotRecord::class, $mainSlot );
                $this->assertNotContains( '~~~', $mainSlot->getContent()->serialize(), 'PST should apply.' );
-               $this->assertContains( $user->getName(), $mainSlot->getContent()->serialize() );
+               $this->assertContains( $sysop->getName(), $mainSlot->getContent()->serialize() );
 
                $auxSlot = $updater->getRawSlot( 'aux' );
                $this->assertInstanceOf( SlotRecord::class, $auxSlot );
@@ -222,6 +232,7 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                $canonicalOutput = $updater->getCanonicalParserOutput();
                $this->assertContains( 'first', $canonicalOutput->getText() );
                $this->assertContains( '<a ', $canonicalOutput->getText() );
+               $this->assertContains( 'inherited ', $canonicalOutput->getText() );
                $this->assertNotEmpty( $canonicalOutput->getLinks() );
        }
 
@@ -232,18 +243,19 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
         * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isChange()
         */
        public function testPrepareContentInherit() {
-               $user = $this->getTestUser()->getUser();
+               $sysop = $this->getTestUser( [ 'sysop' ] )->getUser();
                $page = $this->getPage( __METHOD__ );
 
-               $mainContent1 = new WikitextContent( 'first [[main]] ~~~' );
+               $mainContent1 = new WikitextContent( 'first [[main]] ({{REVISIONUSER}}) ~~~' );
                $mainContent2 = new WikitextContent( 'second' );
 
-               $this->createRevision( $page, 'first', $mainContent1 );
+               $rev = $this->createRevision( $page, 'first', $mainContent1 );
+               $mainContent1 = $rev->getContent( 'main' ); // get post-pst content
 
                $update = new RevisionSlotsUpdate();
                $update->modifyContent( 'main', $mainContent1 );
                $updater1 = $this->getDerivedPageDataUpdater( $page );
-               $updater1->prepareContent( $user, $update, false );
+               $updater1->prepareContent( $sysop, $update, false );
 
                $this->assertNotNull( $updater1->grabCurrentRevision() );
                $this->assertTrue( $updater1->isContentPrepared() );
@@ -251,11 +263,20 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                $this->assertFalse( $updater1->isCreation() );
                $this->assertFalse( $updater1->isChange() );
 
+               $this->assertNotNull( $updater1->getRevision() );
+               $this->assertNotNull( $updater1->getRenderedRevision() );
+
+               // parser-output for null-edit uses the original author's name
+               $html = $updater1->getRenderedRevision()->getRevisionParserOutput()->getText();
+               $this->assertNotContains( $sysop->getName(), $html, '{{REVISIONUSER}}' );
+               $this->assertNotContains( '{{REVISIONUSER}}', $html, '{{REVISIONUSER}}' );
+               $this->assertContains( '(' . $rev->getUser()->getName() . ')', $html, '{{REVISIONUSER}}' );
+
                // TODO: MCR: test inheritance from parent
                $update = new RevisionSlotsUpdate();
                $update->modifyContent( 'main', $mainContent2 );
                $updater2 = $this->getDerivedPageDataUpdater( $page );
-               $updater2->prepareContent( $user, $update, false );
+               $updater2->prepareContent( $sysop, $update, false );
 
                $this->assertFalse( $updater2->isCreation() );
                $this->assertTrue( $updater2->isChange() );
@@ -292,7 +313,10 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                $this->assertTrue( $updater1->isContentPrepared() );
                $this->assertTrue( $updater1->isCreation() );
                $this->assertTrue( $updater1->isChange() );
-               $this->assertTrue( $updater1->isContentPublic() );
+               $this->assertFalse( $updater1->isContentDeleted() );
+
+               $this->assertNotNull( $updater1->getRevision() );
+               $this->assertNotNull( $updater1->getRenderedRevision() );
 
                $this->assertEquals( [ 'main' ], $updater1->getSlots()->getSlotRoles() );
                $this->assertEquals( [ 'main' ], array_keys( $updater1->getSlots()->getOriginalSlots() ) );
@@ -698,10 +722,20 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
        public function testDoUpdates() {
                $page = $this->getPage( __METHOD__ );
 
-               $mainContent1 = new WikitextContent( 'first [[main]]' );
-               $rev = $this->createRevision( $page, 'first', $mainContent1 );
+               $content = [ 'main' => new WikitextContent( 'first [[main]]' ) ];
+
+               if ( $this->hasMultiSlotSupport() ) {
+                       $content['aux'] = new WikitextContent( 'Aux [[Nix]]' );
+               }
+
+               $rev = $this->createRevision( $page, 'first', $content );
                $pageId = $page->getId();
+
                $oldStats = $this->db->selectRow( 'site_stats', '*', '1=1' );
+               $this->db->delete( 'pagelinks', '*' );
+
+               $pcache = MediaWikiServices::getInstance()->getParserCache();
+               $pcache->deleteOptionsKey( $page );
 
                $updater = $this->getDerivedPageDataUpdater( $page, $rev );
                $updater->setArticleCountMethod( 'link' );
@@ -712,15 +746,25 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                $updater->doUpdates();
 
                // links table update
-               $linkCount = $this->db->selectRowCount( 'pagelinks', '*', [ 'pl_from' => $pageId ] );
-               $this->assertSame( 1, $linkCount );
+               $pageLinks = $this->db->select(
+                       'pagelinks',
+                       '*',
+                       [ 'pl_from' => $pageId ],
+                       __METHOD__,
+                       [ 'ORDER BY' => 'pl_namespace, pl_title' ]
+               );
 
-               $pageLinksRow = $this->db->selectRow( 'pagelinks', '*', [ 'pl_from' => $pageId ] );
+               $pageLinksRow = $pageLinks->fetchObject();
                $this->assertInternalType( 'object', $pageLinksRow );
                $this->assertSame( 'Main', $pageLinksRow->pl_title );
 
+               if ( $this->hasMultiSlotSupport() ) {
+                       $pageLinksRow = $pageLinks->fetchObject();
+                       $this->assertInternalType( 'object', $pageLinksRow );
+                       $this->assertSame( 'Nix', $pageLinksRow->pl_title );
+               }
+
                // parser cache update
-               $pcache = MediaWikiServices::getInstance()->getParserCache();
                $cached = $pcache->get( $page, $updater->getCanonicalParserOptions() );
                $this->assertInternalType( 'object', $cached );
                $this->assertSame( $updater->getCanonicalParserOutput(), $cached );
@@ -742,4 +786,11 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                // TODO: test category membership update (with setRcWatchCategoryMembership())
        }
 
+       private function hasMultiSlotSupport() {
+               global $wgMultiContentRevisionSchemaMigrationStage;
+
+               return ( $wgMultiContentRevisionSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW )
+                       && ( $wgMultiContentRevisionSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW );
+       }
+
 }
index 62093f0..43678f9 100644 (file)
@@ -5,6 +5,7 @@ namespace MediaWiki\Tests\Storage;
 use CommentStoreComment;
 use InvalidArgumentException;
 use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\MutableRevisionSlots;
 use MediaWiki\Storage\RevisionAccessException;
 use MediaWiki\Storage\RevisionRecord;
 use MediaWiki\Storage\RevisionSlotsUpdate;
@@ -195,6 +196,11 @@ class MutableRevisionRecordTest extends MediaWikiTestCase {
                $this->assertSame( 'someHash', $record->getSha1() );
        }
 
+       public function testGetSlots() {
+               $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+               $this->assertInstanceOf( MutableRevisionSlots::class, $record->getSlots() );
+       }
+
        public function testSetGetSize() {
                $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
                $this->assertSame( 0, $record->getSize() );
index 758537d..2805ea8 100644 (file)
@@ -7,6 +7,7 @@ use Content;
 use MediaWiki\MediaWikiServices;
 use MediaWiki\Storage\RevisionRecord;
 use MediaWikiTestCase;
+use ParserOptions;
 use RecentChange;
 use Revision;
 use TextContent;
@@ -494,4 +495,89 @@ class PageUpdaterTest extends MediaWikiTestCase {
                );
        }
 
+       public function provideMagicWords() {
+               yield 'PAGEID' => [
+                       'Test {{PAGEID}} Test',
+                       function ( RevisionRecord $rev ) {
+                               return $rev->getPageId();
+                       }
+               ];
+
+               yield 'REVISIONID' => [
+                       'Test {{REVISIONID}} Test',
+                       function ( RevisionRecord $rev ) {
+                               return $rev->getId();
+                       }
+               ];
+
+               yield 'REVISIONUSER' => [
+                       'Test {{REVISIONUSER}} Test',
+                       function ( RevisionRecord $rev ) {
+                               return $rev->getUser()->getName();
+                       }
+               ];
+
+               yield 'REVISIONTIMESTAMP' => [
+                       'Test {{REVISIONTIMESTAMP}} Test',
+                       function ( RevisionRecord $rev ) {
+                               return $rev->getTimestamp();
+                       }
+               ];
+
+               yield 'subst:REVISIONUSER' => [
+                       'Test {{subst:REVISIONUSER}} Test',
+                       function ( RevisionRecord $rev ) {
+                               return $rev->getUser()->getName();
+                       }
+               ];
+
+               yield 'subst:PAGENAME' => [
+                       'Test {{subst:PAGENAME}} Test',
+                       function ( RevisionRecord $rev ) {
+                               return 'PageUpdaterTest::testMagicWords';
+                       }
+               ];
+       }
+
+       /**
+        * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
+        *
+        * Integration test for PageUpdater, DerivedPageDataUpdater, RevisionRenderer
+        * and RenderedRevision, that ensures that magic words depending on revision meta-data
+        * are handled correctly. Note that each magic word needs to be tested separately,
+        * to assert correct behavior for each "vary" flag in the ParserOutput.
+        *
+        * @dataProvider provideMagicWords
+        */
+       public function testMagicWords( $wikitext, $callback ) {
+               $user = $this->getTestUser()->getUser();
+
+               $title = $this->getDummyTitle( __METHOD__ . '-' . $this->getName() );
+               $page = WikiPage::factory( $title );
+               $updater = $page->newPageUpdater( $user );
+
+               $updater->setContent( 'main', new \WikitextContent( $wikitext ) );
+
+               $summary = CommentStoreComment::newUnsavedComment( 'Just a test' );
+               $rev = $updater->saveRevision( $summary, EDIT_NEW );
+
+               if ( !$rev ) {
+                       $this->fail( $updater->getStatus()->getWikiText() );
+               }
+
+               $expected = strval( $callback( $rev ) );
+
+               $cache = MediaWikiServices::getInstance()->getParserCache();
+               $output = $cache->get(
+                       $page,
+                       ParserOptions::newCanonical(
+                               'canonical'
+                       )
+               );
+
+               $this->assertNotNull( $output, 'ParserCache::get' );
+
+               $this->assertContains( $expected, $output->getText() );
+       }
+
 }
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 8c17780..6413ddd 100644 (file)
@@ -13,6 +13,7 @@ class ParserOptionsTest extends MediaWikiTestCase {
                $wrap->defaults = null;
                $wrap->lazyOptions = [
                        'dateformat' => [ ParserOptions::class, 'initDateFormat' ],
+                       'speculativeRevId' => [ ParserOptions::class, 'initSpeculativeRevId' ],
                ];
                $wrap->inCacheKey = [
                        'dateformat' => true,
@@ -309,4 +310,19 @@ class ParserOptionsTest extends MediaWikiTestCase {
                ], ParserOptions::allCacheVaryingOptions() );
        }
 
+       public function testGetSpeculativeRevid() {
+               $options = new ParserOptions();
+
+               $this->assertFalse( $options->getSpeculativeRevId() );
+
+               $counter = 0;
+               $options->setSpeculativeRevIdCallback( function () use( &$counter ) {
+                       return ++$counter;
+               } );
+
+               // make sure the same value is re-used once it is determined
+               $this->assertSame( 1, $options->getSpeculativeRevId() );
+               $this->assertSame( 1, $options->getSpeculativeRevId() );
+       }
+
 }
index b08ba6c..3c73430 100644 (file)
@@ -1,10 +1,11 @@
 <?php
+use Wikimedia\TestingAccessWrapper;
 
 /**
  * @group Database
  *        ^--- trigger DB shadowing because we are using Title magic
  */
-class ParserOutputTest extends MediaWikiTestCase {
+class ParserOutputTest extends MediaWikiLangTestCase {
 
        public static function provideIsLinkInternal() {
                return [
@@ -89,6 +90,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 +165,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 +190,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 +209,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 +234,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 +264,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 +285,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
@@ -291,4 +307,601 @@ EOF
                // phpcs:enable
        }
 
+       /**
+        * @covers ParserOutput::hasText
+        */
+       public function testHasText() {
+               $po = new ParserOutput();
+               $this->assertTrue( $po->hasText() );
+
+               $po = new ParserOutput( null );
+               $this->assertFalse( $po->hasText() );
+
+               $po = new ParserOutput( '' );
+               $this->assertTrue( $po->hasText() );
+
+               $po = new ParserOutput( null );
+               $po->setText( '' );
+               $this->assertTrue( $po->hasText() );
+       }
+
+       /**
+        * @covers ParserOutput::getText
+        */
+       public function testGetText_failsIfNoText() {
+               $po = new ParserOutput( null );
+
+               $this->setExpectedException( LogicException::class );
+               $po->getText();
+       }
+
+       /**
+        * @covers ParserOutput::getRawText
+        */
+       public function testGetRawText_failsIfNoText() {
+               $po = new ParserOutput( null );
+
+               $this->setExpectedException( LogicException::class );
+               $po->getRawText();
+       }
+
+       public function provideMergeHtmlMetaDataFrom() {
+               // title text ------------
+               $a = new ParserOutput();
+               $a->setTitleText( 'X' );
+               $b = new ParserOutput();
+               yield 'only left title text' => [ $a, $b, [ 'getTitleText' => 'X' ] ];
+
+               $a = new ParserOutput();
+               $b = new ParserOutput();
+               $b->setTitleText( 'Y' );
+               yield 'only right title text' => [ $a, $b, [ 'getTitleText' => 'Y' ] ];
+
+               $a = new ParserOutput();
+               $a->setTitleText( 'X' );
+               $b = new ParserOutput();
+               $b->setTitleText( 'Y' );
+               yield 'left title text wins' => [ $a, $b, [ 'getTitleText' => 'X' ] ];
+
+               // index policy ------------
+               $a = new ParserOutput();
+               $a->setIndexPolicy( 'index' );
+               $b = new ParserOutput();
+               yield 'only left index policy' => [ $a, $b, [ 'getIndexPolicy' => 'index' ] ];
+
+               $a = new ParserOutput();
+               $b = new ParserOutput();
+               $b->setIndexPolicy( 'index' );
+               yield 'only right index policy' => [ $a, $b, [ 'getIndexPolicy' => 'index' ] ];
+
+               $a = new ParserOutput();
+               $a->setIndexPolicy( 'noindex' );
+               $b = new ParserOutput();
+               $b->setIndexPolicy( 'index' );
+               yield 'left noindex wins' => [ $a, $b, [ 'getIndexPolicy' => 'noindex' ] ];
+
+               $a = new ParserOutput();
+               $a->setIndexPolicy( 'index' );
+               $b = new ParserOutput();
+               $b->setIndexPolicy( 'noindex' );
+               yield 'right noindex wins' => [ $a, $b, [ 'getIndexPolicy' => 'noindex' ] ];
+
+               // head items and friends ------------
+               $a = new ParserOutput();
+               $a->addHeadItem( '<foo1>' );
+               $a->addHeadItem( '<bar1>', 'bar' );
+               $a->addModules( 'test-module-a' );
+               $a->addModuleScripts( 'test-module-script-a' );
+               $a->addModuleStyles( 'test-module-styles-a' );
+               $b->addJsConfigVars( 'test-config-var-a', 'a' );
+
+               $b = new ParserOutput();
+               $b->setIndexPolicy( 'noindex' );
+               $b->addHeadItem( '<foo2>' );
+               $b->addHeadItem( '<bar2>', 'bar' );
+               $b->addModules( 'test-module-b' );
+               $b->addModuleScripts( 'test-module-script-b' );
+               $b->addModuleStyles( 'test-module-styles-b' );
+               $b->addJsConfigVars( 'test-config-var-b', 'b' );
+               $b->addJsConfigVars( 'test-config-var-a', 'X' );
+
+               yield 'head items and friends' => [ $a, $b, [
+                       'getHeadItems' => [
+                               '<foo1>',
+                               '<foo2>',
+                               'bar' => '<bar2>', // overwritten
+                       ],
+                       'getModules' => [
+                               'test-module-a',
+                               'test-module-b',
+                       ],
+                       'getModuleScripts' => [
+                               'test-module-script-a',
+                               'test-module-script-b',
+                       ],
+                       'getModuleStyles' => [
+                               'test-module-styles-a',
+                               'test-module-styles-b',
+                       ],
+                       'getJsConfigVars' => [
+                               'test-config-var-a' => 'X', // overwritten
+                               'test-config-var-b' => 'b',
+                       ],
+               ] ];
+
+               // TOC ------------
+               $a = new ParserOutput();
+               $a->setTOCHTML( '<p>TOC A</p>' );
+               $a->setSections( [ [ 'fromtitle' => 'A1' ], [ 'fromtitle' => 'A2' ] ] );
+
+               $b = new ParserOutput();
+               $b->setTOCHTML( '<p>TOC B</p>' );
+               $b->setSections( [ [ 'fromtitle' => 'B1' ], [ 'fromtitle' => 'B2' ] ] );
+
+               yield 'concat TOC' => [ $a, $b, [
+                       'getTOCHTML' => '<p>TOC A</p><p>TOC B</p>',
+                       'getSections' => [
+                               [ 'fromtitle' => 'A1' ],
+                               [ 'fromtitle' => 'A2' ],
+                               [ 'fromtitle' => 'B1' ],
+                               [ 'fromtitle' => 'B2' ]
+                       ],
+               ] ];
+
+               // Skin Control  ------------
+               $a = new ParserOutput();
+               $a->setNewSection( true );
+               $a->hideNewSection( true );
+               $a->setNoGallery( true );
+               $a->addWrapperDivClass( 'foo' );
+
+               $a->setIndicator( 'foo', 'Foo!' );
+               $a->setIndicator( 'bar', 'Bar!' );
+
+               $a->setExtensionData( 'foo', 'Foo!' );
+               $a->setExtensionData( 'bar', 'Bar!' );
+
+               $b = new ParserOutput();
+               $b->setNoGallery( true );
+               $b->setEnableOOUI( true );
+               $b->preventClickjacking( true );
+               $a->addWrapperDivClass( 'bar' );
+
+               $b->setIndicator( 'zoo', 'Zoo!' );
+               $b->setIndicator( 'bar', 'Barrr!' );
+
+               $b->setExtensionData( 'zoo', 'Zoo!' );
+               $b->setExtensionData( 'bar', 'Barrr!' );
+
+               yield 'skin control flags' => [ $a, $b, [
+                       'getNewSection' => true,
+                       'getHideNewSection' => true,
+                       'getNoGallery' => true,
+                       'getEnableOOUI' => true,
+                       'preventClickjacking' => true,
+                       'getIndicators' => [
+                               'foo' => 'Foo!',
+                               'bar' => 'Barrr!',
+                               'zoo' => 'Zoo!',
+                       ],
+                       'getWrapperDivClass' => 'foo bar',
+                       '$mExtensionData' => [
+                               'foo' => 'Foo!',
+                               'bar' => 'Barrr!',
+                               'zoo' => 'Zoo!',
+                       ],
+               ] ];
+       }
+
+       /**
+        * @dataProvider provideMergeHtmlMetaDataFrom
+        * @covers ParserOutput::mergeHtmlMetaDataFrom
+        *
+        * @param ParserOutput $a
+        * @param ParserOutput $b
+        * @param array $expected
+        */
+       public function testMergeHtmlMetaDataFrom( ParserOutput $a, ParserOutput $b, $expected ) {
+               $a->mergeHtmlMetaDataFrom( $b );
+
+               $this->assertFieldValues( $a, $expected );
+
+               // test twice, to make sure the operation is idempotent (except for the TOC, see below)
+               $a->mergeHtmlMetaDataFrom( $b );
+
+               // XXX: TOC joining should get smarter. Can we make it idempotent as well?
+               unset( $expected['getTOCHTML'] );
+               unset( $expected['getSections'] );
+
+               $this->assertFieldValues( $a, $expected );
+       }
+
+       private function assertFieldValues( ParserOutput $po, $expected ) {
+               $po = TestingAccessWrapper::newFromObject( $po );
+
+               foreach ( $expected as $method => $value ) {
+                       if ( $method[0] === '$' ) {
+                               $field = substr( $method, 1 );
+                               $actual = $po->__get( $field );
+                       } else {
+                               $actual = $po->__call( $method, [] );
+                       }
+
+                       $this->assertEquals( $value, $actual, $method );
+               }
+       }
+
+       public function provideMergeTrackingMetaDataFrom() {
+               // links ------------
+               $a = new ParserOutput();
+               $a->addLink( Title::makeTitle( NS_MAIN, 'Kittens' ), 6 );
+               $a->addLink( Title::makeTitle( NS_TALK, 'Kittens' ), 16 );
+               $a->addLink( Title::makeTitle( NS_MAIN, 'Goats' ), 7 );
+
+               $a->addTemplate( Title::makeTitle( NS_TEMPLATE, 'Goats' ), 107, 1107 );
+
+               $a->addLanguageLink( 'de' );
+               $a->addLanguageLink( 'ru' );
+               $a->addInterwikiLink( Title::makeTitle( NS_MAIN, 'Kittens DE', '', 'de' ) );
+               $a->addInterwikiLink( Title::makeTitle( NS_MAIN, 'Kittens RU', '', 'ru' ) );
+               $a->addExternalLink( 'https://kittens.wikimedia.test' );
+               $a->addExternalLink( 'https://goats.wikimedia.test' );
+
+               $a->addCategory( 'Foo', 'X' );
+               $a->addImage( 'Billy.jpg', '20180101000013', 'DEAD' );
+
+               $b = new ParserOutput();
+               $b->addLink( Title::makeTitle( NS_MAIN, 'Goats' ), 7 );
+               $b->addLink( Title::makeTitle( NS_TALK, 'Goats' ), 17 );
+               $b->addLink( Title::makeTitle( NS_MAIN, 'Dragons' ), 8 );
+               $b->addLink( Title::makeTitle( NS_FILE, 'Dragons.jpg' ), 28 );
+
+               $b->addTemplate( Title::makeTitle( NS_TEMPLATE, 'Dragons' ), 108, 1108 );
+               $a->addTemplate( Title::makeTitle( NS_MAIN, 'Dragons' ), 118, 1118 );
+
+               $b->addLanguageLink( 'fr' );
+               $b->addLanguageLink( 'ru' );
+               $b->addInterwikiLink( Title::makeTitle( NS_MAIN, 'Kittens FR', '', 'fr' ) );
+               $b->addInterwikiLink( Title::makeTitle( NS_MAIN, 'Dragons RU', '', 'ru' ) );
+               $b->addExternalLink( 'https://dragons.wikimedia.test' );
+               $b->addExternalLink( 'https://goats.wikimedia.test' );
+
+               $b->addCategory( 'Bar', 'Y' );
+               $b->addImage( 'Puff.jpg', '20180101000017', 'BEEF' );
+
+               yield 'all kinds of links' => [ $a, $b, [
+                       'getLinks' => [
+                               NS_MAIN => [
+                                       'Kittens' => 6,
+                                       'Goats' => 7,
+                                       'Dragons' => 8,
+                               ],
+                               NS_TALK => [
+                                       'Kittens' => 16,
+                                       'Goats' => 17,
+                               ],
+                               NS_FILE => [
+                                       'Dragons.jpg' => 28,
+                               ],
+                       ],
+                       'getTemplates' => [
+                               NS_MAIN => [
+                                       'Dragons' => 118,
+                               ],
+                               NS_TEMPLATE => [
+                                       'Dragons' => 108,
+                                       'Goats' => 107,
+                               ],
+                       ],
+                       'getTemplateIds' => [
+                               NS_MAIN => [
+                                       'Dragons' => 1118,
+                               ],
+                               NS_TEMPLATE => [
+                                       'Dragons' => 1108,
+                                       'Goats' => 1107,
+                               ],
+                       ],
+                       'getLanguageLinks' => [ 'de', 'ru', 'fr' ],
+                       'getInterwikiLinks' => [
+                               'de' => [ 'Kittens_DE' => 1 ],
+                               'ru' => [ 'Kittens_RU' => 1, 'Dragons_RU' => 1, ],
+                               'fr' => [ 'Kittens_FR' => 1 ],
+                       ],
+                       'getCategories' => [ 'Foo' => 'X', 'Bar' => 'Y' ],
+                       'getImages' => [ 'Billy.jpg' => 1, 'Puff.jpg' => 1 ],
+                       'getFileSearchOptions' => [
+                               'Billy.jpg' => [ 'time' => '20180101000013', 'sha1' => 'DEAD' ],
+                               'Puff.jpg' => [ 'time' => '20180101000017', 'sha1' => 'BEEF' ],
+                       ],
+                       'getExternalLinks' => [
+                               'https://dragons.wikimedia.test' => 1,
+                               'https://kittens.wikimedia.test' => 1,
+                               'https://goats.wikimedia.test' => 1,
+                       ]
+               ] ];
+
+               // properties ------------
+               $a = new ParserOutput();
+
+               $a->setProperty( 'foo', 'Foo!' );
+               $a->setProperty( 'bar', 'Bar!' );
+
+               $a->setExtensionData( 'foo', 'Foo!' );
+               $a->setExtensionData( 'bar', 'Bar!' );
+
+               $b = new ParserOutput();
+
+               $b->setProperty( 'zoo', 'Zoo!' );
+               $b->setProperty( 'bar', 'Barrr!' );
+
+               $b->setExtensionData( 'zoo', 'Zoo!' );
+               $b->setExtensionData( 'bar', 'Barrr!' );
+
+               yield 'properties' => [ $a, $b, [
+                       'getProperties' => [
+                               'foo' => 'Foo!',
+                               'bar' => 'Barrr!',
+                               'zoo' => 'Zoo!',
+                       ],
+                       '$mExtensionData' => [
+                               'foo' => 'Foo!',
+                               'bar' => 'Barrr!',
+                               'zoo' => 'Zoo!',
+                       ],
+               ] ];
+       }
+
+       /**
+        * @dataProvider provideMergeTrackingMetaDataFrom
+        * @covers ParserOutput::mergeTrackingMetaDataFrom
+        *
+        * @param ParserOutput $a
+        * @param ParserOutput $b
+        * @param array $expected
+        */
+       public function testMergeTrackingMetaDataFrom( ParserOutput $a, ParserOutput $b, $expected ) {
+               $a->mergeTrackingMetaDataFrom( $b );
+
+               $this->assertFieldValues( $a, $expected );
+
+               // test twice, to make sure the operation is idempotent
+               $a->mergeTrackingMetaDataFrom( $b );
+
+               $this->assertFieldValues( $a, $expected );
+       }
+
+       public function provideMergeInternalMetaDataFrom() {
+               // hooks
+               $a = new ParserOutput();
+
+               $a->addOutputHook( 'foo', 'X' );
+               $a->addOutputHook( 'bar' );
+
+               $b = new ParserOutput();
+
+               $b->addOutputHook( 'foo', 'Y' );
+               $b->addOutputHook( 'bar' );
+               $b->addOutputHook( 'zoo' );
+
+               yield 'hooks' => [ $a, $b, [
+                       'getOutputHooks' => [
+                               [ 'foo', 'X' ],
+                               [ 'bar', false ],
+                               [ 'foo', 'Y' ],
+                               [ 'zoo', false ],
+                       ],
+               ] ];
+
+               // flags & co
+               $a = new ParserOutput();
+
+               $a->addWarning( 'Oops' );
+               $a->addWarning( 'Whoops' );
+
+               $a->setFlag( 'foo' );
+               $a->setFlag( 'bar' );
+
+               $a->recordOption( 'Foo' );
+               $a->recordOption( 'Bar' );
+
+               $b = new ParserOutput();
+
+               $b->addWarning( 'Yikes' );
+               $b->addWarning( 'Whoops' );
+
+               $b->setFlag( 'zoo' );
+               $b->setFlag( 'bar' );
+
+               $b->recordOption( 'Zoo' );
+               $b->recordOption( 'Bar' );
+
+               yield 'flags' => [ $a, $b, [
+                       'getWarnings' => [ 'Oops', 'Whoops', 'Yikes' ],
+                       '$mFlags' => [ 'foo' => true, 'bar' => true, 'zoo' => true ],
+                       'getUsedOptions' => [ 'Foo', 'Bar', 'Zoo' ],
+               ] ];
+
+               // timestamp ------------
+               $a = new ParserOutput();
+               $a->setTimestamp( '20180101000011' );
+               $b = new ParserOutput();
+               yield 'only left timestamp' => [ $a, $b, [ 'getTimestamp' => '20180101000011' ] ];
+
+               $a = new ParserOutput();
+               $b = new ParserOutput();
+               $b->setTimestamp( '20180101000011' );
+               yield 'only right timestamp' => [ $a, $b, [ 'getTimestamp' => '20180101000011' ] ];
+
+               $a = new ParserOutput();
+               $a->setTimestamp( '20180101000011' );
+               $b = new ParserOutput();
+               $b->setTimestamp( '20180101000001' );
+               yield 'left timestamp wins' => [ $a, $b, [ 'getTimestamp' => '20180101000011' ] ];
+
+               $a = new ParserOutput();
+               $a->setTimestamp( '20180101000001' );
+               $b = new ParserOutput();
+               $b->setTimestamp( '20180101000011' );
+               yield 'right timestamp wins' => [ $a, $b, [ 'getTimestamp' => '20180101000011' ] ];
+
+               // speculative rev id ------------
+               $a = new ParserOutput();
+               $a->setSpeculativeRevIdUsed( 9 );
+               $b = new ParserOutput();
+               yield 'only left speculative rev id' => [ $a, $b, [ 'getSpeculativeRevIdUsed' => 9 ] ];
+
+               $a = new ParserOutput();
+               $b = new ParserOutput();
+               $b->setSpeculativeRevIdUsed( 9 );
+               yield 'only right speculative rev id' => [ $a, $b, [ 'getSpeculativeRevIdUsed' => 9 ] ];
+
+               $a = new ParserOutput();
+               $a->setSpeculativeRevIdUsed( 9 );
+               $b = new ParserOutput();
+               $b->setSpeculativeRevIdUsed( 9 );
+               yield 'same speculative rev id' => [ $a, $b, [ 'getSpeculativeRevIdUsed' => 9 ] ];
+
+               // limit report (recursive max) ------------
+               $a = new ParserOutput();
+
+               $a->setLimitReportData( 'naive1', 7 );
+               $a->setLimitReportData( 'naive2', 27 );
+
+               $a->setLimitReportData( 'limitreport-simple1', 7 );
+               $a->setLimitReportData( 'limitreport-simple2', 27 );
+
+               $a->setLimitReportData( 'limitreport-pair1', [ 7, 9 ] );
+               $a->setLimitReportData( 'limitreport-pair2', [ 27, 29 ] );
+
+               $a->setLimitReportData( 'limitreport-more1', [ 7, 9, 1 ] );
+               $a->setLimitReportData( 'limitreport-more2', [ 27, 29, 21 ] );
+
+               $a->setLimitReportData( 'limitreport-only-a', 13 );
+
+               $b = new ParserOutput();
+
+               $b->setLimitReportData( 'naive1', 17 );
+               $b->setLimitReportData( 'naive2', 17 );
+
+               $b->setLimitReportData( 'limitreport-simple1', 17 );
+               $b->setLimitReportData( 'limitreport-simple2', 17 );
+
+               $b->setLimitReportData( 'limitreport-pair1', [ 17, 19 ] );
+               $b->setLimitReportData( 'limitreport-pair2', [ 17, 19 ] );
+
+               $b->setLimitReportData( 'limitreport-more1', [ 17, 19, 11 ] );
+               $b->setLimitReportData( 'limitreport-more2', [ 17, 19, 11 ] );
+
+               $b->setLimitReportData( 'limitreport-only-b', 23 );
+
+               // first write wins
+               yield 'limit report' => [ $a, $b, [
+                       'getLimitReportData' => [
+                               'naive1' => 7,
+                               'naive2' => 27,
+                               'limitreport-simple1' => 7,
+                               'limitreport-simple2' => 27,
+                               'limitreport-pair1' => [ 7, 9 ],
+                               'limitreport-pair2' => [ 27, 29 ],
+                               'limitreport-more1' => [ 7, 9, 1 ],
+                               'limitreport-more2' => [ 27, 29, 21 ],
+                               'limitreport-only-a' => 13,
+                       ],
+                       'getLimitReportJSData' => [
+                               'naive1' => 7,
+                               'naive2' => 27,
+                               'limitreport' => [
+                                       'simple1' => 7,
+                                       'simple2' => 27,
+                                       'pair1' => [ 'value' => 7, 'limit' => 9 ],
+                                       'pair2' => [ 'value' => 27, 'limit' => 29 ],
+                                       'more1' => [ 7, 9, 1 ],
+                                       'more2' => [ 27, 29, 21 ],
+                                       'only-a' => 13,
+                               ],
+                       ],
+               ] ];
+       }
+
+       /**
+        * @dataProvider provideMergeInternalMetaDataFrom
+        * @covers ParserOutput::mergeInternalMetaDataFrom
+        *
+        * @param ParserOutput $a
+        * @param ParserOutput $b
+        * @param array $expected
+        */
+       public function testMergeInternalMetaDataFrom( ParserOutput $a, ParserOutput $b, $expected ) {
+               $a->mergeInternalMetaDataFrom( $b );
+
+               $this->assertFieldValues( $a, $expected );
+
+               // test twice, to make sure the operation is idempotent
+               $a->mergeInternalMetaDataFrom( $b );
+
+               $this->assertFieldValues( $a, $expected );
+       }
+
+       public function testMergeInternalMetaDataFrom_parseStartTime() {
+               /** @var object $a */
+               $a = new ParserOutput();
+               $a = TestingAccessWrapper::newFromObject( $a );
+
+               $a->resetParseStartTime();
+               $aClocks = $a->mParseStartTime;
+
+               $b = new ParserOutput();
+
+               $a->mergeInternalMetaDataFrom( $b );
+               $mergedClocks = $a->mParseStartTime;
+
+               foreach ( $mergedClocks as $clock => $timestamp ) {
+                       $this->assertSame( $aClocks[$clock], $timestamp, $clock );
+               }
+
+               // try again, with times in $b also set, and later than $a's
+               usleep( 1234 );
+
+               /** @var object $b */
+               $b = new ParserOutput();
+               $b = TestingAccessWrapper::newFromObject( $b );
+
+               $b->resetParseStartTime();
+
+               $bClocks = $b->mParseStartTime;
+
+               $a->mergeInternalMetaDataFrom( $b->object, 'b' );
+               $mergedClocks = $a->mParseStartTime;
+
+               foreach ( $mergedClocks as $clock => $timestamp ) {
+                       $this->assertSame( $aClocks[$clock], $timestamp, $clock );
+                       $this->assertLessThanOrEqual( $bClocks[$clock], $timestamp, $clock );
+               }
+
+               // try again, with $a's times being later
+               usleep( 1234 );
+               $a->resetParseStartTime();
+               $aClocks = $a->mParseStartTime;
+
+               $a->mergeInternalMetaDataFrom( $b->object, 'b' );
+               $mergedClocks = $a->mParseStartTime;
+
+               foreach ( $mergedClocks as $clock => $timestamp ) {
+                       $this->assertSame( $bClocks[$clock], $timestamp, $clock );
+                       $this->assertLessThanOrEqual( $aClocks[$clock], $timestamp, $clock );
+               }
+
+               // try again, with no times in $a set
+               $a = new ParserOutput();
+               $a = TestingAccessWrapper::newFromObject( $a );
+
+               $a->mergeInternalMetaDataFrom( $b->object, 'b' );
+               $mergedClocks = $a->mParseStartTime;
+
+               foreach ( $mergedClocks as $clock => $timestamp ) {
+                       $this->assertSame( $bClocks[$clock], $timestamp, $clock );
+               }
+       }
+
 }
index c925339..0c707d5 100644 (file)
@@ -151,8 +151,8 @@ class ResourceLoaderModuleTest extends ResourceLoaderTestCase {
                ] );
                $this->assertEquals( $raw, $module->getScript( $context ), 'Raw script' );
                $this->assertEquals(
-                       [ 'scripts' => $build ],
-                       $module->getModuleContent( $context ),
+                       $build,
+                       $module->getModuleContent( $context )[ 'scripts' ],
                        $message
                );
        }
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',
+                       ],
+               ];
+       }
 }
index 9b90bfe..4b7a7eb 100644 (file)
@@ -24,7 +24,7 @@ abstract class DumpTestCase extends MediaWikiLangTestCase {
         * exception and store it until we are in setUp and may finally rethrow
         * the exception without crashing the test suite.
         *
-        * @var Exception|null
+        * @var \Exception|null
         */
        protected $exceptionFromAddDBData = null;
 
index ad9bf3e..b8a60be 100644 (file)
@@ -2,7 +2,9 @@
 
 namespace MediaWiki\Tests\Maintenance;
 
+use Exception;
 use MediaWikiLangTestCase;
+use MWException;
 use TextContentHandler;
 use TextPassDumper;
 use Title;