/maintenance/dev/data
/AdminSettings.php
/LocalSettings.php
-/StartProfiler.php
# Building & testing
npm-debug.log
<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>
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
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
'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',
"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",
* 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 ) {
* 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:
*
$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;
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 );
# 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;
}
$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 ) );
}
/**
- * 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()
}
}
- 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 );
* @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
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;
return $this->getService( 'RevisionLookup' );
}
+ /**
+ * @since 1.32
+ * @return RevisionRenderer
+ */
+ public function getRevisionRenderer() {
+ return $this->getService( 'RevisionRenderer' );
+ }
+
/**
* @since 1.31
* @return RevisionStore
if ( !$this->mArticleBodyOnly ) {
$sk = $this->getSkin();
-
- if ( $sk->shouldPreloadLogo() ) {
- $this->addLogoPreloadLinkHeaders();
- }
}
$linkHeader = $this->getLinkHeader();
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;
}
}
] );
}
- /**
- * 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.
*
--- /dev/null
+<?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;
+ }
+ }
+
+}
--- /dev/null
+<?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;
+ }
+
+}
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;
return $services->getRevisionStore();
},
+ 'RevisionRenderer' => function ( MediaWikiServices $services ) : RevisionRenderer {
+ return new RevisionRenderer( $services->getDBLoadBalancer() );
+ },
+
'RevisionStore' => function ( MediaWikiServices $services ) : RevisionStore {
return $services->getRevisionStoreFactory()->getRevisionStore();
},
* 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 {
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;
*/
private $contLang;
- /**
- * @var LoggerInterface
- */
- private $saveParseLogger;
-
/**
* @var JobQueueGroup
*/
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.
/**
* @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();
}
/**
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;
}
if ( $this->revision
&& $user
+ && $this->revision->getUser( RevisionRecord::RAW )
&& $this->revision->getUser( RevisionRecord::RAW )->getName() !== $user->getName()
) {
return false;
if ( $revision
&& $this->user
+ && $this->revision->getUser( RevisionRecord::RAW )
&& $revision->getUser( RevisionRecord::RAW )->getName() !== $this->user->getName()
) {
return false;
return false;
}
- if ( $this->pstContentSlots
- && $revision
- && !$this->pstContentSlots->hasSameContent( $revision->getSlots() )
+ if ( $revision
+ && $this->revision
+ && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() )
) {
return false;
}
* @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;
}
/**
}
/**
- * 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;
}
}
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;
$this->slotsOutput = [];
$this->canonicalParserOutput = null;
- $this->canonicalParserOptions = null;
// The edit may have already been prepared via api.php?action=stashedit
$stashedEdit = false;
$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 );
$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 ) {
}
private function assertPrepared( $method ) {
- if ( !$this->pstContentSlots ) {
+ if ( !$this->revision ) {
throw new LogicException(
'Must call prepareContent() or prepareUpdate() before calling ' . $method
);
/**
* 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();
}
/**
$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(),
* - 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
'$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,
);
}
- if ( $this->revision ) {
+ if ( $this->revision && $this->revision->getId() ) {
if ( $this->revision->getId() === $revision->getId() ) {
return; // nothing to do!
} else {
}
}
- 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!'
$this->options['created'] = ( $this->pageState['oldId'] === 0 );
$this->revision = $revision;
- $this->pstContentSlots = $revision->getSlots();
$this->doTransition( 'has-revision' );
}
// 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
$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();
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();
}
/**
// 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() ) );
}
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 );
}
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.
*/
// 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 );
}
$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 ) {
$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 );
}
$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';
"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>文件中。",
"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><head></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": "您沒有權限來編輯頁面。",
}
// 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 );
}
$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,
// 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__ );
public static function modifyDisplayQuery( &$tables, &$fields, &$conds,
&$join_conds, &$options, $filter_tag = ''
) {
- global $wgUseTagFilter;
+ global $wgUseTagFilter, $wgChangeTagsSchemaMigrationStage;
// Normalize to arrays
$tables = (array)$tables;
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 ) {
$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 )
// 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
}
// 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__ );
}
$po = new ParserOutput();
+ $options->registerWatcher( [ $po, 'recordOption' ] );
if ( Hooks::run( 'ContentGetParserOutput',
[ $this, $title, $revId, $options, $generateHtml, &$po ] )
}
Hooks::run( 'ContentAlterParserOutput', [ $this, $title, $po ] );
+ $options->registerWatcher( null );
return $po;
}
$html = '';
}
+ $output->clearWrapperDivClass();
$output->setText( $html );
}
// 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();
*/
use Wikimedia\Rdbms\IDatabase;
+use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MediaWikiServices;
use Wikimedia\ScopedCallback;
// 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
* @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;
* '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
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' );
+ }
}
/**
$error = new OOUI\HtmlSnippet( $error );
}
- $notices = $this->getNotices();
+ $notices = $this->getNotices( 'skip deprecation' );
foreach ( $notices as &$notice ) {
$notice = new OOUI\HtmlSnippet( $notice );
}
* 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 );
* 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'] ) ) {
* 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
*/
// 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
$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.
$lookAhead = ( $idx + 1 < $maxLen ) ? $str[$idx + 1] : '';
$lookBehind = ( $idx - 1 >= 0 ) ? $str[$idx - 1] : '';
if ( $inString ) {
- continue;
+ break;
} elseif ( !$inComment &&
( $lookAhead === '/' || $lookAhead === '*' )
* 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;
/** @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 */
/** @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;
/**
# 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 ) {
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;
return MediaWikiServices::getInstance()->getRevisionStore();
}
+ /**
+ * @return RevisionRenderer
+ */
+ private function getRevisionRenderer() {
+ return MediaWikiServices::getInstance()->getRevisionRenderer();
+ }
+
/**
* @return ParserCache
*/
// 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__ );
}
$derivedDataUpdater = new DerivedPageDataUpdater(
$this, // NOTE: eventually, PageUpdater should not know about WikiPage
$this->getRevisionStore(),
+ $this->getRevisionRenderer(),
$this->getParserCache(),
JobQueueGroup::singleton(),
MessageCache::singleton(),
# 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 );
* @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
*
$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':
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
);
# 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 );
}
$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();
*/
private static $lazyOptions = [
'dateformat' => [ __CLASS__, 'initDateFormat' ],
+ 'speculativeRevId' => [ __CLASS__, 'initSpeculativeRevId' ],
];
/**
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() {
* @return callable|null Old value
*/
public function setSpeculativeRevIdCallback( $x ) {
+ $this->setOption( 'speculativeRevId', null ); // reset
return $this->setOptionLegacy( 'speculativeRevIdCallback', $x );
}
'currentRevisionCallback' => [ Parser::class, 'statelessFetchRevision' ],
'templateCallback' => [ Parser::class, 'statelessFetchTemplate' ],
'speculativeRevIdCallback' => null,
+ 'speculativeRevId' => null,
];
Hooks::run( 'ParserOptionsRegister', [
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,
/** @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;
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 = ''
) {
$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
* @since 1.27
*/
public function getRawText() {
+ if ( $this->mText === null ) {
+ throw new LogicException( 'This ParserOutput contains no text!' );
+ }
+
return $this->mText;
}
* - 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
'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'] ) {
);
}
+ // 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
return $this->mExternalLinks;
}
+ public function setNoGallery( $value ) {
+ $this->mNoGallery = (bool)$value;
+ }
public function getNoGallery() {
return $this->mNoGallery;
}
[ '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 );
+ }
+
}
'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 {
$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;
}
$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 );
}
}
if ( !is_null( $deps ) ) {
$this->fileDeps[$vary] = self::expandRelativePaths(
- (array)FormatJson::decode( $deps, true )
+ (array)json_decode( $deps, true )
);
} else {
$this->fileDeps[$vary] = [];
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',
[
$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 );
* @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
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.
*
}
/**
- * 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' );
$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 );',
}
$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;
/**
* 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
*/
/**
* 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 );
$warningDone = true;
}
$startPos += 2;
- continue;
+ break;
}
// Recursively parse another rule
$inner .= $this->recursiveConvertRule( $text, $variant, $startPos, $depth + 1 );
"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 →",
"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": "наступная старонка →",
"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",
"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|আরও লিঙ্ক]] দেখাও।",
"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 <html> nelze používat mimo běžné stránky.",
"gotointerwiki": "Opustit {{GRAMMAR:4sg|{{SITENAME}}}}",
"gotointerwiki-invalid": "Zadaný název je neplatný.",
"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",
"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",
"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": "<html>-Tags können nicht außerhalb von normalen Seiten verwendet werden.",
"gotointerwiki": "{{SITENAME}} verlassen",
"gotointerwiki-invalid": "Der angegebene Titel ist ungültig.",
"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": "دسترسیها",
"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}}"
}
"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",
"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é !",
"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.",
"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",
"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": "<html>-ознаките не може да се користат вон нормалните страници.",
"gotointerwiki": "Го напуштате {{SITENAME}}",
"gotointerwiki-invalid": "Укажаниот наслов е неважечки.",
"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": "അംഗത്വം സൃഷ്ടിക്കുന്നത് തുടരുക",
"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": "ꯆꯥꯡ ꯅꯥꯏꯗꯕ ꯂꯥꯃꯥꯏꯒꯤ ꯃꯔꯥ"
"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.",
"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": "ဖယ်ရှားရန်",
"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 %",
"quotation-marks": "«$1»",
"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 →",
"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",
"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 <html> não podem ser utilizadas fora de páginas normais.",
"gotointerwiki": "A sair da wiki {{SITENAME}}",
"gotointerwiki-invalid": "O título especificado é inválido.",
"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",
"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": "<html> теги могут быть использованы только в пределах обычных страниц.",
"gotointerwiki": "Покидаем {{grammar:accusative|{{SITENAME}}}}...",
"gotointerwiki-invalid": "Указан некорректный заголовок.",
"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": "صفحا پڙهو",
"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!",
"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": "<html> тагови не могу да се користе ван нормалних страница.",
"gotointerwiki": "Напуштање пројекта {{SITENAME}}",
"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",
"compare-rev1": "Беренче юрама",
"compare-rev2": "Икенче юрама",
"compare-submit": "Чагыштыр",
+ "permanentlink": "Даими сылтама",
"dberr-problems": "Гафу итегез! Сайтта техник кыенлыклар чыкты.",
"dberr-again": "Сәхифәне берничә минуттан соң яңартып карагыз.",
"dberr-info": "(Мәгълүматлар базасы серверы белән тоташырга мөмкин түгел: $1)",
"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|داخل ہونا]] ضروری ہے۔",
"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",
"confirm-unwatch-top": "从监视列表中删除此页吗?",
"confirm-rollback-button": "确定",
"confirm-rollback-top": "回退此页面的编辑么?",
+ "confirm-mcrundo-title": "撤销一次更改",
+ "mcrundofailed": "撤销失败",
+ "mcrundo-missingparam": "请求中缺少必需参数。",
+ "mcrundo-changed": "在您访问的差异以来,此页面已更新。请复核新的更改。",
"semicolon-separator": ";",
"comma-separator": "、",
"colon-separator": ":",
"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": "<html> 標籤無法在一般頁面之外使用。",
"gotointerwiki": "離開 {{SITENAME}}",
"gotointerwiki-invalid": "指定的標題無效。",
EXCLUDE_SYMLINKS = YES
EXCLUDE_PATTERNS = LocalSettings.php \
AdminSettings.php \
- StartProfiler.php \
.svn \
*/.git/* \
{{EXCLUDE_PATTERNS}}
*/
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";
}
### 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:
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:
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
class ManageForeignResources extends Maintenance {
private $defaultAlgo = 'sha384';
private $tmpParentDir;
+ private $action;
+ private $failAfterOutput = false;
public function __construct() {
global $IP;
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.
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 );
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 ) ) {
}
}
+ private function cleanUp() {
+ wfRecursiveRemoveDir( $this->tmpParentDir );
+ }
+
+ protected function fatalError( $msg, $exitCode = 1 ) {
+ $this->cleanUp();
+ parent::fatalError( $msg, $exitCode );
+ }
+
/**
* Basic YAML parser.
*
"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": {
],
'jquery.localize' => [
'scripts' => 'resources/src/jquery/jquery.localize.js',
+ 'deprecated' => 'Please use "jquery.i18n" instead.',
],
'jquery.makeCollapsible' => [
'dependencies' => [ 'jquery.makeCollapsible.styles' ],
'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( {
* @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 ) {}
},
// 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
*/
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() {
}
public function testReadingHead() {
- $dir = __DIR__ . '/../data/gitrepo/1';
+ $dir = self::$tempDir . '/gitrepo/1';
$fixture = new GitInfo( $dir );
$this->assertEquals( 'refs/heads/master', $fixture->getHead() );
}
public function testIndirection() {
- $dir = __DIR__ . '/../data/gitrepo/2';
+ $dir = self::$tempDir . '/gitrepo/2';
$fixture = new GitInfo( $dir );
$this->assertEquals( 'refs/heads/master', $fixture->getHead() );
}
public function testIndirection2() {
- $dir = __DIR__ . '/../data/gitrepo/3';
+ $dir = self::$tempDir . '/gitrepo/3';
$fixture = new GitInfo( $dir );
$this->assertEquals( 'refs/heads/master', $fixture->getHead() );
}
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() );
<?php
class HtmlTest extends MediaWikiTestCase {
+ private $restoreWarnings;
protected function setUp() {
parent::setUp();
] );
$this->setUserLang( $langObj );
$this->setContentLang( $langObj );
+ $this->restoreWarnings = false;
+ }
+
+ protected function tearDown() {
+ if ( $this->restoreWarnings ) {
+ $this->restoreWarnings = false;
+ Wikimedia\restoreWarnings();
+ }
+ parent::tearDown();
}
/**
],
'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,
],
];
}
* @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 );
}
}
] );
}
- /**
- * @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
*/
--- /dev/null
+<?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' );
+ }
+
+}
--- /dev/null
+<?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' );
+ }
+
+}
$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.
$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(
$updater->prepareUpdate( $rev );
$options2 = $updater->getCanonicalParserOptions();
- $this->assertNotSame( $options1, $options2 );
$currentRev = call_user_func( $options2->getCurrentRevisionCallback(), $page->getTitle() );
$this->assertSame( $rev->getId(), $currentRev->getId() );
* @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() );
$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() );
$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() ) );
$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 );
$canonicalOutput = $updater->getCanonicalParserOutput();
$this->assertContains( 'first', $canonicalOutput->getText() );
$this->assertContains( '<a ', $canonicalOutput->getText() );
+ $this->assertContains( 'inherited ', $canonicalOutput->getText() );
$this->assertNotEmpty( $canonicalOutput->getLinks() );
}
* @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() );
$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() );
$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() ) );
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' );
$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 );
// TODO: test category membership update (with setRcWatchCategoryMembership())
}
+ private function hasMultiSlotSupport() {
+ global $wgMultiContentRevisionSchemaMigrationStage;
+
+ return ( $wgMultiContentRevisionSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW )
+ && ( $wgMultiContentRevisionSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW );
+ }
+
}
use CommentStoreComment;
use InvalidArgumentException;
use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\MutableRevisionSlots;
use MediaWiki\Storage\RevisionAccessException;
use MediaWiki\Storage\RevisionRecord;
use MediaWiki\Storage\RevisionSlotsUpdate;
$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() );
use MediaWiki\MediaWikiServices;
use MediaWiki\Storage\RevisionRecord;
use MediaWikiTestCase;
+use ParserOptions;
use RecentChange;
use Revision;
use TextContent;
);
}
+ 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() );
+ }
+
}
$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 );
--- /dev/null
+<?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 ) );
+ }
+
+}
];
}
+ 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()
}
$wrap->defaults = null;
$wrap->lazyOptions = [
'dateformat' => [ ParserOptions::class, 'initDateFormat' ],
+ 'speculativeRevId' => [ ParserOptions::class, 'initSpeculativeRevId' ],
];
$wrap->inCacheKey = [
'dateformat' => true,
], 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() );
+ }
+
}
<?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 [
$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
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>
</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
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>
</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&action=edit&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>
</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>
<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&action=edit&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&action=edit&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&action=edit&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&action=edit&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
// 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 );
+ }
+ }
+
}
] );
$this->assertEquals( $raw, $module->getScript( $context ), 'Raw script' );
$this->assertEquals(
- [ 'scripts' => $build ],
- $module->getModuleContent( $context ),
+ $build,
+ $module->getModuleContent( $context )[ 'scripts' ],
$message
);
}
<?php
+use Wikimedia\TestingAccessWrapper;
+
/**
* @group ResourceLoader
*/
-class ResourceLoaderSkinModuleTest extends PHPUnit\Framework\TestCase {
+class ResourceLoaderSkinModuleTest extends MediaWikiTestCase {
use MediaWikiCoversValidator;
}
/**
- * @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' => [
],
];
}
+
+ /**
+ * @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',
+ ],
+ ];
+ }
}
* 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;
namespace MediaWiki\Tests\Maintenance;
+use Exception;
use MediaWikiLangTestCase;
+use MWException;
use TextContentHandler;
use TextPassDumper;
use Title;