From: jenkins-bot Date: Fri, 15 Dec 2017 00:26:44 +0000 (+0000) Subject: Merge "mw.rcfilters.ui.MenuSelectWidget: Always open this menu downwards" X-Git-Tag: 1.31.0-rc.0~1193 X-Git-Url: http://git.cyclocoop.org/%7B%24www_url%7Dadmin/password.php?a=commitdiff_plain;h=dcfb68a6f700c25dd1dad85da9777cae9c92a586;hp=e153fe1ae46b0a93df6ef223e9903b806e4437cf;p=lhc%2Fweb%2Fwiklou.git Merge "mw.rcfilters.ui.MenuSelectWidget: Always open this menu downwards" --- diff --git a/autoload.php b/autoload.php index 8aa6afbca2..6b8387b4f7 100644 --- a/autoload.php +++ b/autoload.php @@ -449,7 +449,7 @@ $wgAutoloadLocalClasses = [ 'Exif' => __DIR__ . '/includes/media/Exif.php', 'ExifBitmapHandler' => __DIR__ . '/includes/media/ExifBitmap.php', 'ExplodeIterator' => __DIR__ . '/includes/libs/ExplodeIterator.php', - 'ExportProgressFilter' => __DIR__ . '/maintenance/backup.inc', + 'ExportProgressFilter' => __DIR__ . '/includes/export/ExportProgressFilter.php', 'ExportSites' => __DIR__ . '/maintenance/exportSites.php', 'ExtensionJsonValidationError' => __DIR__ . '/includes/registration/ExtensionJsonValidationError.php', 'ExtensionJsonValidator' => __DIR__ . '/includes/registration/ExtensionJsonValidator.php', @@ -942,6 +942,22 @@ $wgAutoloadLocalClasses = [ 'MediaWiki\\Shell\\Result' => __DIR__ . '/includes/shell/Result.php', 'MediaWiki\\Shell\\Shell' => __DIR__ . '/includes/shell/Shell.php', 'MediaWiki\\Site\\MediaWikiPageNameNormalizer' => __DIR__ . '/includes/site/MediaWikiPageNameNormalizer.php', + 'MediaWiki\\Storage\\BlobAccessException' => __DIR__ . '/includes/Storage/BlobAccessException.php', + 'MediaWiki\\Storage\\BlobStore' => __DIR__ . '/includes/Storage/BlobStore.php', + 'MediaWiki\\Storage\\IncompleteRevisionException' => __DIR__ . '/includes/Storage/IncompleteRevisionException.php', + 'MediaWiki\\Storage\\MutableRevisionRecord' => __DIR__ . '/includes/Storage/MutableRevisionRecord.php', + 'MediaWiki\\Storage\\MutableRevisionSlots' => __DIR__ . '/includes/Storage/MutableRevisionSlots.php', + 'MediaWiki\\Storage\\RevisionAccessException' => __DIR__ . '/includes/Storage/RevisionAccessException.php', + 'MediaWiki\\Storage\\RevisionArchiveRecord' => __DIR__ . '/includes/Storage/RevisionArchiveRecord.php', + 'MediaWiki\\Storage\\RevisionFactory' => __DIR__ . '/includes/Storage/RevisionFactory.php', + 'MediaWiki\\Storage\\RevisionLookup' => __DIR__ . '/includes/Storage/RevisionLookup.php', + 'MediaWiki\\Storage\\RevisionRecord' => __DIR__ . '/includes/Storage/RevisionRecord.php', + 'MediaWiki\\Storage\\RevisionSlots' => __DIR__ . '/includes/Storage/RevisionSlots.php', + 'MediaWiki\\Storage\\RevisionStore' => __DIR__ . '/includes/Storage/RevisionStore.php', + 'MediaWiki\\Storage\\RevisionStoreRecord' => __DIR__ . '/includes/Storage/RevisionStoreRecord.php', + 'MediaWiki\\Storage\\SlotRecord' => __DIR__ . '/includes/Storage/SlotRecord.php', + 'MediaWiki\\Storage\\SqlBlobStore' => __DIR__ . '/includes/Storage/SqlBlobStore.php', + 'MediaWiki\\Storage\\SuppressedDataException' => __DIR__ . '/includes/Storage/SuppressedDataException.php', 'MediaWiki\\Tidy\\BalanceActiveFormattingElements' => __DIR__ . '/includes/tidy/Balancer.php', 'MediaWiki\\Tidy\\BalanceElement' => __DIR__ . '/includes/tidy/Balancer.php', 'MediaWiki\\Tidy\\BalanceMarker' => __DIR__ . '/includes/tidy/Balancer.php', @@ -961,6 +977,7 @@ $wgAutoloadLocalClasses = [ 'MediaWiki\\Tidy\\RemexMungerData' => __DIR__ . '/includes/tidy/RemexMungerData.php', 'MediaWiki\\Tidy\\TidyDriverBase' => __DIR__ . '/includes/tidy/TidyDriverBase.php', 'MediaWiki\\User\\UserIdentity' => __DIR__ . '/includes/user/UserIdentity.php', + 'MediaWiki\\User\\UserIdentityValue' => __DIR__ . '/includes/user/UserIdentityValue.php', 'MediaWiki\\Widget\\ComplexNamespaceInputWidget' => __DIR__ . '/includes/widget/ComplexNamespaceInputWidget.php', 'MediaWiki\\Widget\\ComplexTitleInputWidget' => __DIR__ . '/includes/widget/ComplexTitleInputWidget.php', 'MediaWiki\\Widget\\DateInputWidget' => __DIR__ . '/includes/widget/DateInputWidget.php', diff --git a/includes/Feed.php b/includes/Feed.php index 35f2ce9438..0e715df2ff 100644 --- a/includes/Feed.php +++ b/includes/Feed.php @@ -84,13 +84,23 @@ class FeedItem { } /** - * Get the unique id of this item - * + * Get the unique id of this item; already xml-encoded + * @return string + */ + public function getUniqueID() { + $id = $this->getUniqueIDUnescaped(); + if ( $id ) { + return $this->xmlEncode( $id ); + } + } + + /** + * Get the unique id of this item, without any escaping * @return string */ - public function getUniqueId() { + public function getUniqueIdUnescaped() { if ( $this->uniqueId ) { - return $this->xmlEncode( wfExpandUrl( $this->uniqueId, PROTO_CURRENT ) ); + return wfExpandUrl( $this->uniqueId, PROTO_CURRENT ); } } @@ -123,6 +133,14 @@ class FeedItem { return $this->xmlEncode( $this->url ); } + /** Get the URL of this item without any escaping + * + * @return string + */ + public function getUrlUnescaped() { + return $this->url; + } + /** * Get the description of this item; already xml-encoded * @@ -132,6 +150,14 @@ class FeedItem { return $this->xmlEncode( $this->description ); } + /** + * Get the description of this item without any escaping + * + */ + public function getDescriptionUnescaped() { + return $this->description; + } + /** * Get the language of this item * @@ -160,6 +186,15 @@ class FeedItem { return $this->xmlEncode( $this->author ); } + /** + * Get the author of this item without any escaping + * + * @return string + */ + public function getAuthorUnescaped() { + return $this->author; + } + /** * Get the comment of this item; already xml-encoded * @@ -169,6 +204,15 @@ class FeedItem { return $this->xmlEncode( $this->comments ); } + /** + * Get the comment of this item without any escaping + * + * @return string + */ + public function getCommentsUnescaped() { + return $this->comments; + } + /** * Quickie hack... strip out wikilinks to more legible form from the comment. * @@ -187,6 +231,23 @@ class FeedItem { * @ingroup Feed */ abstract class ChannelFeed extends FeedItem { + + /** @var TemplateParser */ + protected $templateParser; + + /** + * @param string|Title $title Feed's title + * @param string $description + * @param string $url URL uniquely designating the feed. + * @param string $date Feed's date + * @param string $author Author's user name + * @param string $comments + */ + function __construct( $title, $description, $url, $date = '', $author = '', $comments = '' ) { + parent::__construct( $title, $description, $url, $date, $author, $comments ); + $this->templateParser = new TemplateParser(); + } + /** * Generate Header of the feed * @par Example: @@ -279,13 +340,15 @@ abstract class ChannelFeed extends FeedItem { class RSSFeed extends ChannelFeed { /** - * Format a date given a timestamp + * Format a date given a timestamp. If a timestamp is not given, nothing is returned * - * @param int $ts Timestamp - * @return string Date string + * @param int|null $ts Timestamp + * @return string|null Date string */ function formatTime( $ts ) { - return gmdate( 'D, d M Y H:i:s \G\M\T', wfTimestamp( TS_UNIX, $ts ) ); + if ( $ts ) { + return gmdate( 'D, d M Y H:i:s \G\M\T', wfTimestamp( TS_UNIX, $ts ) ); + } } /** @@ -295,15 +358,17 @@ class RSSFeed extends ChannelFeed { global $wgVersion; $this->outXmlHeader(); - ?> - - <?php print $this->getTitle() ?> - getUrl(), PROTO_CURRENT ) ?> - getDescription() ?> - getLanguage() ?> - MediaWiki - formatTime( wfTimestampNow() ) ?> - $this->getTitle(), + 'url' => $this->xmlEncode( wfExpandUrl( $this->getUrlUnescaped(), PROTO_CURRENT ) ), + 'description' => $this->getDescription(), + 'language' => $this->xmlEncode( $this->getLanguage() ), + 'version' => $this->xmlEncode( $wgVersion ), + 'timestamp' => $this->xmlEncode( $this->formatTime( wfTimestampNow() ) ) + ]; + print $this->templateParser->processTemplate( 'RSSHeader', $templateParams ); } /** @@ -311,28 +376,30 @@ class RSSFeed extends ChannelFeed { * @param FeedItem $item Item to be output */ function outItem( $item ) { - // @codingStandardsIgnoreStart Ignore long lines and formatting issues. - ?> - - <?php print $item->getTitle(); ?> - getUrl(), PROTO_CURRENT ); ?> - rssIsPermalink ) { print ' isPermaLink="false"'; } ?>>getUniqueId(); ?> - getDescription() ?> - getDate() ) { ?>formatTime( $item->getDate() ); ?> - getAuthor() ) { ?>getAuthor(); ?> - getComments() ) { ?>getComments(), PROTO_CURRENT ); ?> - - $item->getTitle(), + "url" => $this->xmlEncode( wfExpandUrl( $item->getUrlUnescaped(), PROTO_CURRENT ) ), + "permalink" => $item->rssIsPermalink, + "uniqueID" => $item->getUniqueId(), + "description" => $item->getDescription(), + "date" => $this->xmlEncode( $this->formatTime( $item->getDate() ) ), + "author" => $item->getAuthor() + ]; + $comments = $item->getCommentsUnescaped(); + if ( $comments ) { + $commentsEscaped = $this->xmlEncode( wfExpandUrl( $comments, PROTO_CURRENT ) ); + $templateParams["comments"] = $commentsEscaped; + } + print $this->templateParser->processTemplate( 'RSSItem', $templateParams ); } /** * Output an RSS 2.0 footer */ function outFooter() { - ?> - -"; } } @@ -343,14 +410,16 @@ class RSSFeed extends ChannelFeed { */ class AtomFeed extends ChannelFeed { /** - * Format a date given timestamp. + * Format a date given timestamp, if one is given. * - * @param string|int $timestamp - * @return string + * @param string|int|null $timestamp + * @return string|null */ function formatTime( $timestamp ) { - // need to use RFC 822 time format at least for rss2.0 - return gmdate( 'Y-m-d\TH:i:s', wfTimestamp( TS_UNIX, $timestamp ) ); + if ( $timestamp ) { + // need to use RFC 822 time format at least for rss2.0 + return gmdate( 'Y-m-d\TH:i:s', wfTimestamp( TS_UNIX, $timestamp ) ); + } } /** @@ -358,20 +427,20 @@ class AtomFeed extends ChannelFeed { */ function outHeader() { global $wgVersion; - $this->outXmlHeader(); - // @codingStandardsIgnoreStart Ignore long lines and formatting issues. - ?> - getFeedId() ?> - <?php print $this->getTitle() ?> - - - formatTime( wfTimestampNow() ) ?>Z - getDescription() ?> - MediaWiki - - $this->xmlEncode( $this->getLanguage() ), + 'feedID' => $this->getFeedID(), + 'title' => $this->getTitle(), + 'url' => $this->xmlEncode( wfExpandUrl( $this->getUrlUnescaped(), PROTO_CURRENT ) ), + 'selfUrl' => $this->getSelfUrl(), + 'timestamp' => $this->xmlEncode( $this->formatTime( wfTimestampNow() ) ), + 'description' => $this->getDescription(), + 'version' => $this->xmlEncode( $wgVersion ), + ]; + print $this->templateParser->processTemplate( 'AtomHeader', $templateParams ); } /** @@ -401,30 +470,24 @@ class AtomFeed extends ChannelFeed { */ function outItem( $item ) { global $wgMimeType; - // @codingStandardsIgnoreStart Ignore long lines and formatting issues. - ?> - - getUniqueId(); ?> - <?php print $item->getTitle(); ?> - - getDate() ) { ?> - formatTime( $item->getDate() ); ?>Z - - - getDescription() ?> - getAuthor() ) { ?>getAuthor(); ?> - - -getComments() ) { ?>getComments() ?> - */ + // Manually escaping rather than letting Mustache do it because Mustache + // uses htmlentities, which does not work with XML + $templateParams = [ + "uniqueID" => $item->getUniqueId(), + "title" => $item->getTitle(), + "mimeType" => $this->xmlEncode( $wgMimeType ), + "url" => $this->xmlEncode( wfExpandUrl( $item->getUrlUnescaped(), PROTO_CURRENT ) ), + "date" => $this->xmlEncode( $this->formatTime( $item->getDate() ) ), + "description" => $item->getDescription(), + "author" => $item->getAuthor() + ]; + print $this->templateParser->processTemplate( 'AtomItem', $templateParams ); } /** * Outputs the footer for Atom 1.0 feed (basically '\'). */ - function outFooter() {?> - "; } } diff --git a/includes/MediaWikiServices.php b/includes/MediaWikiServices.php index 19b71f12e1..33d0fd4d3d 100644 --- a/includes/MediaWikiServices.php +++ b/includes/MediaWikiServices.php @@ -11,6 +11,8 @@ use GlobalVarConfig; use Hooks; use IBufferingStatsdDataFactory; use MediaWiki\Shell\CommandFactory; +use MediaWiki\Storage\BlobStore; +use MediaWiki\Storage\RevisionStore; use Wikimedia\Rdbms\LBFactory; use LinkCache; use Wikimedia\Rdbms\LoadBalancer; @@ -698,6 +700,22 @@ class MediaWikiServices extends ServiceContainer { return $this->getService( 'ExternalStoreFactory' ); } + /** + * @since 1.31 + * @return BlobStore + */ + public function getBlobStore() { + return $this->getService( 'BlobStore' ); + } + + /** + * @since 1.31 + * @return RevisionStore + */ + public function getRevisionStore() { + return $this->getService( 'RevisionStore' ); + } + /////////////////////////////////////////////////////////////////////////// // NOTE: When adding a service getter here, don't forget to add a test // case for it in MediaWikiServicesTest::provideGetters() and in diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php index dad0630edf..d21bcef332 100644 --- a/includes/ServiceWiring.php +++ b/includes/ServiceWiring.php @@ -42,6 +42,8 @@ use MediaWiki\Linker\LinkRendererFactory; use MediaWiki\Logger\LoggerFactory; use MediaWiki\MediaWikiServices; use MediaWiki\Shell\CommandFactory; +use MediaWiki\Storage\RevisionStore; +use MediaWiki\Storage\SqlBlobStore; return [ 'DBLoadBalancerFactory' => function ( MediaWikiServices $services ) { @@ -456,6 +458,46 @@ return [ ); }, + 'RevisionStore' => function ( MediaWikiServices $services ) { + /** @var SqlBlobStore $blobStore */ + $blobStore = $services->getService( '_SqlBlobStore' ); + + $store = new RevisionStore( + $services->getDBLoadBalancer(), + $blobStore, + $services->getMainWANObjectCache() + ); + + $config = $services->getMainConfig(); + $store->setContentHandlerUseDB( $config->get( 'ContentHandlerUseDB' ) ); + + return $store; + }, + + 'BlobStore' => function ( MediaWikiServices $services ) { + return $services->getService( '_SqlBlobStore' ); + }, + + '_SqlBlobStore' => function ( MediaWikiServices $services ) { + global $wgContLang; // TODO: manage $wgContLang as a service + + $store = new SqlBlobStore( + $services->getDBLoadBalancer(), + $services->getMainWANObjectCache() + ); + + $config = $services->getMainConfig(); + $store->setCompressBlobs( $config->get( 'CompressRevisions' ) ); + $store->setCacheExpiry( $config->get( 'RevisionCacheExpiry' ) ); + $store->setUseExternalStore( $config->get( 'DefaultExternalStore' ) !== false ); + + if ( $config->get( 'LegacyEncoding' ) ) { + $store->setLegacyEncoding( $config->get( 'LegacyEncoding' ), $wgContLang ); + } + + return $store; + }, + /////////////////////////////////////////////////////////////////////////// // NOTE: When adding a service here, don't forget to add a getter function // in the MediaWikiServices class. The convenience getter should just call diff --git a/includes/Storage/BlobAccessException.php b/includes/Storage/BlobAccessException.php new file mode 100644 index 0000000000..ffc5ecabf4 --- /dev/null +++ b/includes/Storage/BlobAccessException.php @@ -0,0 +1,34 @@ +getPageAsLinkTarget() ); + $rev = new MutableRevisionRecord( $title, $parent->getWikiId() ); + + $rev->setComment( $comment ); + $rev->setUser( $user ); + $rev->setTimestamp( $timestamp ); + + foreach ( $parent->getSlotRoles() as $role ) { + $slot = $parent->getSlot( $role, self::RAW ); + $rev->inheritSlot( $slot ); + } + + $rev->setPageId( $parent->getPageId() ); + $rev->setParentId( $parent->getId() ); + + return $rev; + } + + /** + * @note Avoid calling this constructor directly. Use the appropriate methods + * in RevisionStore instead. + * + * @param Title $title The title of the page this Revision is associated with. + * @param bool|string $wikiId the wiki ID of the site this Revision belongs to, + * or false for the local site. + * + * @throws MWException + */ + function __construct( Title $title, $wikiId = false ) { + $slots = new MutableRevisionSlots(); + + parent::__construct( $title, $slots, $wikiId ); + + $this->mSlots = $slots; // redundant, but nice for static analysis + } + + /** + * @param int $parentId + */ + public function setParentId( $parentId ) { + Assert::parameterType( 'integer', $parentId, '$parentId' ); + + $this->mParentId = $parentId; + } + + /** + * Sets the given slot. If a slot with the same role is already present in the revision, + * it is replaced. + * + * @note This can only be used with a fresh "unattached" SlotRecord. Calling code that has a + * SlotRecord from another revision should use inheritSlot(). Calling code that has access to + * a Content object can use setContent(). + * + * @note This may cause the slot meta-data for the revision to be lazy-loaded. + * + * @note Calling this method will cause the revision size and hash to be re-calculated upon + * the next call to getSize() and getSha1(), respectively. + * + * @param SlotRecord $slot + */ + public function setSlot( SlotRecord $slot ) { + if ( $slot->hasRevision() && $slot->getRevision() !== $this->getId() ) { + throw new InvalidArgumentException( + 'The given slot must be an unsaved, unattached one. ' + . 'This slot is already attached to revision ' . $slot->getRevision() . '. ' + . 'Use inheritSlot() instead to preserve a slot from a previous revision.' + ); + } + + $this->mSlots->setSlot( $slot ); + $this->resetAggregateValues(); + } + + /** + * "Inherits" the given slot's content. + * + * If a slot with the same role is already present in the revision, it is replaced. + * + * @note This may cause the slot meta-data for the revision to be lazy-loaded. + * + * @param SlotRecord $parentSlot + */ + public function inheritSlot( SlotRecord $parentSlot ) { + $slot = SlotRecord::newInherited( $parentSlot ); + $this->setSlot( $slot ); + } + + /** + * Sets the content for the slot with the given role. + * + * If a slot with the same role is already present in the revision, it is replaced. + * Calling code that has access to a SlotRecord can use inheritSlot() instead. + * + * @note This may cause the slot meta-data for the revision to be lazy-loaded. + * + * @note Calling this method will cause the revision size and hash to be re-calculated upon + * the next call to getSize() and getSha1(), respectively. + * + * @param string $role + * @param Content $content + */ + public function setContent( $role, Content $content ) { + $this->mSlots->setContent( $role, $content ); + $this->resetAggregateValues(); + } + + /** + * Removes the slot with the given role from this revision. + * This effectively ends the "stream" with that role on the revision's page. + * Future revisions will no longer inherit this slot, unless it is added back explicitly. + * + * @note This may cause the slot meta-data for the revision to be lazy-loaded. + * + * @note Calling this method will cause the revision size and hash to be re-calculated upon + * the next call to getSize() and getSha1(), respectively. + * + * @param string $role + */ + public function removeSlot( $role ) { + $this->mSlots->removeSlot( $role ); + $this->resetAggregateValues(); + } + + /** + * @param CommentStoreComment $comment + */ + public function setComment( CommentStoreComment $comment ) { + $this->mComment = $comment; + } + + /** + * Set revision hash, for optimization. Prevents getSha1() from re-calculating the hash. + * + * @note This should only be used if the calling code is sure that the given hash is correct + * for the revision's content, and there is no chance of the content being manipulated + * later. When in doubt, this method should not be called. + * + * @param string $sha1 SHA1 hash as a base36 string. + */ + public function setSha1( $sha1 ) { + Assert::parameterType( 'string', $sha1, '$sha1' ); + + $this->mSha1 = $sha1; + } + + /** + * Set nominal revision size, for optimization. Prevents getSize() from re-calculating the size. + * + * @note This should only be used if the calling code is sure that the given size is correct + * for the revision's content, and there is no chance of the content being manipulated + * later. When in doubt, this method should not be called. + * + * @param int $size nominal size in bogo-bytes + */ + public function setSize( $size ) { + Assert::parameterType( 'integer', $size, '$size' ); + + $this->mSize = $size; + } + + /** + * @param int $visibility + */ + public function setVisibility( $visibility ) { + Assert::parameterType( 'integer', $visibility, '$visibility' ); + + $this->mDeleted = $visibility; + } + + /** + * @param string $timestamp A timestamp understood by wfTimestamp + */ + public function setTimestamp( $timestamp ) { + Assert::parameterType( 'string', $timestamp, '$timestamp' ); + + $this->mTimestamp = wfTimestamp( TS_MW, $timestamp ); + } + + /** + * @param bool $minorEdit + */ + public function setMinorEdit( $minorEdit ) { + Assert::parameterType( 'boolean', $minorEdit, '$minorEdit' ); + + $this->mMinorEdit = $minorEdit; + } + + /** + * Set the revision ID. + * + * MCR migration note: this replaces Revision::setId() + * + * @warning Use this with care, especially when preparing a revision for insertion + * into the database! The revision ID should only be fixed in special cases + * like preserving the original ID when restoring a revision. + * + * @param int $id + */ + public function setId( $id ) { + Assert::parameterType( 'integer', $id, '$id' ); + + $this->mId = $id; + } + + /** + * Sets the user identity associated with the revision + * + * @param UserIdentity $user + */ + public function setUser( UserIdentity $user ) { + $this->mUser = $user; + } + + /** + * @param int $pageId + */ + public function setPageId( $pageId ) { + Assert::parameterType( 'integer', $pageId, '$pageId' ); + + if ( $this->mTitle->exists() && $pageId !== $this->mTitle->getArticleID() ) { + throw new InvalidArgumentException( + 'The given Title does not belong to page ID ' . $this->mPageId + ); + } + + $this->mPageId = $pageId; + } + + /** + * Returns the nominal size of this revision. + * + * MCR migration note: this replaces Revision::getSize + * + * @return int The nominal size, may be computed on the fly if not yet known. + */ + public function getSize() { + // If not known, re-calculate and remember. Will be reset when slots change. + if ( $this->mSize === null ) { + $this->mSize = $this->mSlots->computeSize(); + } + + return $this->mSize; + } + + /** + * Returns the base36 sha1 of this revision. + * + * MCR migration note: this replaces Revision::getSha1 + * + * @return string The revision hash, may be computed on the fly if not yet known. + */ + public function getSha1() { + // If not known, re-calculate and remember. Will be reset when slots change. + if ( $this->mSha1 === null ) { + $this->mSha1 = $this->mSlots->computeSha1(); + } + + return $this->mSha1; + } + + /** + * Invalidate cached aggregate values such as hash and size. + */ + private function resetAggregateValues() { + $this->mSize = null; + $this->mSha1 = null; + } + +} diff --git a/includes/Storage/MutableRevisionSlots.php b/includes/Storage/MutableRevisionSlots.php new file mode 100644 index 0000000000..2e675c8937 --- /dev/null +++ b/includes/Storage/MutableRevisionSlots.php @@ -0,0 +1,137 @@ +getRole(); + $inherited[$role] = SlotRecord::newInherited( $slot ); + } + + return new MutableRevisionSlots( $inherited ); + } + + /** + * @param SlotRecord[] $slots An array of SlotRecords. + */ + public function __construct( array $slots = [] ) { + parent::__construct( $slots ); + } + + /** + * Sets the given slot. + * If a slot with the same role is already present, it is replaced. + * + * @note This may cause the slot meta-data for the revision to be lazy-loaded. + * + * @param SlotRecord $slot + */ + public function setSlot( SlotRecord $slot ) { + if ( !is_array( $this->slots ) ) { + $this->getSlots(); // initialize $this->slots + } + + $role = $slot->getRole(); + $this->slots[$role] = $slot; + } + + /** + * Sets the content for the slot with the given role. + * If a slot with the same role is already present, it is replaced. + * + * @note This may cause the slot meta-data for the revision to be lazy-loaded. + * + * @param string $role + * @param Content $content + */ + public function setContent( $role, Content $content ) { + $slot = SlotRecord::newUnsaved( $role, $content ); + $this->setSlot( $slot ); + } + + /** + * Remove the slot for the given role, discontinue the corresponding stream. + * + * @note This may cause the slot meta-data for the revision to be lazy-loaded. + * + * @param string $role + */ + public function removeSlot( $role ) { + if ( !is_array( $this->slots ) ) { + $this->getSlots(); // initialize $this->slots + } + + unset( $this->slots[$role] ); + } + + /** + * Return all slots that are not inherited. + * + * @note This may cause the slot meta-data for the revision to be lazy-loaded. + * + * @return SlotRecord[] + */ + public function getTouchedSlots() { + return array_filter( + $this->getSlots(), + function ( SlotRecord $slot ) { + return !$slot->isInherited(); + } + ); + } + + /** + * Return all slots that are inherited. + * + * @note This may cause the slot meta-data for the revision to be lazy-loaded. + * + * @return SlotRecord[] + */ + public function getInheritedSlots() { + return array_filter( + $this->getSlots(), + function ( SlotRecord $slot ) { + return $slot->isInherited(); + } + ); + } + +} diff --git a/includes/Storage/RevisionAccessException.php b/includes/Storage/RevisionAccessException.php new file mode 100644 index 0000000000..ee6efc0a0c --- /dev/null +++ b/includes/Storage/RevisionAccessException.php @@ -0,0 +1,34 @@ +mArchiveId = intval( $row->ar_id ); + + // NOTE: ar_page_id may be different from $this->mTitle->getArticleID() in some cases, + // notably when a partially restored page has been moved, and a new page has been created + // with the same title. Archive rows for that title will then have the wrong page id. + $this->mPageId = isset( $row->ar_page_id ) ? intval( $row->ar_page_id ) : $title->getArticleID(); + + // NOTE: ar_parent_id = 0 indicates that there is no parent revision, while null + // indicates that the parent revision is unknown. As per MW 1.31, the database schema + // allows ar_parent_id to be NULL. + $this->mParentId = isset( $row->ar_parent_id ) ? intval( $row->ar_parent_id ) : null; + $this->mId = isset( $row->ar_rev_id ) ? intval( $row->ar_rev_id ) : null; + $this->mComment = $comment; + $this->mUser = $user; + $this->mTimestamp = wfTimestamp( TS_MW, $row->ar_timestamp ); + $this->mMinorEdit = boolval( $row->ar_minor_edit ); + $this->mDeleted = intval( $row->ar_deleted ); + $this->mSize = intval( $row->ar_len ); + $this->mSha1 = isset( $row->ar_sha1 ) ? $row->ar_sha1 : null; + } + + /** + * Get archive row ID + * + * @return int + */ + public function getArchiveId() { + return $this->mId; + } + + /** + * @return int|null The revision id, or null if the original revision ID + * was not recorded in the archive table. + */ + public function getId() { + // overwritten just to refine the contract specification. + return parent::getId(); + } + + /** + * @return int The nominal revision size, never null. May be computed on the fly. + */ + public function getSize() { + // If length is null, calculate and remember it (potentially SLOW!). + // This is for compatibility with old database rows that don't have the field set. + if ( $this->mSize === null ) { + $this->mSize = $this->mSlots->computeSize(); + } + + return $this->mSize; + } + + /** + * @return string The revision hash, never null. May be computed on the fly. + */ + public function getSha1() { + // If hash is null, calculate it and remember (potentially SLOW!) + // This is for compatibility with old database rows that don't have the field set. + if ( $this->mSha1 === null ) { + $this->mSha1 = $this->mSlots->computeSha1(); + } + + return $this->mSha1; + } + + /** + * @param int $audience + * @param User|null $user + * + * @return UserIdentity The identity of the revision author, null if access is forbidden. + */ + public function getUser( $audience = self::FOR_PUBLIC, User $user = null ) { + // overwritten just to add a guarantee to the contract + return parent::getUser( $audience, $user ); + } + + /** + * @param int $audience + * @param User|null $user + * + * @return CommentStoreComment The revision comment, null if access is forbidden. + */ + public function getComment( $audience = self::FOR_PUBLIC, User $user = null ) { + // overwritten just to add a guarantee to the contract + return parent::getComment( $audience, $user ); + } + + /** + * @return string never null + */ + public function getTimestamp() { + // overwritten just to add a guarantee to the contract + return parent::getTimestamp(); + } + +} diff --git a/includes/Storage/RevisionFactory.php b/includes/Storage/RevisionFactory.php new file mode 100644 index 0000000000..86e8c06fbb --- /dev/null +++ b/includes/Storage/RevisionFactory.php @@ -0,0 +1,94 @@ +ar_user, etc. + * + * @return RevisionRecord + */ + public function newRevisionFromArchiveRow( + $row, + $queryFlags = 0, + Title $title = null, + array $overrides = [] + ); + +} diff --git a/includes/Storage/RevisionLookup.php b/includes/Storage/RevisionLookup.php new file mode 100644 index 0000000000..5cd157ba07 --- /dev/null +++ b/includes/Storage/RevisionLookup.php @@ -0,0 +1,118 @@ +mTitle = $title; + $this->mSlots = $slots; + $this->mWiki = $wikiId; + + // XXX: this is a sensible default, but we may not have a Title object here in the future. + $this->mPageId = $title->getArticleID(); + } + + /** + * Implemented to defy serialization. + * + * @throws LogicException always + */ + public function __sleep() { + throw new LogicException( __CLASS__ . ' is not serializable.' ); + } + + /** + * @param RevisionRecord $rec + * + * @return bool True if this RevisionRecord is known to have same content as $rec. + * False if the content is different (or not known to be the same). + */ + public function hasSameContent( RevisionRecord $rec ) { + if ( $rec === $this ) { + return true; + } + + if ( $this->getId() !== null && $this->getId() === $rec->getId() ) { + return true; + } + + // check size before hash, since size is quicker to compute + if ( $this->getSize() !== $rec->getSize() ) { + return false; + } + + // instead of checking the hash, we could also check the content addresses of all slots. + + if ( $this->getSha1() === $rec->getSha1() ) { + return true; + } + + return false; + } + + /** + * Returns the Content of the given slot of this revision. + * Call getSlotNames() to get a list of available slots. + * + * Note that for mutable Content objects, each call to this method will return a + * fresh clone. + * + * MCR migration note: this replaces Revision::getContent + * + * @param string $role The role name of the desired slot + * @param int $audience + * @param User|null $user + * + * @throws RevisionAccessException if the slot does not exist or slot data + * could not be lazy-loaded. + * @return Content|null The content of the given slot, or null if access is forbidden. + */ + public function getContent( $role, $audience = self::FOR_PUBLIC, User $user = null ) { + // XXX: throwing an exception would be nicer, but would a further + // departure from the signature of Revision::getContent(), and thus + // more complex and error prone refactoring. + if ( !$this->audienceCan( self::DELETED_TEXT, $audience, $user ) ) { + return null; + } + + $content = $this->getSlot( $role, $audience, $user )->getContent(); + return $content->copy(); + } + + /** + * Returns meta-data for the given slot. + * + * @param string $role The role name of the desired slot + * @param int $audience + * @param User|null $user + * + * @throws RevisionAccessException if the slot does not exist or slot data + * could not be lazy-loaded. + * @return SlotRecord The slot meta-data. If access to the slot content is forbidden, + * calling getContent() on the SlotRecord will throw an exception. + */ + public function getSlot( $role, $audience = self::FOR_PUBLIC, User $user = null ) { + $slot = $this->mSlots->getSlot( $role ); + + if ( !$this->audienceCan( self::DELETED_TEXT, $audience, $user ) ) { + return SlotRecord::newWithSuppressedContent( $slot ); + } + + return $slot; + } + + /** + * Returns the slot names (roles) of all slots present in this revision. + * getContent() will succeed only for the names returned by this method. + * + * @return string[] + */ + public function getSlotRoles() { + return $this->mSlots->getSlotRoles(); + } + + /** + * Get revision ID. Depending on the concrete subclass, this may return null if + * the revision ID is not known (e.g. because the revision does not yet exist + * in the database). + * + * MCR migration note: this replaces Revision::getId + * + * @return int|null + */ + public function getId() { + return $this->mId; + } + + /** + * Get parent revision ID (the original previous page revision). + * If there is no parent revision, this returns 0. + * If the parent revision is undefined or unknown, this returns null. + * + * @note As of MW 1.31, the database schema allows the parent ID to be + * NULL to indicate that it is unknown. + * + * MCR migration note: this replaces Revision::getParentId + * + * @return int|null + */ + public function getParentId() { + return $this->mParentId; + } + + /** + * Returns the nominal size of this revision, in bogo-bytes. + * May be calculated on the fly if not known, which may in the worst + * case may involve loading all content. + * + * MCR migration note: this replaces Revision::getSize + * + * @return int + */ + abstract public function getSize(); + + /** + * Returns the base36 sha1 of this revision. This hash is derived from the + * hashes of all slots associated with the revision. + * May be calculated on the fly if not known, which may in the worst + * case may involve loading all content. + * + * MCR migration note: this replaces Revision::getSha1 + * + * @return string + */ + abstract public function getSha1(); + + /** + * Get the page ID. If the page does not yet exist, the page ID is 0. + * + * MCR migration note: this replaces Revision::getPage + * + * @return int + */ + public function getPageId() { + return $this->mPageId; + } + + /** + * Get the ID of the wiki this revision belongs to. + * + * @return string|false The wiki's logical name, of false to indicate the local wiki. + */ + public function getWikiId() { + return $this->mWiki; + } + + /** + * Returns the title of the page this revision is associated with as a LinkTarget object. + * + * MCR migration note: this replaces Revision::getTitle + * + * @return LinkTarget + */ + public function getPageAsLinkTarget() { + return $this->mTitle; + } + + /** + * Fetch revision's author's user identity, if it's available to the specified audience. + * If the specified audience does not have access to it, null will be + * returned. Depending on the concrete subclass, null may also be returned if the user is + * not yet specified. + * + * MCR migration note: this replaces Revision::getUser + * + * @param int $audience One of: + * RevisionRecord::FOR_PUBLIC to be displayed to all users + * RevisionRecord::FOR_THIS_USER to be displayed to the given user + * RevisionRecord::RAW get the ID regardless of permissions + * @param User|null $user User object to check for, only if FOR_THIS_USER is passed + * to the $audience parameter + * @return UserIdentity|null + */ + public function getUser( $audience = self::FOR_PUBLIC, User $user = null ) { + if ( !$this->audienceCan( self::DELETED_USER, $audience, $user ) ) { + return null; + } else { + return $this->mUser; + } + } + + /** + * Fetch revision comment, if it's available to the specified audience. + * If the specified audience does not have access to the comment, + * this will return null. Depending on the concrete subclass, null may also be returned + * if the comment is not yet specified. + * + * MCR migration note: this replaces Revision::getComment + * + * @param int $audience One of: + * RevisionRecord::FOR_PUBLIC to be displayed to all users + * RevisionRecord::FOR_THIS_USER to be displayed to the given user + * RevisionRecord::RAW get the text regardless of permissions + * @param User|null $user User object to check for, only if FOR_THIS_USER is passed + * to the $audience parameter + * + * @return CommentStoreComment|null + */ + public function getComment( $audience = self::FOR_PUBLIC, User $user = null ) { + if ( !$this->audienceCan( self::DELETED_COMMENT, $audience, $user ) ) { + return null; + } else { + return $this->mComment; + } + } + + /** + * MCR migration note: this replaces Revision::isMinor + * + * @return bool + */ + public function isMinor() { + return (bool)$this->mMinorEdit; + } + + /** + * MCR migration note: this replaces Revision::isDeleted + * + * @param int $field One of DELETED_* bitfield constants + * + * @return bool + */ + public function isDeleted( $field ) { + return ( $this->getVisibility() & $field ) == $field; + } + + /** + * Get the deletion bitfield of the revision + * + * MCR migration note: this replaces Revision::getVisibility + * + * @return int + */ + public function getVisibility() { + return (int)$this->mDeleted; + } + + /** + * MCR migration note: this replaces Revision::getTimestamp. + * + * May return null if the timestamp was not specified. + * + * @return string|null + */ + public function getTimestamp() { + return $this->mTimestamp; + } + + /** + * Check that the given audience has access to the given field. + * + * MCR migration note: this corresponds to Revision::userCan + * + * @param int $field One of self::DELETED_TEXT, + * self::DELETED_COMMENT, + * self::DELETED_USER + * @param int $audience One of: + * RevisionRecord::FOR_PUBLIC to be displayed to all users + * RevisionRecord::FOR_THIS_USER to be displayed to the given user + * RevisionRecord::RAW get the text regardless of permissions + * @param User|null $user User object to check. Required if $audience is FOR_THIS_USER, + * ignored otherwise. + * + * @return bool + */ + protected function audienceCan( $field, $audience, User $user = null ) { + if ( $audience == self::FOR_PUBLIC && $this->isDeleted( $field ) ) { + return false; + } elseif ( $audience == self::FOR_THIS_USER ) { + if ( !$user ) { + throw new InvalidArgumentException( + 'A User object must be given when checking FOR_THIS_USER audience.' + ); + } + + if ( !$this->userCan( $field, $user ) ) { + return false; + } + } + + return true; + } + + /** + * Determine if the current user is allowed to view a particular + * field of this revision, if it's marked as deleted. + * + * MCR migration note: this corresponds to Revision::userCan + * + * @param int $field One of self::DELETED_TEXT, + * self::DELETED_COMMENT, + * self::DELETED_USER + * @param User $user User object to check + * @return bool + */ + protected function userCan( $field, User $user ) { + // TODO: use callback for permission checks, so we don't need to know a Title object! + return self::userCanBitfield( $this->getVisibility(), $field, $user, $this->mTitle ); + } + + /** + * Determine if the current user is allowed to view a particular + * field of this revision, if it's marked as deleted. This is used + * by various classes to avoid duplication. + * + * MCR migration note: this replaces Revision::userCanBitfield + * + * @param int $bitfield Current field + * @param int $field One of self::DELETED_TEXT = File::DELETED_FILE, + * self::DELETED_COMMENT = File::DELETED_COMMENT, + * self::DELETED_USER = File::DELETED_USER + * @param User $user User object to check + * @param Title|null $title A Title object to check for per-page restrictions on, + * instead of just plain userrights + * @return bool + */ + public static function userCanBitfield( $bitfield, $field, User $user, Title $title = null ) { + if ( $bitfield & $field ) { // aspect is deleted + if ( $bitfield & self::DELETED_RESTRICTED ) { + $permissions = [ 'suppressrevision', 'viewsuppressed' ]; + } elseif ( $field & self::DELETED_TEXT ) { + $permissions = [ 'deletedtext' ]; + } else { + $permissions = [ 'deletedhistory' ]; + } + $permissionlist = implode( ', ', $permissions ); + if ( $title === null ) { + wfDebug( "Checking for $permissionlist due to $field match on $bitfield\n" ); + return call_user_func_array( [ $user, 'isAllowedAny' ], $permissions ); + } else { + $text = $title->getPrefixedText(); + wfDebug( "Checking for $permissionlist on $text due to $field match on $bitfield\n" ); + foreach ( $permissions as $perm ) { + if ( $title->userCan( $perm, $user ) ) { + return true; + } + } + return false; + } + } else { + return true; + } + } + +} diff --git a/includes/Storage/RevisionSlots.php b/includes/Storage/RevisionSlots.php new file mode 100644 index 0000000000..8d3d7e3d70 --- /dev/null +++ b/includes/Storage/RevisionSlots.php @@ -0,0 +1,189 @@ +slots = $slots; + } else { + $this->setSlotsInternal( $slots ); + } + } + + /** + * @param SlotRecord[] $slots + */ + private function setSlotsInternal( array $slots ) { + $this->slots = []; + + // re-key the slot array + foreach ( $slots as $slot ) { + $role = $slot->getRole(); + $this->slots[$role] = $slot; + } + } + + /** + * Implemented to defy serialization. + * + * @throws LogicException always + */ + public function __sleep() { + throw new LogicException( __CLASS__ . ' is not serializable.' ); + } + + /** + * Returns the Content of the given slot. + * Call getSlotNames() to get a list of available slots. + * + * Note that for mutable Content objects, each call to this method will return a + * fresh clone. + * + * @param string $role The role name of the desired slot + * + * @throws RevisionAccessException if the slot does not exist or slot data + * could not be lazy-loaded. + * @return Content + */ + public function getContent( $role ) { + // Return a copy to be safe. Immutable content objects return $this from copy(). + return $this->getSlot( $role )->getContent()->copy(); + } + + /** + * Returns the SlotRecord of the given slot. + * Call getSlotNames() to get a list of available slots. + * + * @param string $role The role name of the desired slot + * + * @throws RevisionAccessException if the slot does not exist or slot data + * could not be lazy-loaded. + * @return SlotRecord + */ + public function getSlot( $role ) { + $slots = $this->getSlots(); + + if ( isset( $slots[$role] ) ) { + return $slots[$role]; + } else { + throw new RevisionAccessException( 'No such slot: ' . $role ); + } + } + + /** + * Returns the slot names (roles) of all slots present in this revision. + * getContent() will succeed only for the names returned by this method. + * + * @return string[] + */ + public function getSlotRoles() { + $slots = $this->getSlots(); + return array_keys( $slots ); + } + + /** + * Computes the total nominal size of the revision's slots, in bogo-bytes. + * + * @warn This is potentially expensive! It may cause all slot's content to be loaded + * and deserialized. + * + * @return int + */ + public function computeSize() { + return array_reduce( $this->getSlots(), function ( $accu, SlotRecord $slot ) { + return $accu + $slot->getSize(); + }, 0 ); + } + + /** + * Returns an associative array that maps role names to SlotRecords. Each SlotRecord + * represents the content meta-data of a slot, together they define the content of + * a revision. + * + * @note This may cause the content meta-data for the revision to be lazy-loaded. + * + * @return SlotRecord[] revision slot/content rows, keyed by slot role name. + */ + public function getSlots() { + if ( is_callable( $this->slots ) ) { + $slots = call_user_func( $this->slots ); + + Assert::postcondition( + is_array( $slots ), + 'Slots info callback should return an array of objects' + ); + + $this->setSlotsInternal( $slots ); + } + + return $this->slots; + } + + /** + * Computes the combined hash of the revisions's slots. + * + * @note For backwards compatibility, the combined hash of a single slot + * is that slot's hash. For consistency, the combined hash of an empty set of slots + * is the hash of the empty string. + * + * @warn This is potentially expensive! It may cause all slot's content to be loaded + * and deserialized, then re-serialized and hashed. + * + * @return string + */ + public function computeSha1() { + $slots = $this->getSlots(); + ksort( $slots ); + + if ( empty( $slots ) ) { + return SlotRecord::base36Sha1( '' ); + } + + return array_reduce( $slots, function ( $accu, SlotRecord $slot ) { + return $accu === null + ? $slot->getSha1() + : SlotRecord::base36Sha1( $accu . $slot->getSha1() ); + }, null ); + } + +} diff --git a/includes/Storage/RevisionStore.php b/includes/Storage/RevisionStore.php new file mode 100644 index 0000000000..b8debb8b6a --- /dev/null +++ b/includes/Storage/RevisionStore.php @@ -0,0 +1,1914 @@ +loadBalancer = $loadBalancer; + $this->blobStore = $blobStore; + $this->cache = $cache; + $this->wikiId = $wikiId; + } + + /** + * @return bool + */ + public function getContentHandlerUseDB() { + return $this->contentHandlerUseDB; + } + + /** + * @param bool $contentHandlerUseDB + */ + public function setContentHandlerUseDB( $contentHandlerUseDB ) { + $this->contentHandlerUseDB = $contentHandlerUseDB; + } + + /** + * @return LoadBalancer + */ + private function getDBLoadBalancer() { + return $this->loadBalancer; + } + + /** + * @param int $mode DB_MASTER or DB_REPLICA + * + * @return IDatabase + */ + private function getDBConnection( $mode ) { + $lb = $this->getDBLoadBalancer(); + return $lb->getConnection( $mode, [], $this->wikiId ); + } + + /** + * @param IDatabase $connection + */ + private function releaseDBConnection( IDatabase $connection ) { + $lb = $this->getDBLoadBalancer(); + $lb->reuseConnection( $connection ); + } + + /** + * @param int $mode DB_MASTER or DB_REPLICA + * + * @return DBConnRef + */ + private function getDBConnectionRef( $mode ) { + $lb = $this->getDBLoadBalancer(); + return $lb->getConnectionRef( $mode, [], $this->wikiId ); + } + + /** + * Determines the page Title based on the available information. + * + * MCR migration note: this corresponds to Revision::getTitle + * + * @param int|null $pageId + * @param int|null $revId + * @param int $queryFlags + * + * @return Title + * @throws RevisionAccessException + */ + private function getTitle( $pageId, $revId, $queryFlags = 0 ) { + if ( !$pageId && !$revId ) { + throw new InvalidArgumentException( '$pageId and $revId cannot both be 0 or null' ); + } + + $title = null; + + // Loading by ID is best, but Title::newFromID does not support that for foreign IDs. + if ( $pageId !== null && $pageId > 0 && $this->wikiId === false ) { + // TODO: better foreign title handling (introduce TitleFactory) + $title = Title::newFromID( $pageId, $queryFlags ); + } + + // rev_id is defined as NOT NULL, but this revision may not yet have been inserted. + if ( !$title && $revId !== null && $revId > 0 ) { + list( $dbMode, $dbOptions, , ) = DBAccessObjectUtils::getDBOptions( $queryFlags ); + + $dbr = $this->getDbConnectionRef( $dbMode ); + // @todo: Title::getSelectFields(), or Title::getQueryInfo(), or something like that + $row = $dbr->selectRow( + [ 'revision', 'page' ], + [ + 'page_namespace', + 'page_title', + 'page_id', + 'page_latest', + 'page_is_redirect', + 'page_len', + ], + [ 'rev_id' => $revId ], + __METHOD__, + $dbOptions, + [ 'page' => [ 'JOIN', 'page_id=rev_page' ] ] + ); + if ( $row ) { + // TODO: better foreign title handling (introduce TitleFactory) + $title = Title::newFromRow( $row ); + } + } + + if ( !$title ) { + throw new RevisionAccessException( + "Could not determine title for page ID $pageId and revision ID $revId" + ); + } + + return $title; + } + + /** + * @param mixed $value + * @param string $name + * + * @throw IncompleteRevisionException if $value is null + * @return mixed $value, if $value is not null + */ + private function failOnNull( $value, $name ) { + if ( $value === null ) { + throw new IncompleteRevisionException( + "$name must not be " . var_export( $value, true ) . "!" + ); + } + + return $value; + } + + /** + * @param mixed $value + * @param string $name + * + * @throw IncompleteRevisionException if $value is empty + * @return mixed $value, if $value is not null + */ + private function failOnEmpty( $value, $name ) { + if ( $value === null || $value === 0 || $value === '' ) { + throw new IncompleteRevisionException( + "$name must not be " . var_export( $value, true ) . "!" + ); + } + + return $value; + } + + /** + * Insert a new revision into the database, returning the new revision ID + * number on success and dies horribly on failure. + * + * MCR migration note: this replaces Revision::insertOn + * + * @param RevisionRecord $rev + * @param IDatabase $dbw (master connection) + * + * @throws InvalidArgumentException + * @return RevisionRecord the new revision record. + */ + public function insertRevisionOn( RevisionRecord $rev, IDatabase $dbw ) { + // TODO: pass in a DBTransactionContext instead of a database connection. + $this->checkDatabaseWikiId( $dbw ); + + if ( !$rev->getSlotRoles() ) { + throw new InvalidArgumentException( 'At least one slot needs to be defined!' ); + } + + if ( $rev->getSlotRoles() !== [ 'main' ] ) { + throw new InvalidArgumentException( 'Only the main slot is supported for now!' ); + } + + // TODO: we shouldn't need an actual Title here. + $title = Title::newFromLinkTarget( $rev->getPageAsLinkTarget() ); + $pageId = $this->failOnEmpty( $rev->getPageId(), 'rev_page field' ); // check this early + + $parentId = $rev->getParentId() === null + ? $this->getPreviousRevisionId( $dbw, $rev ) + : $rev->getParentId(); + + // Record the text (or external storage URL) to the blob store + $slot = $rev->getSlot( 'main', RevisionRecord::RAW ); + + $size = $this->failOnNull( $rev->getSize(), 'size field' ); + $sha1 = $this->failOnEmpty( $rev->getSha1(), 'sha1 field' ); + + if ( !$slot->hasAddress() ) { + $content = $slot->getContent(); + $format = $content->getDefaultFormat(); + $model = $content->getModel(); + + $this->checkContentModel( $content, $title ); + + $data = $content->serialize( $format ); + + // Hints allow the blob store to optimize by "leaking" application level information to it. + // TODO: with the new MCR storage schema, we rev_id have this before storing the blobs. + // When we have it, add rev_id as a hint. Can be used with rev_parent_id for + // differential storage or compression of subsequent revisions. + $blobHints = [ + BlobStore::DESIGNATION_HINT => 'page-content', // BlobStore may be used for other things too. + BlobStore::PAGE_HINT => $pageId, + BlobStore::ROLE_HINT => $slot->getRole(), + BlobStore::PARENT_HINT => $parentId, + BlobStore::SHA1_HINT => $slot->getSha1(), + BlobStore::MODEL_HINT => $model, + BlobStore::FORMAT_HINT => $format, + ]; + + $blobAddress = $this->blobStore->storeBlob( $data, $blobHints ); + } else { + $blobAddress = $slot->getAddress(); + $model = $slot->getModel(); + $format = $slot->getFormat(); + } + + $textId = $this->blobStore->getTextIdFromAddress( $blobAddress ); + + if ( !$textId ) { + throw new LogicException( + 'Blob address not supported in 1.29 database schema: ' . $blobAddress + ); + } + + // getTextIdFromAddress() is free to insert something into the text table, so $textId + // may be a new value, not anything already contained in $blobAddress. + $blobAddress = 'tt:' . $textId; + + $comment = $this->failOnNull( $rev->getComment( RevisionRecord::RAW ), 'comment' ); + $user = $this->failOnNull( $rev->getUser( RevisionRecord::RAW ), 'user' ); + $timestamp = $this->failOnEmpty( $rev->getTimestamp(), 'timestamp field' ); + + # Record the edit in revisions + $row = [ + 'rev_page' => $pageId, + 'rev_parent_id' => $parentId, + 'rev_text_id' => $textId, + 'rev_minor_edit' => $rev->isMinor() ? 1 : 0, + 'rev_user' => $this->failOnNull( $user->getId(), 'user field' ), + 'rev_user_text' => $this->failOnEmpty( $user->getName(), 'user_text field' ), + 'rev_timestamp' => $dbw->timestamp( $timestamp ), + 'rev_deleted' => $rev->getVisibility(), + 'rev_len' => $size, + 'rev_sha1' => $sha1, + ]; + + if ( $rev->getId() !== null ) { + // Needed to restore revisions with their original ID + $row['rev_id'] = $rev->getId(); + } + + list( $commentFields, $commentCallback ) = + CommentStore::newKey( 'rev_comment' )->insertWithTempTable( $dbw, $comment ); + $row += $commentFields; + + if ( $this->contentHandlerUseDB ) { + // MCR migration note: rev_content_model and rev_content_format will go away + + $defaultModel = ContentHandler::getDefaultModelFor( $title ); + $defaultFormat = ContentHandler::getForModelID( $defaultModel )->getDefaultFormat(); + + $row['rev_content_model'] = ( $model === $defaultModel ) ? null : $model; + $row['rev_content_format'] = ( $format === $defaultFormat ) ? null : $format; + } + + $dbw->insert( 'revision', $row, __METHOD__ ); + + if ( !isset( $row['rev_id'] ) ) { + // only if auto-increment was used + $row['rev_id'] = intval( $dbw->insertId() ); + } + $commentCallback( $row['rev_id'] ); + + // Insert IP revision into ip_changes for use when querying for a range. + if ( $row['rev_user'] === 0 && IP::isValid( $row['rev_user_text'] ) ) { + $ipcRow = [ + 'ipc_rev_id' => $row['rev_id'], + 'ipc_rev_timestamp' => $row['rev_timestamp'], + 'ipc_hex' => IP::toHex( $row['rev_user_text'] ), + ]; + $dbw->insert( 'ip_changes', $ipcRow, __METHOD__ ); + } + + $newSlot = SlotRecord::newSaved( $row['rev_id'], $blobAddress, $slot ); + $slots = new RevisionSlots( [ 'main' => $newSlot ] ); + + $user = new UserIdentityValue( intval( $row['rev_user'] ), $row['rev_user_text'] ); + + $rev = new RevisionStoreRecord( + $title, + $user, + $comment, + (object)$row, + $slots, + $this->wikiId + ); + + $newSlot = $rev->getSlot( 'main', RevisionRecord::RAW ); + + // sanity checks + Assert::postcondition( $rev->getId() > 0, 'revision must have an ID' ); + Assert::postcondition( $rev->getPageId() > 0, 'revision must have a page ID' ); + Assert::postcondition( + $rev->getComment( RevisionRecord::RAW ) !== null, + 'revision must have a comment' + ); + Assert::postcondition( + $rev->getUser( RevisionRecord::RAW ) !== null, + 'revision must have a user' + ); + + Assert::postcondition( $newSlot !== null, 'revision must have a main slot' ); + Assert::postcondition( + $newSlot->getAddress() !== null, + 'main slot must have an addess' + ); + + Hooks::run( 'RevisionRecordInserted', [ $rev ] ); + + return $rev; + } + + /** + * MCR migration note: this corresponds to Revision::checkContentModel + * + * @param Content $content + * @param Title $title + * + * @throws MWException + * @throws MWUnknownContentModelException + */ + private function checkContentModel( Content $content, Title $title ) { + // Note: may return null for revisions that have not yet been inserted + + $model = $content->getModel(); + $format = $content->getDefaultFormat(); + $handler = $content->getContentHandler(); + + $name = "$title"; + + if ( !$handler->isSupportedFormat( $format ) ) { + throw new MWException( "Can't use format $format with content model $model on $name" ); + } + + if ( !$this->contentHandlerUseDB ) { + // if $wgContentHandlerUseDB is not set, + // all revisions must use the default content model and format. + + $defaultModel = ContentHandler::getDefaultModelFor( $title ); + $defaultHandler = ContentHandler::getForModelID( $defaultModel ); + $defaultFormat = $defaultHandler->getDefaultFormat(); + + if ( $model != $defaultModel ) { + throw new MWException( "Can't save non-default content model with " + . "\$wgContentHandlerUseDB disabled: model is $model, " + . "default for $name is $defaultModel" + ); + } + + if ( $format != $defaultFormat ) { + throw new MWException( "Can't use non-default content format with " + . "\$wgContentHandlerUseDB disabled: format is $format, " + . "default for $name is $defaultFormat" + ); + } + } + + if ( !$content->isValid() ) { + throw new MWException( + "New content for $name is not valid! Content model is $model" + ); + } + } + + /** + * Create a new null-revision for insertion into a page's + * history. This will not re-save the text, but simply refer + * to the text from the previous version. + * + * Such revisions can for instance identify page rename + * operations and other such meta-modifications. + * + * MCR migration note: this replaces Revision::newNullRevision + * + * @todo Introduce newFromParentRevision(). newNullRevision can then be based on that + * (or go away). + * + * @param IDatabase $dbw + * @param Title $title Title of the page to read from + * @param CommentStoreComment $comment RevisionRecord's summary + * @param bool $minor Whether the revision should be considered as minor + * @param User $user The user to attribute the revision to + * @return RevisionRecord|null RevisionRecord or null on error + */ + public function newNullRevision( + IDatabase $dbw, + Title $title, + CommentStoreComment $comment, + $minor, + User $user + ) { + $this->checkDatabaseWikiId( $dbw ); + + $fields = [ 'page_latest', 'page_namespace', 'page_title', + 'rev_id', 'rev_text_id', 'rev_len', 'rev_sha1' ]; + + if ( $this->contentHandlerUseDB ) { + $fields[] = 'rev_content_model'; + $fields[] = 'rev_content_format'; + } + + $current = $dbw->selectRow( + [ 'page', 'revision' ], + $fields, + [ + 'page_id' => $title->getArticleID(), + 'page_latest=rev_id', + ], + __METHOD__, + [ 'FOR UPDATE' ] // T51581 + ); + + if ( $current ) { + $fields = [ + 'page' => $title->getArticleID(), + 'user_text' => $user->getName(), + 'user' => $user->getId(), + 'comment' => $comment, + 'minor_edit' => $minor, + 'text_id' => $current->rev_text_id, + 'parent_id' => $current->page_latest, + 'len' => $current->rev_len, + 'sha1' => $current->rev_sha1 + ]; + + if ( $this->contentHandlerUseDB ) { + $fields['content_model'] = $current->rev_content_model; + $fields['content_format'] = $current->rev_content_format; + } + + $fields['title'] = Title::makeTitle( $current->page_namespace, $current->page_title ); + + $mainSlot = $this->emulateMainSlot_1_29( $fields, 0, $title ); + $revision = new MutableRevisionRecord( $title, $this->wikiId ); + $this->initializeMutableRevisionFromArray( $revision, $fields ); + $revision->setSlot( $mainSlot ); + } else { + $revision = null; + } + + return $revision; + } + + /** + * MCR migration note: this replaces Revision::isUnpatrolled + * + * @return int Rcid of the unpatrolled row, zero if there isn't one + */ + public function isUnpatrolled( RevisionRecord $rev ) { + $rc = $this->getRecentChange( $rev ); + if ( $rc && $rc->getAttribute( 'rc_patrolled' ) == 0 ) { + return $rc->getAttribute( 'rc_id' ); + } else { + return 0; + } + } + + /** + * Get the RC object belonging to the current revision, if there's one + * + * MCR migration note: this replaces Revision::getRecentChange + * + * @todo move this somewhere else? + * + * @param RevisionRecord $rev + * @param int $flags (optional) $flags include: + * IDBAccessObject::READ_LATEST: Select the data from the master + * + * @return null|RecentChange + */ + public function getRecentChange( RevisionRecord $rev, $flags = 0 ) { + $dbr = $this->getDBConnection( DB_REPLICA ); + + list( $dbType, ) = DBAccessObjectUtils::getDBOptions( $flags ); + + $userIdentity = $rev->getUser( RevisionRecord::RAW ); + + if ( !$userIdentity ) { + // If the revision has no user identity, chances are it never went + // into the database, and doesn't have an RC entry. + return null; + } + + // TODO: Select by rc_this_oldid alone - but as of Nov 2017, there is no index on that! + $rc = RecentChange::newFromConds( + [ + 'rc_user_text' => $userIdentity->getName(), + 'rc_timestamp' => $dbr->timestamp( $rev->getTimestamp() ), + 'rc_this_oldid' => $rev->getId() + ], + __METHOD__, + $dbType + ); + + $this->releaseDBConnection( $dbr ); + + // XXX: cache this locally? Glue it to the RevisionRecord? + return $rc; + } + + /** + * Maps fields of the archive row to corresponding revision rows. + * + * @param object $archiveRow + * + * @return object a revision row object, corresponding to $archiveRow. + */ + private static function mapArchiveFields( $archiveRow ) { + $fieldMap = [ + // keep with ar prefix: + 'ar_id' => 'ar_id', + + // not the same suffix: + 'ar_page_id' => 'rev_page', + 'ar_rev_id' => 'rev_id', + + // same suffix: + 'ar_text_id' => 'rev_text_id', + 'ar_timestamp' => 'rev_timestamp', + 'ar_user_text' => 'rev_user_text', + 'ar_user' => 'rev_user', + 'ar_minor_edit' => 'rev_minor_edit', + 'ar_deleted' => 'rev_deleted', + 'ar_len' => 'rev_len', + 'ar_parent_id' => 'rev_parent_id', + 'ar_sha1' => 'rev_sha1', + 'ar_comment' => 'rev_comment', + 'ar_comment_cid' => 'rev_comment_cid', + 'ar_comment_id' => 'rev_comment_id', + 'ar_comment_text' => 'rev_comment_text', + 'ar_comment_data' => 'rev_comment_data', + 'ar_comment_old' => 'rev_comment_old', + 'ar_content_format' => 'rev_content_format', + 'ar_content_model' => 'rev_content_model', + ]; + + if ( empty( $archiveRow->ar_text_id ) ) { + $fieldMap['ar_text'] = 'old_text'; + $fieldMap['ar_flags'] = 'old_flags'; + } + + $revRow = new stdClass(); + foreach ( $fieldMap as $arKey => $revKey ) { + if ( property_exists( $archiveRow, $arKey ) ) { + $revRow->$revKey = $archiveRow->$arKey; + } + } + + return $revRow; + } + + /** + * Constructs a RevisionRecord for the revisions main slot, based on the MW1.29 schema. + * + * @param object|array $row Either a database row or an array + * @param int $queryFlags for callbacks + * @param Title $title + * + * @return SlotRecord The main slot, extracted from the MW 1.29 style row. + * @throws MWException + */ + private function emulateMainSlot_1_29( $row, $queryFlags, Title $title ) { + $mainSlotRow = new stdClass(); + $mainSlotRow->role_name = 'main'; + + $content = null; + $blobData = null; + $blobFlags = ''; + + if ( is_object( $row ) ) { + // archive row + if ( !isset( $row->rev_id ) && isset( $row->ar_user ) ) { + $row = $this->mapArchiveFields( $row ); + } + + if ( isset( $row->rev_text_id ) && $row->rev_text_id > 0 ) { + $mainSlotRow->cont_address = 'tt:' . $row->rev_text_id; + } elseif ( isset( $row->ar_id ) ) { + $mainSlotRow->cont_address = 'ar:' . $row->ar_id; + } + + if ( isset( $row->old_text ) ) { + // this happens when the text-table gets joined directly, in the pre-1.30 schema + $blobData = isset( $row->old_text ) ? strval( $row->old_text ) : null; + $blobFlags = isset( $row->old_flags ) ? strval( $row->old_flags ) : ''; + } + + $mainSlotRow->slot_revision = intval( $row->rev_id ); + + $mainSlotRow->cont_size = isset( $row->rev_len ) ? intval( $row->rev_len ) : null; + $mainSlotRow->cont_sha1 = isset( $row->rev_sha1 ) ? strval( $row->rev_sha1 ) : null; + $mainSlotRow->model_name = isset( $row->rev_content_model ) + ? strval( $row->rev_content_model ) + : null; + // XXX: in the future, we'll probably always use the default format, and drop content_format + $mainSlotRow->format_name = isset( $row->rev_content_format ) + ? strval( $row->rev_content_format ) + : null; + } elseif ( is_array( $row ) ) { + $mainSlotRow->slot_revision = isset( $row['id'] ) ? intval( $row['id'] ) : null; + + $mainSlotRow->cont_address = isset( $row['text_id'] ) + ? 'tt:' . intval( $row['text_id'] ) + : null; + $mainSlotRow->cont_size = isset( $row['len'] ) ? intval( $row['len'] ) : null; + $mainSlotRow->cont_sha1 = isset( $row['sha1'] ) ? strval( $row['sha1'] ) : null; + + $mainSlotRow->model_name = isset( $row['content_model'] ) + ? strval( $row['content_model'] ) : null; // XXX: must be a string! + // XXX: in the future, we'll probably always use the default format, and drop content_format + $mainSlotRow->format_name = isset( $row['content_format'] ) + ? strval( $row['content_format'] ) : null; + $blobData = isset( $row['text'] ) ? rtrim( strval( $row['text'] ) ) : null; + $blobFlags = isset( $row['flags'] ) ? trim( strval( $row['flags'] ) ) : ''; + + // if we have a Content object, override mText and mContentModel + if ( !empty( $row['content'] ) ) { + if ( !( $row['content'] instanceof Content ) ) { + throw new MWException( 'content field must contain a Content object.' ); + } + + /** @var Content $content */ + $content = $row['content']; + $handler = $content->getContentHandler(); + + $mainSlotRow->model_name = $content->getModel(); + + // XXX: in the future, we'll probably always use the default format. + if ( $mainSlotRow->format_name === null ) { + $mainSlotRow->format_name = $handler->getDefaultFormat(); + } + } + } else { + throw new MWException( 'Revision constructor passed invalid row format.' ); + } + + // With the old schema, the content changes with every revision. + // ...except for null-revisions. Would be nice if we could detect them. + $mainSlotRow->slot_inherited = 0; + + if ( $mainSlotRow->model_name === null ) { + $mainSlotRow->model_name = function ( SlotRecord $slot ) use ( $title ) { + // TODO: MCR: consider slot role in getDefaultModelFor()! Use LinkTarget! + // TODO: MCR: deprecate $title->getModel(). + return ContentHandler::getDefaultModelFor( $title ); + }; + } + + if ( !$content ) { + $content = function ( SlotRecord $slot ) + use ( $blobData, $blobFlags, $queryFlags, $mainSlotRow ) + { + return $this->loadSlotContent( + $slot, + $blobData, + $blobFlags, + $mainSlotRow->format_name, + $queryFlags + ); + }; + } + + return new SlotRecord( $mainSlotRow, $content ); + } + + /** + * Loads a Content object based on a slot row. + * + * This method does not call $slot->getContent(), and may be used as a callback + * called by $slot->getContent(). + * + * MCR migration note: this roughly corresponds to Revision::getContentInternal + * + * @param SlotRecord $slot The SlotRecord to load content for + * @param string|null $blobData The content blob, in the form indicated by $blobFlags + * @param string $blobFlags Flags indicating how $blobData needs to be processed + * @param string|null $blobFormat MIME type indicating how $dataBlob is encoded + * @param int $queryFlags + * + * @throw RevisionAccessException + * @return Content + */ + private function loadSlotContent( + SlotRecord $slot, + $blobData = null, + $blobFlags = '', + $blobFormat = null, + $queryFlags = 0 + ) { + if ( $blobData !== null ) { + Assert::parameterType( 'string', $blobData, '$blobData' ); + Assert::parameterType( 'string', $blobFlags, '$blobFlags' ); + + $cacheKey = $slot->hasAddress() ? $slot->getAddress() : null; + + $data = $this->blobStore->expandBlob( $blobData, $blobFlags, $cacheKey ); + + if ( $data === false ) { + throw new RevisionAccessException( + "Failed to expand blob data using flags $blobFlags (key: $cacheKey)" + ); + } + } else { + $address = $slot->getAddress(); + try { + $data = $this->blobStore->getBlob( $address, $queryFlags ); + } catch ( BlobAccessException $e ) { + throw new RevisionAccessException( + "Failed to load data blob from $address: " . $e->getMessage(), 0, $e + ); + } + } + + // Unserialize content + $handler = ContentHandler::getForModelID( $slot->getModel() ); + + $content = $handler->unserializeContent( $data, $blobFormat ); + return $content; + } + + /** + * Load a page revision from a given revision ID number. + * Returns null if no such revision can be found. + * + * MCR migration note: this replaces Revision::newFromId + * + * $flags include: + * IDBAccessObject::READ_LATEST: Select the data from the master + * IDBAccessObject::READ_LOCKING : Select & lock the data from the master + * + * @param int $id + * @param int $flags (optional) + * @return RevisionRecord|null + */ + public function getRevisionById( $id, $flags = 0 ) { + return $this->newRevisionFromConds( [ 'rev_id' => intval( $id ) ], $flags ); + } + + /** + * Load either the current, or a specified, revision + * that's attached to a given link target. If not attached + * to that link target, will return null. + * + * MCR migration note: this replaces Revision::newFromTitle + * + * $flags include: + * IDBAccessObject::READ_LATEST: Select the data from the master + * IDBAccessObject::READ_LOCKING : Select & lock the data from the master + * + * @param LinkTarget $linkTarget + * @param int $revId (optional) + * @param int $flags Bitfield (optional) + * @return RevisionRecord|null + */ + public function getRevisionByTitle( LinkTarget $linkTarget, $revId = 0, $flags = 0 ) { + $conds = [ + 'page_namespace' => $linkTarget->getNamespace(), + 'page_title' => $linkTarget->getDBkey() + ]; + if ( $revId ) { + // Use the specified revision ID. + // Note that we use newRevisionFromConds here because we want to retry + // and fall back to master if the page is not found on a replica. + // Since the caller supplied a revision ID, we are pretty sure the revision is + // supposed to exist, so we should try hard to find it. + $conds['rev_id'] = $revId; + return $this->newRevisionFromConds( $conds, $flags ); + } else { + // Use a join to get the latest revision. + // Note that we don't use newRevisionFromConds here because we don't want to retry + // and fall back to master. The assumption is that we only want to force the fallback + // if we are quite sure the revision exists because the caller supplied a revision ID. + // If the page isn't found at all on a replica, it probably simply does not exist. + $db = $this->getDBConnection( ( $flags & self::READ_LATEST ) ? DB_MASTER : DB_REPLICA ); + + $conds[] = 'rev_id=page_latest'; + $rev = $this->loadRevisionFromConds( $db, $conds, $flags ); + + $this->releaseDBConnection( $db ); + return $rev; + } + } + + /** + * Load either the current, or a specified, revision + * that's attached to a given page ID. + * Returns null if no such revision can be found. + * + * MCR migration note: this replaces Revision::newFromPageId + * + * $flags include: + * IDBAccessObject::READ_LATEST: Select the data from the master (since 1.20) + * IDBAccessObject::READ_LOCKING : Select & lock the data from the master + * + * @param int $pageId + * @param int $revId (optional) + * @param int $flags Bitfield (optional) + * @return RevisionRecord|null + */ + public function getRevisionByPageId( $pageId, $revId = 0, $flags = 0 ) { + $conds = [ 'page_id' => $pageId ]; + if ( $revId ) { + // Use the specified revision ID. + // Note that we use newRevisionFromConds here because we want to retry + // and fall back to master if the page is not found on a replica. + // Since the caller supplied a revision ID, we are pretty sure the revision is + // supposed to exist, so we should try hard to find it. + $conds['rev_id'] = $revId; + return $this->newRevisionFromConds( $conds, $flags ); + } else { + // Use a join to get the latest revision. + // Note that we don't use newRevisionFromConds here because we don't want to retry + // and fall back to master. The assumption is that we only want to force the fallback + // if we are quite sure the revision exists because the caller supplied a revision ID. + // If the page isn't found at all on a replica, it probably simply does not exist. + $db = $this->getDBConnection( ( $flags & self::READ_LATEST ) ? DB_MASTER : DB_REPLICA ); + + $conds[] = 'rev_id=page_latest'; + $rev = $this->loadRevisionFromConds( $db, $conds, $flags ); + + $this->releaseDBConnection( $db ); + return $rev; + } + } + + /** + * Load the revision for the given title with the given timestamp. + * WARNING: Timestamps may in some circumstances not be unique, + * so this isn't the best key to use. + * + * MCR migration note: this replaces Revision::loadFromTimestamp + * + * @param Title $title + * @param string $timestamp + * @return RevisionRecord|null + */ + public function getRevisionFromTimestamp( $title, $timestamp ) { + return $this->newRevisionFromConds( + [ + 'rev_timestamp' => $timestamp, + 'page_namespace' => $title->getNamespace(), + 'page_title' => $title->getDBkey() + ], + 0, + $title + ); + } + + /** + * Make a fake revision object from an archive table row. This is queried + * for permissions or even inserted (as in Special:Undelete) + * + * MCR migration note: this replaces Revision::newFromArchiveRow + * + * @param object $row + * @param int $queryFlags + * @param Title|null $title + * @param array $overrides associative array with fields of $row to override. This may be + * used e.g. to force the parent revision ID or page ID. Keys in the array are fields + * names from the archive table without the 'ar_' prefix, i.e. use 'parent_id' to + * override ar_parent_id. + * + * @return RevisionRecord + * @throws MWException + */ + public function newRevisionFromArchiveRow( + $row, + $queryFlags = 0, + Title $title = null, + array $overrides = [] + ) { + Assert::parameterType( 'object', $row, '$row' ); + + // check second argument, since Revision::newFromArchiveRow had $overrides in that spot. + Assert::parameterType( 'integer', $queryFlags, '$queryFlags' ); + + if ( !$title && isset( $overrides['title'] ) ) { + if ( !( $overrides['title'] instanceof Title ) ) { + throw new MWException( 'title field override must contain a Title object.' ); + } + + $title = $overrides['title']; + } + + if ( !isset( $title ) ) { + if ( isset( $row->ar_namespace ) && isset( $row->ar_title ) ) { + $title = Title::makeTitle( $row->ar_namespace, $row->ar_title ); + } else { + throw new InvalidArgumentException( + 'A Title or ar_namespace and ar_title must be given' + ); + } + } + + foreach ( $overrides as $key => $value ) { + $field = "ar_$key"; + $row->$field = $value; + } + + $user = $this->getUserIdentityFromRowObject( $row, 'ar_' ); + + $comment = CommentStore::newKey( 'ar_comment' ) + // Legacy because $row may have come from self::selectFields() + ->getCommentLegacy( $this->getDBConnection( DB_REPLICA ), $row, true ); + + $mainSlot = $this->emulateMainSlot_1_29( $row, $queryFlags, $title ); + $slots = new RevisionSlots( [ 'main' => $mainSlot ] ); + + return new RevisionArchiveRecord( $title, $user, $comment, $row, $slots, $this->wikiId ); + } + + /** + * @param object $row + * @param string $prefix Field prefix, such as 'rev_' or 'ar_'. + * + * @return UserIdentityValue + */ + private function getUserIdentityFromRowObject( $row, $prefix = 'rev_' ) { + $idField = "{$prefix}user"; + $nameField = "{$prefix}user_text"; + + $userId = intval( $row->$idField ); + + if ( isset( $row->user_name ) ) { + $userName = $row->user_name; + } elseif ( isset( $row->$nameField ) ) { + $userName = $row->$nameField; + } else { + $userName = User::whoIs( $userId ); + } + + if ( $userName === false ) { + wfWarn( __METHOD__ . ': Cannot determine user name for user ID ' . $userId ); + $userName = ''; + } + + return new UserIdentityValue( $userId, $userName ); + } + + /** + * @see RevisionFactory::newRevisionFromRow_1_29 + * + * MCR migration note: this replaces Revision::newFromRow + * + * @param object $row + * @param int $queryFlags + * @param Title|null $title + * + * @return RevisionRecord + * @throws MWException + * @throws RevisionAccessException + */ + private function newRevisionFromRow_1_29( $row, $queryFlags = 0, Title $title = null ) { + Assert::parameterType( 'object', $row, '$row' ); + + if ( !$title ) { + $pageId = isset( $row->rev_page ) ? $row->rev_page : 0; // XXX: also check page_id? + $revId = isset( $row->rev_id ) ? $row->rev_id : 0; + + $title = $this->getTitle( $pageId, $revId ); + } + + if ( !isset( $row->page_latest ) ) { + $row->page_latest = $title->getLatestRevID(); + if ( $row->page_latest === 0 && $title->exists() ) { + wfWarn( 'Encountered title object in limbo: ID ' . $title->getArticleID() ); + } + } + + $user = $this->getUserIdentityFromRowObject( $row ); + + $comment = CommentStore::newKey( 'rev_comment' ) + // Legacy because $row may have come from self::selectFields() + ->getCommentLegacy( $this->getDBConnection( DB_REPLICA ), $row, true ); + + $mainSlot = $this->emulateMainSlot_1_29( $row, $queryFlags, $title ); + $slots = new RevisionSlots( [ 'main' => $mainSlot ] ); + + return new RevisionStoreRecord( $title, $user, $comment, $row, $slots, $this->wikiId ); + } + + /** + * @see RevisionFactory::newRevisionFromRow + * + * MCR migration note: this replaces Revision::newFromRow + * + * @param object $row + * @param int $queryFlags + * @param Title|null $title + * + * @return RevisionRecord + */ + public function newRevisionFromRow( $row, $queryFlags = 0, Title $title = null ) { + return $this->newRevisionFromRow_1_29( $row, $queryFlags, $title ); + } + + /** + * Constructs a new MutableRevisionRecord based on the given associative array following + * the MW1.29 convention for the Revision constructor. + * + * MCR migration note: this replaces Revision::newFromRow + * + * @param array $fields + * @param int $queryFlags + * @param Title|null $title + * + * @return MutableRevisionRecord + * @throws MWException + * @throws RevisionAccessException + */ + public function newMutableRevisionFromArray( + array $fields, + $queryFlags = 0, + Title $title = null + ) { + if ( !$title && isset( $fields['title'] ) ) { + if ( !( $fields['title'] instanceof Title ) ) { + throw new MWException( 'title field must contain a Title object.' ); + } + + $title = $fields['title']; + } + + if ( !$title ) { + $pageId = isset( $fields['page'] ) ? $fields['page'] : 0; + $revId = isset( $fields['id'] ) ? $fields['id'] : 0; + + $title = $this->getTitle( $pageId, $revId ); + } + + if ( !isset( $fields['page'] ) ) { + $fields['page'] = $title->getArticleID( $queryFlags ); + } + + // if we have a content object, use it to set the model and type + if ( !empty( $fields['content'] ) ) { + if ( !( $fields['content'] instanceof Content ) ) { + throw new MWException( 'content field must contain a Content object.' ); + } + + if ( !empty( $fields['text_id'] ) ) { + throw new MWException( + "Text already stored in external store (id {$fields['text_id']}), " . + "can't serialize content object" + ); + } + } + + // Replaces old lazy loading logic in Revision::getUserText. + if ( !isset( $fields['user_text'] ) && isset( $fields['user'] ) ) { + if ( $fields['user'] instanceof UserIdentity ) { + /** @var User $user */ + $user = $fields['user']; + $fields['user_text'] = $user->getName(); + $fields['user'] = $user->getId(); + } else { + // TODO: wrap this in a callback to make it lazy again. + $name = $fields['user'] === 0 ? false : User::whoIs( $fields['user'] ); + + if ( $name === false ) { + throw new MWException( + 'user_text not given, and unknown user ID ' . $fields['user'] + ); + } + + $fields['user_text'] = $name; + } + } + + if ( + isset( $fields['comment'] ) + && !( $fields['comment'] instanceof CommentStoreComment ) + ) { + $commentData = isset( $fields['comment_data'] ) ? $fields['comment_data'] : null; + + if ( $fields['comment'] instanceof Message ) { + $fields['comment'] = CommentStoreComment::newUnsavedComment( + $fields['comment'], + $commentData + ); + } else { + $commentText = trim( strval( $fields['comment'] ) ); + $fields['comment'] = CommentStoreComment::newUnsavedComment( + $commentText, + $commentData + ); + } + } + + $mainSlot = $this->emulateMainSlot_1_29( $fields, $queryFlags, $title ); + + $revision = new MutableRevisionRecord( $title, $this->wikiId ); + $this->initializeMutableRevisionFromArray( $revision, $fields ); + $revision->setSlot( $mainSlot ); + + return $revision; + } + + /** + * @param MutableRevisionRecord $record + * @param array $fields + */ + private function initializeMutableRevisionFromArray( + MutableRevisionRecord $record, + array $fields + ) { + /** @var UserIdentity $user */ + $user = null; + + if ( isset( $fields['user'] ) && ( $fields['user'] instanceof UserIdentity ) ) { + $user = $fields['user']; + } elseif ( isset( $fields['user'] ) && isset( $fields['user_text'] ) ) { + $user = new UserIdentityValue( intval( $fields['user'] ), $fields['user_text'] ); + } elseif ( isset( $fields['user'] ) ) { + $user = User::newFromId( intval( $fields['user'] ) ); + } elseif ( isset( $fields['user_text'] ) ) { + $user = User::newFromName( $fields['user_text'] ); + + // User::newFromName will return false for IP addresses (and invalid names) + if ( $user == false ) { + $user = new UserIdentityValue( 0, $fields['user_text'] ); + } + } + + if ( $user ) { + $record->setUser( $user ); + } + + $timestamp = isset( $fields['timestamp'] ) + ? strval( $fields['timestamp'] ) + : wfTimestampNow(); // TODO: use a callback, so we can override it for testing. + + $record->setTimestamp( $timestamp ); + + if ( isset( $fields['page'] ) ) { + $record->setPageId( intval( $fields['page'] ) ); + } + + if ( isset( $fields['id'] ) ) { + $record->setId( intval( $fields['id'] ) ); + } + if ( isset( $fields['parent_id'] ) ) { + $record->setParentId( intval( $fields['parent_id'] ) ); + } + + if ( isset( $fields['sha1'] ) ) { + $record->setSha1( $fields['sha1'] ); + } + if ( isset( $fields['size'] ) ) { + $record->setSize( intval( $fields['size'] ) ); + } + + if ( isset( $fields['minor_edit'] ) ) { + $record->setMinorEdit( intval( $fields['minor_edit'] ) !== 0 ); + } + if ( isset( $fields['deleted'] ) ) { + $record->setVisibility( intval( $fields['deleted'] ) ); + } + + if ( isset( $fields['comment'] ) ) { + Assert::parameterType( + CommentStoreComment::class, + $fields['comment'], + '$row[\'comment\']' + ); + $record->setComment( $fields['comment'] ); + } + } + + /** + * Load a page revision from a given revision ID number. + * Returns null if no such revision can be found. + * + * MCR migration note: this corresponds to Revision::loadFromId + * + * @note direct use is deprecated! + * @todo remove when unused! there seem to be no callers of Revision::loadFromId + * + * @param IDatabase $db + * @param int $id + * + * @return RevisionRecord|null + */ + public function loadRevisionFromId( IDatabase $db, $id ) { + return $this->loadRevisionFromConds( $db, [ 'rev_id' => intval( $id ) ] ); + } + + /** + * Load either the current, or a specified, revision + * that's attached to a given page. If not attached + * to that page, will return null. + * + * MCR migration note: this replaces Revision::loadFromPageId + * + * @note direct use is deprecated! + * @todo remove when unused! + * + * @param IDatabase $db + * @param int $pageid + * @param int $id + * @return RevisionRecord|null + */ + public function loadRevisionFromPageId( IDatabase $db, $pageid, $id = 0 ) { + $conds = [ 'rev_page' => intval( $pageid ), 'page_id' => intval( $pageid ) ]; + if ( $id ) { + $conds['rev_id'] = intval( $id ); + } else { + $conds[] = 'rev_id=page_latest'; + } + return $this->loadRevisionFromConds( $db, $conds ); + } + + /** + * Load either the current, or a specified, revision + * that's attached to a given page. If not attached + * to that page, will return null. + * + * MCR migration note: this replaces Revision::loadFromTitle + * + * @note direct use is deprecated! + * @todo remove when unused! + * + * @param IDatabase $db + * @param Title $title + * @param int $id + * + * @return RevisionRecord|null + */ + public function loadRevisionFromTitle( IDatabase $db, $title, $id = 0 ) { + if ( $id ) { + $matchId = intval( $id ); + } else { + $matchId = 'page_latest'; + } + + return $this->loadRevisionFromConds( + $db, + [ + "rev_id=$matchId", + 'page_namespace' => $title->getNamespace(), + 'page_title' => $title->getDBkey() + ], + 0, + $title + ); + } + + /** + * Load the revision for the given title with the given timestamp. + * WARNING: Timestamps may in some circumstances not be unique, + * so this isn't the best key to use. + * + * MCR migration note: this replaces Revision::loadFromTimestamp + * + * @note direct use is deprecated! Use getRevisionFromTimestamp instead! + * @todo remove when unused! + * + * @param IDatabase $db + * @param Title $title + * @param string $timestamp + * @return RevisionRecord|null + */ + public function loadRevisionFromTimestamp( IDatabase $db, $title, $timestamp ) { + return $this->loadRevisionFromConds( $db, + [ + 'rev_timestamp' => $db->timestamp( $timestamp ), + 'page_namespace' => $title->getNamespace(), + 'page_title' => $title->getDBkey() + ], + 0, + $title + ); + } + + /** + * Given a set of conditions, fetch a revision + * + * This method should be used if we are pretty sure the revision exists. + * Unless $flags has READ_LATEST set, this method will first try to find the revision + * on a replica before hitting the master database. + * + * MCR migration note: this corresponds to Revision::newFromConds + * + * @param array $conditions + * @param int $flags (optional) + * @param Title $title + * + * @return RevisionRecord|null + */ + private function newRevisionFromConds( $conditions, $flags = 0, Title $title = null ) { + $db = $this->getDBConnection( ( $flags & self::READ_LATEST ) ? DB_MASTER : DB_REPLICA ); + $rev = $this->loadRevisionFromConds( $db, $conditions, $flags, $title ); + $this->releaseDBConnection( $db ); + + $lb = $this->getDBLoadBalancer(); + + // Make sure new pending/committed revision are visibile later on + // within web requests to certain avoid bugs like T93866 and T94407. + if ( !$rev + && !( $flags & self::READ_LATEST ) + && $lb->getServerCount() > 1 + && $lb->hasOrMadeRecentMasterChanges() + ) { + $flags = self::READ_LATEST; + $db = $this->getDBConnection( DB_MASTER ); + $rev = $this->loadRevisionFromConds( $db, $conditions, $flags, $title ); + $this->releaseDBConnection( $db ); + } + + return $rev; + } + + /** + * Given a set of conditions, fetch a revision from + * the given database connection. + * + * MCR migration note: this corresponds to Revision::loadFromConds + * + * @param IDatabase $db + * @param array $conditions + * @param int $flags (optional) + * @param Title $title + * + * @return RevisionRecord|null + */ + private function loadRevisionFromConds( + IDatabase $db, + $conditions, + $flags = 0, + Title $title = null + ) { + $row = $this->fetchRevisionRowFromConds( $db, $conditions, $flags ); + if ( $row ) { + $rev = $this->newRevisionFromRow( $row, $flags, $title ); + + return $rev; + } + + return null; + } + + /** + * Throws an exception if the given database connection does not belong to the wiki this + * RevisionStore is bound to. + * + * @param IDatabase $db + * @throws MWException + */ + private function checkDatabaseWikiId( IDatabase $db ) { + $storeWiki = $this->wikiId; + $dbWiki = $db->getDomainID(); + + if ( $dbWiki === $storeWiki ) { + return; + } + + // XXX: we really want the default database ID... + $storeWiki = $storeWiki ?: wfWikiID(); + $dbWiki = $dbWiki ?: wfWikiID(); + + if ( $dbWiki !== $storeWiki ) { + throw new MWException( "RevisionStore for $storeWiki " + . "cannot be used with a DB connection for $dbWiki" ); + } + } + + /** + * Given a set of conditions, return a row with the + * fields necessary to build RevisionRecord objects. + * + * MCR migration note: this corresponds to Revision::fetchFromConds + * + * @param IDatabase $db + * @param array $conditions + * @param int $flags (optional) + * + * @return object|false data row as a raw object + */ + private function fetchRevisionRowFromConds( IDatabase $db, $conditions, $flags = 0 ) { + $this->checkDatabaseWikiId( $db ); + + $revQuery = self::getQueryInfo( [ 'page', 'user' ] ); + $options = []; + if ( ( $flags & self::READ_LOCKING ) == self::READ_LOCKING ) { + $options[] = 'FOR UPDATE'; + } + return $db->selectRow( + $revQuery['tables'], + $revQuery['fields'], + $conditions, + __METHOD__, + $options, + $revQuery['joins'] + ); + } + + /** + * Return the tables, fields, and join conditions to be selected to create + * a new revision object. + * + * MCR migration note: this replaces Revision::getQueryInfo + * + * @since 1.31 + * + * @param array $options Any combination of the following strings + * - 'page': Join with the page table, and select fields to identify the page + * - 'user': Join with the user table, and select the user name + * - 'text': Join with the text table, and select fields to load page text + * + * @return array With three keys: + * - tables: (string[]) to include in the `$table` to `IDatabase->select()` + * - fields: (string[]) to include in the `$vars` to `IDatabase->select()` + * - joins: (array) to include in the `$join_conds` to `IDatabase->select()` + */ + public function getQueryInfo( $options = [] ) { + $ret = [ + 'tables' => [], + 'fields' => [], + 'joins' => [], + ]; + + $ret['tables'][] = 'revision'; + $ret['fields'] = array_merge( $ret['fields'], [ + 'rev_id', + 'rev_page', + 'rev_text_id', + 'rev_timestamp', + 'rev_user_text', + 'rev_user', + 'rev_minor_edit', + 'rev_deleted', + 'rev_len', + 'rev_parent_id', + 'rev_sha1', + ] ); + + $commentQuery = CommentStore::newKey( 'rev_comment' )->getJoin(); + $ret['tables'] = array_merge( $ret['tables'], $commentQuery['tables'] ); + $ret['fields'] = array_merge( $ret['fields'], $commentQuery['fields'] ); + $ret['joins'] = array_merge( $ret['joins'], $commentQuery['joins'] ); + + if ( $this->contentHandlerUseDB ) { + $ret['fields'][] = 'rev_content_format'; + $ret['fields'][] = 'rev_content_model'; + } + + if ( in_array( 'page', $options, true ) ) { + $ret['tables'][] = 'page'; + $ret['fields'] = array_merge( $ret['fields'], [ + 'page_namespace', + 'page_title', + 'page_id', + 'page_latest', + 'page_is_redirect', + 'page_len', + ] ); + $ret['joins']['page'] = [ 'INNER JOIN', [ 'page_id = rev_page' ] ]; + } + + if ( in_array( 'user', $options, true ) ) { + $ret['tables'][] = 'user'; + $ret['fields'] = array_merge( $ret['fields'], [ + 'user_name', + ] ); + $ret['joins']['user'] = [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ]; + } + + if ( in_array( 'text', $options, true ) ) { + $ret['tables'][] = 'text'; + $ret['fields'] = array_merge( $ret['fields'], [ + 'old_text', + 'old_flags' + ] ); + $ret['joins']['text'] = [ 'INNER JOIN', [ 'rev_text_id=old_id' ] ]; + } + + return $ret; + } + + /** + * Return the tables, fields, and join conditions to be selected to create + * a new archived revision object. + * + * MCR migration note: this replaces Revision::getArchiveQueryInfo + * + * @since 1.31 + * + * @return array With three keys: + * - tables: (string[]) to include in the `$table` to `IDatabase->select()` + * - fields: (string[]) to include in the `$vars` to `IDatabase->select()` + * - joins: (array) to include in the `$join_conds` to `IDatabase->select()` + */ + public function getArchiveQueryInfo() { + $commentQuery = CommentStore::newKey( 'ar_comment' )->getJoin(); + $ret = [ + 'tables' => [ 'archive' ] + $commentQuery['tables'], + 'fields' => [ + 'ar_id', + 'ar_page_id', + 'ar_namespace', + 'ar_title', + 'ar_rev_id', + 'ar_text', + 'ar_text_id', + 'ar_timestamp', + 'ar_user_text', + 'ar_user', + 'ar_minor_edit', + 'ar_deleted', + 'ar_len', + 'ar_parent_id', + 'ar_sha1', + ] + $commentQuery['fields'], + 'joins' => $commentQuery['joins'], + ]; + + if ( $this->contentHandlerUseDB ) { + $ret['fields'][] = 'ar_content_format'; + $ret['fields'][] = 'ar_content_model'; + } + + return $ret; + } + + /** + * Do a batched query for the sizes of a set of revisions. + * + * MCR migration note: this replaces Revision::getParentLengths + * + * @param IDatabase $db + * @param int[] $revIds + * @return int[] associative array mapping revision IDs from $revIds to the nominal size + * of the corresponding revision. + */ + public function listRevisionSizes( IDatabase $db, array $revIds ) { + $this->checkDatabaseWikiId( $db ); + + $revLens = []; + if ( !$revIds ) { + return $revLens; // empty + } + + $res = $db->select( + 'revision', + [ 'rev_id', 'rev_len' ], + [ 'rev_id' => $revIds ], + __METHOD__ + ); + + foreach ( $res as $row ) { + $revLens[$row->rev_id] = intval( $row->rev_len ); + } + + return $revLens; + } + + /** + * Get previous revision for this title + * + * MCR migration note: this replaces Revision::getPrevious + * + * @param RevisionRecord $rev + * + * @return RevisionRecord|null + */ + public function getPreviousRevision( RevisionRecord $rev ) { + $title = $this->getTitle( $rev->getPageId(), $rev->getId() ); + $prev = $title->getPreviousRevisionID( $rev->getId() ); + if ( $prev ) { + return $this->getRevisionByTitle( $title, $prev ); + } + return null; + } + + /** + * Get next revision for this title + * + * MCR migration note: this replaces Revision::getNext + * + * @param RevisionRecord $rev + * + * @return RevisionRecord|null + */ + public function getNextRevision( RevisionRecord $rev ) { + $title = $this->getTitle( $rev->getPageId(), $rev->getId() ); + $next = $title->getNextRevisionID( $rev->getId() ); + if ( $next ) { + return $this->getRevisionByTitle( $title, $next ); + } + return null; + } + + /** + * Get previous revision Id for this page_id + * This is used to populate rev_parent_id on save + * + * MCR migration note: this corresponds to Revision::getPreviousRevisionId + * + * @param IDatabase $db + * @param RevisionRecord $rev + * + * @return int + */ + private function getPreviousRevisionId( IDatabase $db, RevisionRecord $rev ) { + $this->checkDatabaseWikiId( $db ); + + if ( $rev->getPageId() === null ) { + return 0; + } + # Use page_latest if ID is not given + if ( !$rev->getId() ) { + $prevId = $db->selectField( + 'page', 'page_latest', + [ 'page_id' => $rev->getPageId() ], + __METHOD__ + ); + } else { + $prevId = $db->selectField( + 'revision', 'rev_id', + [ 'rev_page' => $rev->getPageId(), 'rev_id < ' . $rev->getId() ], + __METHOD__, + [ 'ORDER BY' => 'rev_id DESC' ] + ); + } + return intval( $prevId ); + } + + /** + * Get rev_timestamp from rev_id, without loading the rest of the row + * + * MCR migration note: this replaces Revision::getTimestampFromId + * + * @param Title $title + * @param int $id + * @param int $flags + * @return string|bool False if not found + */ + public function getTimestampFromId( $title, $id, $flags = 0 ) { + $db = $this->getDBConnection( + ( $flags & IDBAccessObject::READ_LATEST ) ? DB_MASTER : DB_REPLICA + ); + + $conds = [ 'rev_id' => $id ]; + $conds['rev_page'] = $title->getArticleID(); + $timestamp = $db->selectField( 'revision', 'rev_timestamp', $conds, __METHOD__ ); + + $this->releaseDBConnection( $db ); + return ( $timestamp !== false ) ? wfTimestamp( TS_MW, $timestamp ) : false; + } + + /** + * Get count of revisions per page...not very efficient + * + * MCR migration note: this replaces Revision::countByPageId + * + * @param IDatabase $db + * @param int $id Page id + * @return int + */ + public function countRevisionsByPageId( IDatabase $db, $id ) { + $this->checkDatabaseWikiId( $db ); + + $row = $db->selectRow( 'revision', + [ 'revCount' => 'COUNT(*)' ], + [ 'rev_page' => $id ], + __METHOD__ + ); + if ( $row ) { + return intval( $row->revCount ); + } + return 0; + } + + /** + * Get count of revisions per page...not very efficient + * + * MCR migration note: this replaces Revision::countByTitle + * + * @param IDatabase $db + * @param Title $title + * @return int + */ + public function countRevisionsByTitle( IDatabase $db, $title ) { + $id = $title->getArticleID(); + if ( $id ) { + return $this->countRevisionsByPageId( $db, $id ); + } + return 0; + } + + /** + * Check if no edits were made by other users since + * the time a user started editing the page. Limit to + * 50 revisions for the sake of performance. + * + * MCR migration note: this replaces Revision::userWasLastToEdit + * + * @deprecated since 1.31; Can possibly be removed, since the self-conflict suppression + * logic in EditPage that uses this seems conceptually dubious. Revision::userWasLastToEdit + * has been deprecated since 1.24. + * + * @param IDatabase $db The Database to perform the check on. + * @param int $pageId The ID of the page in question + * @param int $userId The ID of the user in question + * @param string $since Look at edits since this time + * + * @return bool True if the given user was the only one to edit since the given timestamp + */ + public function userWasLastToEdit( IDatabase $db, $pageId, $userId, $since ) { + $this->checkDatabaseWikiId( $db ); + + if ( !$userId ) { + return false; + } + + $res = $db->select( + 'revision', + 'rev_user', + [ + 'rev_page' => $pageId, + 'rev_timestamp > ' . $db->addQuotes( $db->timestamp( $since ) ) + ], + __METHOD__, + [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 50 ] + ); + foreach ( $res as $row ) { + if ( $row->rev_user != $userId ) { + return false; + } + } + return true; + } + + /** + * Load a revision based on a known page ID and current revision ID from the DB + * + * This method allows for the use of caching, though accessing anything that normally + * requires permission checks (aside from the text) will trigger a small DB lookup. + * + * MCR migration note: this replaces Revision::newKnownCurrent + * + * @param Title $title the associated page title + * @param int $revId current revision of this page. Defaults to $title->getLatestRevID(). + * + * @return RevisionRecord|bool Returns false if missing + */ + public function getKnownCurrentRevision( Title $title, $revId ) { + $db = $this->getDBConnectionRef( DB_REPLICA ); + + $pageId = $title->getArticleID(); + + if ( !$pageId ) { + return false; + } + + if ( !$revId ) { + $revId = $title->getLatestRevID(); + } + + if ( !$revId ) { + wfWarn( + 'No latest revision known for page ' . $title->getPrefixedDBkey() + . ' even though it exists with page ID ' . $pageId + ); + return false; + } + + $row = $this->cache->getWithSetCallback( + // Page/rev IDs passed in from DB to reflect history merges + $this->cache->makeGlobalKey( 'revision-row-1.29', $db->getDomainID(), $pageId, $revId ), + WANObjectCache::TTL_WEEK, + function ( $curValue, &$ttl, array &$setOpts ) use ( $db, $pageId, $revId ) { + $setOpts += Database::getCacheSetOptions( $db ); + + $conds = [ + 'rev_page' => intval( $pageId ), + 'page_id' => intval( $pageId ), + 'rev_id' => intval( $revId ), + ]; + + $row = $this->fetchRevisionRowFromConds( $db, $conds ); + return $row ?: false; // don't cache negatives + } + ); + + // Reflect revision deletion and user renames + if ( $row ) { + return $this->newRevisionFromRow( $row, 0, $title ); + } else { + return false; + } + } + + // TODO: move relevant methods from Title here, e.g. getFirstRevision, isBigDeletion, etc. + +} diff --git a/includes/Storage/RevisionStoreRecord.php b/includes/Storage/RevisionStoreRecord.php new file mode 100644 index 0000000000..50ae8d57d9 --- /dev/null +++ b/includes/Storage/RevisionStoreRecord.php @@ -0,0 +1,207 @@ +mId = intval( $row->rev_id ); + $this->mPageId = intval( $row->rev_page ); + $this->mComment = $comment; + + $timestamp = wfTimestamp( TS_MW, $row->rev_timestamp ); + Assert::parameter( is_string( $timestamp ), '$row->rev_timestamp', 'must be a valid timestamp' ); + + $this->mUser = $user; + $this->mMinorEdit = boolval( $row->rev_minor_edit ); + $this->mTimestamp = $timestamp; + $this->mDeleted = intval( $row->rev_deleted ); + + // NOTE: rev_parent_id = 0 indicates that there is no parent revision, while null + // indicates that the parent revision is unknown. As per MW 1.31, the database schema + // allows rev_parent_id to be NULL. + $this->mParentId = isset( $row->rev_parent_id ) ? intval( $row->rev_parent_id ) : null; + $this->mSize = isset( $row->rev_len ) ? intval( $row->rev_len ) : null; + $this->mSha1 = isset( $row->rev_sha1 ) ? $row->rev_sha1 : null; + + // NOTE: we must not call $this->mTitle->getLatestRevID() here, since the state of + // page_latest may be in limbo during revision creation. In that case, calling + // $this->mTitle->getLatestRevID() would cause a bad value to be cached in the Title + // object. During page creation, that bad value would be 0. + if ( isset( $row->page_latest ) ) { + $this->mCurrent = ( $row->rev_id == $row->page_latest ); + } + + // sanity check + if ( + $this->mPageId && $this->mTitle->exists() + && $this->mPageId !== $this->mTitle->getArticleID() + ) { + throw new InvalidArgumentException( + 'The given Title does not belong to page ID ' . $this->mPageId + ); + } + } + + /** + * MCR migration note: this replaces Revision::isCurrent + * + * @return bool + */ + public function isCurrent() { + return $this->mCurrent; + } + + /** + * MCR migration note: this replaces Revision::isDeleted + * + * @param int $field One of DELETED_* bitfield constants + * + * @return bool + */ + public function isDeleted( $field ) { + if ( $this->isCurrent() && $field === self::DELETED_TEXT ) { + // Current revisions of pages cannot have the content hidden. Skipping this + // check is very useful for Parser as it fetches templates using newKnownCurrent(). + // Calling getVisibility() in that case triggers a verification database query. + return false; // no need to check + } + + return parent::isDeleted( $field ); + } + + protected function userCan( $field, User $user ) { + if ( $this->isCurrent() && $field === self::DELETED_TEXT ) { + // Current revisions of pages cannot have the content hidden. Skipping this + // check is very useful for Parser as it fetches templates using newKnownCurrent(). + // Calling getVisibility() in that case triggers a verification database query. + return true; // no need to check + } + + return parent::userCan( $field, $user ); + } + + /** + * @return int The revision id, never null. + */ + public function getId() { + // overwritten just to add a guarantee to the contract + return parent::getId(); + } + + /** + * @return string The nominal revision size, never null. May be computed on the fly. + */ + public function getSize() { + // If length is null, calculate and remember it (potentially SLOW!). + // This is for compatibility with old database rows that don't have the field set. + if ( $this->mSize === null ) { + $this->mSize = $this->mSlots->computeSize(); + } + + return $this->mSize; + } + + /** + * @return string The revision hash, never null. May be computed on the fly. + */ + public function getSha1() { + // If hash is null, calculate it and remember (potentially SLOW!) + // This is for compatibility with old database rows that don't have the field set. + if ( $this->mSha1 === null ) { + $this->mSha1 = $this->mSlots->computeSha1(); + } + + return $this->mSha1; + } + + /** + * @param int $audience + * @param User|null $user + * + * @return UserIdentity The identity of the revision author, null if access is forbidden. + */ + public function getUser( $audience = self::FOR_PUBLIC, User $user = null ) { + // overwritten just to add a guarantee to the contract + return parent::getUser( $audience, $user ); + } + + /** + * @param int $audience + * @param User|null $user + * + * @return CommentStoreComment The revision comment, null if access is forbidden. + */ + public function getComment( $audience = self::FOR_PUBLIC, User $user = null ) { + // overwritten just to add a guarantee to the contract + return parent::getComment( $audience, $user ); + } + + /** + * @return string timestamp, never null + */ + public function getTimestamp() { + // overwritten just to add a guarantee to the contract + return parent::getTimestamp(); + } + +} diff --git a/includes/Storage/SlotRecord.php b/includes/Storage/SlotRecord.php new file mode 100644 index 0000000000..8769330d11 --- /dev/null +++ b/includes/Storage/SlotRecord.php @@ -0,0 +1,430 @@ +row; + + return new SlotRecord( $row, function () { + throw new SuppressedDataException( 'Content suppressed!' ); + } ); + } + + /** + * Constructs a new SlotRecord from an existing SlotRecord, overriding some fields. + * The slot's content cannot be overwritten. + * + * @param SlotRecord $slot + * @param array $overrides + * + * @return SlotRecord + */ + private static function newDerived( SlotRecord $slot, array $overrides = [] ) { + $row = $slot->row; + + foreach ( $overrides as $key => $value ) { + $row->$key = $value; + } + + return new SlotRecord( $row, $slot->content ); + } + + /** + * Constructs a new SlotRecord for a new revision, inheriting the content of the given SlotRecord + * of a previous revision. + * + * @param SlotRecord $slot + * + * @return SlotRecord + */ + public static function newInherited( SlotRecord $slot ) { + return self::newDerived( $slot, [ + 'slot_inherited' => true, + 'slot_revision' => null, + ] ); + } + + /** + * Constructs a new Slot from a Content object for a new revision. + * This is the preferred way to construct a slot for storing Content that + * resulted from a user edit. + * + * @param string $role + * @param Content $content + * @param bool $inherited + * + * @return SlotRecord + */ + public static function newUnsaved( $role, Content $content, $inherited = false ) { + Assert::parameterType( 'boolean', $inherited, '$inherited' ); + Assert::parameterType( 'string', $role, '$role' ); + + $row = [ + 'slot_id' => null, // not yet known + 'slot_address' => null, // not yet known. need setter? + 'slot_revision' => null, // not yet known + 'slot_inherited' => $inherited, + 'cont_size' => null, // compute later + 'cont_sha1' => null, // compute later + 'role_name' => $role, + 'model_name' => $content->getModel(), + ]; + + return new SlotRecord( (object)$row, $content ); + } + + /** + * Constructs a SlotRecord for a newly saved revision, based on the proto-slot that was + * supplied to the code that performed the save operation. This adds information that + * has only become available during saving, particularly the revision ID and blob address. + * + * @param int $revisionId + * @param string $blobAddress + * @param SlotRecord $protoSlot The proto-slot that was provided to the code that then + * + * @return SlotRecord + */ + public static function newSaved( $revisionId, $blobAddress, SlotRecord $protoSlot ) { + Assert::parameterType( 'integer', $revisionId, '$revisionId' ); + Assert::parameterType( 'string', $blobAddress, '$blobAddress' ); + + return self::newDerived( $protoSlot, [ + 'slot_revision' => $revisionId, + 'cont_address' => $blobAddress, + ] ); + } + + /** + * SlotRecord constructor. + * + * The following fields are supported by the $row parameter: + * + * $row->blob_data + * $row->blob_address + * + * @param object $row A database row composed of fields of the slot and content tables, + * as a raw object. Any field value can be a callback that produces the field value + * given this SlotRecord as a parameter. However, plain strings cannot be used as + * callbacks here, for security reasons. + * @param Content|callable $content The content object associated with the slot, or a + * callback that will return that Content object, given this SlotRecord as a parameter. + */ + public function __construct( $row, $content ) { + Assert::parameterType( 'object', $row, '$row' ); + Assert::parameterType( 'Content|callable', $content, '$content' ); + + $this->row = $row; + $this->content = $content; + } + + /** + * Implemented to defy serialization. + * + * @throws LogicException always + */ + public function __sleep() { + throw new LogicException( __CLASS__ . ' is not serializable.' ); + } + + /** + * Returns the Content of the given slot. + * + * @note This is free to load Content from whatever subsystem is necessary, + * performing potentially expensive operations and triggering I/O-related + * failure modes. + * + * @note This method does not apply audience filtering. + * + * @throws SuppressedDataException if access to the content is not allowed according + * to the audience check performed by RevisionRecord::getSlot(). + * + * @return Content The slot's content. This is a direct reference to the internal instance, + * copy before exposing to application logic! + */ + public function getContent() { + if ( $this->content instanceof Content ) { + return $this->content; + } + + $obj = call_user_func( $this->content, $this ); + + Assert::postcondition( + $obj instanceof Content, + 'Slot content callback should return a Content object' + ); + + $this->content = $obj; + + return $this->content; + } + + /** + * Returns the string value of a data field from the database row supplied to the constructor. + * If the field was set to a callback, that callback is invoked and the result returned. + * + * @param string $name + * + * @throws OutOfBoundsException + * @return mixed Returns the field's value, or null if the field is NULL in the DB row. + */ + private function getField( $name ) { + if ( !isset( $this->row->$name ) ) { + // distinguish between unknown and uninitialized fields + if ( property_exists( $this->row, $name ) ) { + throw new IncompleteRevisionException( 'Uninitialized field: ' . $name ); + } else { + throw new OutOfBoundsException( 'No such field: ' . $name ); + } + } + + $value = $this->row->$name; + + // NOTE: allow callbacks, but don't trust plain string callables from the database! + if ( !is_string( $value ) && is_callable( $value ) ) { + $value = call_user_func( $value, $this ); + $this->setField( $name, $value ); + } + + return $value; + } + + /** + * Returns the string value of a data field from the database row supplied to the constructor. + * + * @param string $name + * + * @throws OutOfBoundsException + * @throws IncompleteRevisionException + * @return string Returns the string value + */ + private function getStringField( $name ) { + return strval( $this->getField( $name ) ); + } + + /** + * Returns the int value of a data field from the database row supplied to the constructor. + * + * @param string $name + * + * @throws OutOfBoundsException + * @throws IncompleteRevisionException + * @return int Returns the int value + */ + private function getIntField( $name ) { + return intval( $this->getField( $name ) ); + } + + /** + * @param string $name + * @return bool whether this record contains the given field + */ + private function hasField( $name ) { + return isset( $this->row->$name ); + } + + /** + * Returns the ID of the revision this slot is associated with. + * + * @return int + */ + public function getRevision() { + return $this->getIntField( 'slot_revision' ); + } + + /** + * Whether this slot was inherited from an older revision. + * + * @return bool + */ + public function isInherited() { + return $this->getIntField( 'slot_inherited' ) !== 0; + } + + /** + * Whether this slot has an address. Slots will have an address if their + * content has been stored. While building a new revision, + * SlotRecords will not have an address associated. + * + * @return bool + */ + public function hasAddress() { + return $this->hasField( 'cont_address' ); + } + + /** + * Whether this slot has revision ID associated. Slots will have a revision ID associated + * only if they were loaded as part of an existing revision. While building a new revision, + * Slotrecords will not have a revision ID associated. + * + * @return bool + */ + public function hasRevision() { + return $this->hasField( 'slot_revision' ); + } + + /** + * Returns the role of the slot. + * + * @return string + */ + public function getRole() { + return $this->getStringField( 'role_name' ); + } + + /** + * Returns the address of this slot's content. + * This address can be used with BlobStore to load the Content object. + * + * @return string + */ + public function getAddress() { + return $this->getStringField( 'cont_address' ); + } + + /** + * Returns the content size + * + * @return int size of the content, in bogo-bytes, as reported by Content::getSize. + */ + public function getSize() { + try { + $size = $this->getIntField( 'cont_size' ); + } catch ( IncompleteRevisionException $ex ) { + $size = $this->getContent()->getSize(); + $this->setField( 'cont_size', $size ); + } + + return $size; + } + + /** + * Returns the content size + * + * @return string hash of the content. + */ + public function getSha1() { + try { + $sha1 = $this->getStringField( 'cont_sha1' ); + } catch ( IncompleteRevisionException $ex ) { + $format = $this->hasField( 'format_name' ) + ? $this->getStringField( 'format_name' ) + : null; + + $data = $this->getContent()->serialize( $format ); + $sha1 = self::base36Sha1( $data ); + $this->setField( 'cont_sha1', $sha1 ); + } + + return $sha1; + } + + /** + * Returns the content model. This is the model name that decides + * which ContentHandler is appropriate for interpreting the + * data of the blob referenced by the address returned by getAddress(). + * + * @return string the content model of the content + */ + public function getModel() { + try { + $model = $this->getStringField( 'model_name' ); + } catch ( IncompleteRevisionException $ex ) { + $model = $this->getContent()->getModel(); + $this->setField( 'model_name', $model ); + } + + return $model; + } + + /** + * Returns the blob serialization format as a MIME type. + * + * @note When this method returns null, the caller is expected + * to auto-detect the serialization format, or to rely on + * the default format associated with the content model. + * + * @return string|null + */ + public function getFormat() { + // XXX: we currently do not plan to store the format for each slot! + + if ( $this->hasField( 'format_name' ) ) { + return $this->getStringField( 'format_name' ); + } + + return null; + } + + /** + * @param string $name + * @param string|int|null $value + */ + private function setField( $name, $value ) { + $this->row->$name = $value; + } + + /** + * Get the base 36 SHA-1 value for a string of text + * + * MCR migration note: this replaces Revision::base36Sha1 + * + * @param string $blob + * @return string + */ + public static function base36Sha1( $blob ) { + return \Wikimedia\base_convert( sha1( $blob ), 16, 36, 31 ); + } + +} diff --git a/includes/Storage/SqlBlobStore.php b/includes/Storage/SqlBlobStore.php new file mode 100644 index 0000000000..0714633285 --- /dev/null +++ b/includes/Storage/SqlBlobStore.php @@ -0,0 +1,580 @@ +dbLoadBalancer = $dbLoadBalancer; + $this->cache = $cache; + $this->wikiId = $wikiId; + } + + /** + * @return int time for which blobs can be cached, in seconds + */ + public function getCacheExpiry() { + return $this->cacheExpiry; + } + + /** + * @param int $cacheExpiry time for which blobs can be cached, in seconds + */ + public function setCacheExpiry( $cacheExpiry ) { + Assert::parameterType( 'integer', $cacheExpiry, '$cacheExpiry' ); + + $this->cacheExpiry = $cacheExpiry; + } + + /** + * @return bool whether blobs should be compressed for storage + */ + public function getCompressBlobs() { + return $this->compressBlobs; + } + + /** + * @param bool $compressBlobs whether blobs should be compressed for storage + */ + public function setCompressBlobs( $compressBlobs ) { + $this->compressBlobs = $compressBlobs; + } + + /** + * @return false|string The legacy encoding to assume for blobs that are not marked as utf8. + * False means handling of legacy encoding is disabled, and utf8 assumed. + */ + public function getLegacyEncoding() { + return $this->legacyEncoding; + } + + /** + * @return Language|null The locale to use when decoding from a legacy encoding, or null + * if handling of legacy encoding is disabled. + */ + public function getLegacyEncodingConversionLang() { + return $this->legacyEncodingConversionLang; + } + + /** + * @param string $legacyEncoding The legacy encoding to assume for blobs that are + * not marked as utf8. + * @param Language $language The locale to use when decoding from a legacy encoding. + */ + public function setLegacyEncoding( $legacyEncoding, Language $language ) { + Assert::parameterType( 'string', $legacyEncoding, '$legacyEncoding' ); + + $this->legacyEncoding = $legacyEncoding; + $this->legacyEncodingConversionLang = $language; + } + + /** + * @return bool Whether to use the ExternalStore mechanism for storing blobs. + */ + public function getUseExternalStore() { + return $this->useExternalStore; + } + + /** + * @param bool $useExternalStore Whether to use the ExternalStore mechanism for storing blobs. + */ + public function setUseExternalStore( $useExternalStore ) { + Assert::parameterType( 'boolean', $useExternalStore, '$useExternalStore' ); + + $this->useExternalStore = $useExternalStore; + } + + /** + * @return LoadBalancer + */ + private function getDBLoadBalancer() { + return $this->dbLoadBalancer; + } + + /** + * @param int $index A database index, like DB_MASTER or DB_REPLICA + * + * @return IDatabase + */ + private function getDBConnection( $index ) { + $lb = $this->getDBLoadBalancer(); + return $lb->getConnection( $index, [], $this->wikiId ); + } + + /** + * Stores an arbitrary blob of data and returns an address that can be used with + * getBlob() to retrieve the same blob of data, + * + * @param string $data + * @param array $hints An array of hints. + * + * @throws BlobAccessException + * @return string an address that can be used with getBlob() to retrieve the data. + */ + public function storeBlob( $data, $hints = [] ) { + try { + $flags = $this->compressData( $data ); + + # Write to external storage if required + if ( $this->useExternalStore ) { + // Store and get the URL + $data = ExternalStore::insertToDefault( $data ); + if ( !$data ) { + throw new BlobAccessException( "Failed to store text to external storage" ); + } + if ( $flags ) { + $flags .= ','; + } + $flags .= 'external'; + + // TODO: we could also return an address for the external store directly here. + // That would mean bypassing the text table entirely when the external store is + // used. We'll need to assess expected fallout before doing that. + } + + $dbw = $this->getDBConnection( DB_REPLICA ); + + $old_id = $dbw->nextSequenceValue( 'text_old_id_seq' ); + $dbw->insert( + 'text', + [ + 'old_id' => $old_id, + 'old_text' => $data, + 'old_flags' => $flags, + ], + __METHOD__ + ); + + $textId = $dbw->insertId(); + + return 'tt:' . $textId; + } catch ( MWException $e ) { + throw new BlobAccessException( $e->getMessage(), 0, $e ); + } + } + + /** + * Retrieve a blob, given an address. + * Currently hardcoded to the 'text' table storage engine. + * + * MCR migration note: this replaces Revision::loadText + * + * @param string $blobAddress + * @param int $queryFlags + * + * @throws BlobAccessException + * @return string + */ + public function getBlob( $blobAddress, $queryFlags = 0 ) { + Assert::parameterType( 'string', $blobAddress, '$blobAddress' ); + + // No negative caching; negative hits on text rows may be due to corrupted replica DBs + $blob = $this->cache->getWithSetCallback( + // TODO: change key, since this is not necessarily revision text! + $this->cache->makeKey( 'revisiontext', 'textid', $blobAddress ), + $this->getCacheTTL(), + function () use ( $blobAddress, $queryFlags ) { + return $this->fetchBlob( $blobAddress, $queryFlags ); + }, + [ 'pcGroup' => self::TEXT_CACHE_GROUP, 'pcTTL' => IExpiringStore::TTL_PROC_LONG ] + ); + + if ( $blob === false ) { + throw new BlobAccessException( 'Failed to load blob from address ' . $blobAddress ); + } + + return $blob; + } + + /** + * MCR migration note: this corresponds to Revision::fetchText + * + * @param string $blobAddress + * @param int $queryFlags + * + * @throw BlobAccessException + * @return string|false + */ + private function fetchBlob( $blobAddress, $queryFlags ) { + list( $schema, $id, ) = self::splitBlobAddress( $blobAddress ); + + //TODO: MCR: also support 'ex' schema with ExternalStore URLs, plus flags encoded in the URL! + //TODO: MCR: also support 'ar' schema for content blobs in old style archive rows! + if ( $schema === 'tt' ) { + $textId = intval( $id ); + } else { + // XXX: change to better exceptions! That makes migration more difficult, though. + throw new BlobAccessException( "Unknown blob address schema: $schema" ); + } + + if ( !$textId || $id !== (string)$textId ) { + // XXX: change to better exceptions! That makes migration more difficult, though. + throw new BlobAccessException( "Bad blob address: $blobAddress" ); + } + + // Callers doing updates will pass in READ_LATEST as usual. Since the text/blob tables + // do not normally get rows changed around, set READ_LATEST_IMMUTABLE in those cases. + $queryFlags |= DBAccessObjectUtils::hasFlags( $queryFlags, self::READ_LATEST ) + ? self::READ_LATEST_IMMUTABLE + : 0; + + list( $index, $options, $fallbackIndex, $fallbackOptions ) = + DBAccessObjectUtils::getDBOptions( $queryFlags ); + + // Text data is immutable; check replica DBs first. + $row = $this->getDBConnection( $index )->selectRow( + 'text', + [ 'old_text', 'old_flags' ], + [ 'old_id' => $textId ], + __METHOD__, + $options + ); + + // Fallback to DB_MASTER in some cases if the row was not found, using the appropriate + // options, such as FOR UPDATE to avoid missing rows due to REPEATABLE-READ. + if ( !$row && $fallbackIndex !== null ) { + $row = $this->getDBConnection( $fallbackIndex )->selectRow( + 'text', + [ 'old_text', 'old_flags' ], + [ 'old_id' => $textId ], + __METHOD__, + $fallbackOptions + ); + } + + if ( !$row ) { + wfWarn( __METHOD__ . ": No text row with ID $textId." ); + return false; + } + + $blob = $this->expandBlob( $row->old_text, $row->old_flags, $blobAddress ); + + if ( $blob === false ) { + wfWarn( __METHOD__ . ": Bad data in text row $textId." ); + return false; + } + + return $blob; + } + + /** + * Expand a raw data blob according to the flags given. + * + * MCR migration note: this replaces Revision::getRevisionText + * + * @note direct use is deprecated, use getBlob() or SlotRecord::getContent() instead. + * @todo make this private, there should be no need to use this method outside this class. + * + * @param string $raw The raw blob data, to be processed according to $flags. + * May be the blob itself, or the blob compressed, or just the address + * of the actual blob, depending on $flags. + * @param string|string[] $flags Blob flags, such as 'external' or 'gzip'. + * @param string|null $cacheKey May be used for caching if given + * + * @return false|string The expanded blob or false on failure + */ + public function expandBlob( $raw, $flags, $cacheKey = null ) { + if ( is_string( $flags ) ) { + $flags = explode( ',', $flags ); + } + + // Use external methods for external objects, text in table is URL-only then + if ( in_array( 'external', $flags ) ) { + $url = $raw; + $parts = explode( '://', $url, 2 ); + if ( count( $parts ) == 1 || $parts[1] == '' ) { + return false; + } + + if ( $cacheKey ) { + // Make use of the wiki-local revision text cache. + // The cached value should be decompressed, so handle that and return here. + // NOTE: we rely on $this->cache being the right cache for $this->wikiId! + return $this->cache->getWithSetCallback( + // TODO: change key, since this is not necessarily revision text! + $this->cache->makeKey( 'revisiontext', 'textid', $cacheKey ), + $this->getCacheTTL(), + function () use ( $url, $flags ) { + // No negative caching per BlobStore::getBlob() + $blob = ExternalStore::fetchFromURL( $url, [ 'wiki' => $this->wikiId ] ); + + return $this->decompressData( $blob, $flags ); + }, + [ 'pcGroup' => self::TEXT_CACHE_GROUP, 'pcTTL' => WANObjectCache::TTL_PROC_LONG ] + ); + } else { + $blob = ExternalStore::fetchFromURL( $url, [ 'wiki' => $this->wikiId ] ); + return $this->decompressData( $blob, $flags ); + } + } else { + return $this->decompressData( $raw, $flags ); + } + } + + /** + * If $wgCompressRevisions is enabled, we will compress data. + * The input string is modified in place. + * Return value is the flags field: contains 'gzip' if the + * data is compressed, and 'utf-8' if we're saving in UTF-8 + * mode. + * + * MCR migration note: this replaces Revision::compressRevisionText + * + * @note direct use is deprecated! + * @todo make this private, there should be no need to use this method outside this class. + * + * @param mixed &$blob Reference to a text + * + * @return string + */ + public function compressData( &$blob ) { + $blobFlags = []; + + // Revisions not marked as UTF-8 will have legacy decoding applied by decompressData(). + // XXX: if $this->legacyEncoding is not set, we could skip this. May be risky, though. + $blobFlags[] = 'utf-8'; + + if ( $this->compressBlobs ) { + if ( function_exists( 'gzdeflate' ) ) { + $deflated = gzdeflate( $blob ); + + if ( $deflated === false ) { + wfLogWarning( __METHOD__ . ': gzdeflate() failed' ); + } else { + $blob = $deflated; + $blobFlags[] = 'gzip'; + } + } else { + wfDebug( __METHOD__ . " -- no zlib support, not compressing\n" ); + } + } + return implode( ',', $blobFlags ); + } + + /** + * Re-converts revision text according to its flags. + * + * MCR migration note: this replaces Revision::decompressRevisionText + * + * @note direct use is deprecated, use getBlob() or SlotRecord::getContent() instead. + * @todo make this private, there should be no need to use this method outside this class. + * + * @param mixed $blob Reference to a text + * @param array $blobFlags Compression flags + * + * @return string|bool Decompressed text, or false on failure + */ + public function decompressData( $blob, $blobFlags ) { + if ( $blob === false ) { + // Text failed to be fetched; nothing to do + return false; + } + + if ( in_array( 'gzip', $blobFlags ) ) { + # Deal with optional compression of archived pages. + # This can be done periodically via maintenance/compressOld.php, and + # as pages are saved if $wgCompressRevisions is set. + $blob = gzinflate( $blob ); + + if ( $blob === false ) { + wfLogWarning( __METHOD__ . ': gzinflate() failed' ); + return false; + } + } + + if ( in_array( 'object', $blobFlags ) ) { + # Generic compressed storage + $obj = unserialize( $blob ); + if ( !is_object( $obj ) ) { + // Invalid object + return false; + } + $blob = $obj->getText(); + } + + // Needed to support old revisions left over from from the 1.4 / 1.5 migration. + if ( $blob !== false && $this->legacyEncoding && $this->legacyEncodingConversionLang + && !in_array( 'utf-8', $blobFlags ) && !in_array( 'utf8', $blobFlags ) + ) { + # Old revisions kept around in a legacy encoding? + # Upconvert on demand. + # ("utf8" checked for compatibility with some broken + # conversion scripts 2008-12-30) + $blob = $this->legacyEncodingConversionLang->iconv( $this->legacyEncoding, 'UTF-8', $blob ); + } + + return $blob; + } + + /** + * Get the text cache TTL + * + * MCR migration note: this replaces Revision::getCacheTTL + * + * @return int + */ + private function getCacheTTL() { + if ( $this->cache->getQoS( WANObjectCache::ATTR_EMULATION ) + <= WANObjectCache::QOS_EMULATION_SQL + ) { + // Do not cache RDBMs blobs in...the RDBMs store + $ttl = WANObjectCache::TTL_UNCACHEABLE; + } else { + $ttl = $this->cacheExpiry ?: WANObjectCache::TTL_UNCACHEABLE; + } + + return $ttl; + } + + /** + * Returns an ID corresponding to the old_id field in the text table, corresponding + * to the given $address. + * + * Currently, $address must start with 'tt:' followed by a decimal integer representing + * the old_id; if $address does not start with 'tt:', null is returned. However, + * the implementation may change to insert rows into the text table on the fly. + * + * @note This method exists for use with the text table based storage schema. + * It should not be assumed that is will function with all future kinds of content addresses. + * + * @deprecated since 1.31, so not assume that all blob addresses refer to a row in the text + * table. This method should become private once the relevant refactoring in WikiPage is + * complete. + * + * @param string $address + * + * @return int|null + */ + public function getTextIdFromAddress( $address ) { + list( $schema, $id, ) = self::splitBlobAddress( $address ); + + if ( $schema !== 'tt' ) { + return null; + } + + $textId = intval( $id ); + + if ( !$textId || $id !== (string)$textId ) { + throw new InvalidArgumentException( "Malformed text_id: $id" ); + } + + return $textId; + } + + /** + * Splits a blob address into three parts: the schema, the ID, and parameters/flags. + * + * @param string $address + * + * @throws InvalidArgumentException + * @return array [ $schema, $id, $parameters ], with $parameters being an assoc array. + */ + private static function splitBlobAddress( $address ) { + if ( !preg_match( '/^(\w+):(\w+)(\?(.*))?$/', $address, $m ) ) { + throw new InvalidArgumentException( "Bad blob address: $address" ); + } + + $schema = strtolower( $m[1] ); + $id = $m[2]; + $parameters = isset( $m[4] ) ? wfCgiToArray( $m[4] ) : []; + + return [ $schema, $id, $parameters ]; + } + +} diff --git a/includes/Storage/SuppressedDataException.php b/includes/Storage/SuppressedDataException.php new file mode 100644 index 0000000000..24f16a6482 --- /dev/null +++ b/includes/Storage/SuppressedDataException.php @@ -0,0 +1,33 @@ +isFileMatch(); } + + if ( isset( $prop['extensiondata'] ) ) { + $extra = $result->getExtensionData(); + // Add augmented data to the result. The data would be organized as a map: + // augmentorName => data + if ( $extra ) { + $vals['extensiondata'] = ApiResult::addMetadataToResultVars( $extra ); + } + } + return $vals; } @@ -372,6 +382,7 @@ class ApiQuerySearch extends ApiQueryGeneratorBase { 'categorysnippet', 'score', // deprecated 'hasrelated', // deprecated + 'extensiondata', ], ApiBase::PARAM_ISMULTI => true, ApiBase::PARAM_HELP_MSG_PER_VALUE => [], diff --git a/includes/api/i18n/de.json b/includes/api/i18n/de.json index ba8d2f989d..85b65cf17c 100644 --- a/includes/api/i18n/de.json +++ b/includes/api/i18n/de.json @@ -838,6 +838,7 @@ "apihelp-query+search-param-prop": "Eigenschaften zur Rückgabe:", "apihelp-query+search-param-qiprofile": "Zu verwendendes anfrageunabhängiges Profil (wirkt sich auf den Ranking-Algorithmus aus).", "apihelp-query+search-paramvalue-prop-wordcount": "Ergänzt den Wortzähler der Seite.", + "apihelp-query+search-paramvalue-prop-extensiondata": "Ergänzt zusätzliche von Erweiterungen erzeugte Daten.", "apihelp-query+search-param-limit": "Wie viele Seiten insgesamt zurückgegeben werden sollen.", "apihelp-query+search-example-simple": "Nach meaning suchen.", "apihelp-query+search-example-text": "Texte nach meaning durchsuchen.", diff --git a/includes/api/i18n/en.json b/includes/api/i18n/en.json index 91c3e185b0..e1360c8ad8 100644 --- a/includes/api/i18n/en.json +++ b/includes/api/i18n/en.json @@ -1153,6 +1153,7 @@ "apihelp-query+search-paramvalue-prop-sectiontitle": "Adds the title of the matching section.", "apihelp-query+search-paramvalue-prop-categorysnippet": "Adds a parsed snippet of the matching category.", "apihelp-query+search-paramvalue-prop-isfilematch": "Adds a boolean indicating if the search matched file content.", + "apihelp-query+search-paramvalue-prop-extensiondata": "Adds extra data generated by extensions.", "apihelp-query+search-paramvalue-prop-score": "Ignored.", "apihelp-query+search-paramvalue-prop-hasrelated": "Ignored.", "apihelp-query+search-param-limit": "How many total pages to return.", diff --git a/includes/api/i18n/es.json b/includes/api/i18n/es.json index b84057ea6f..ef5f50d257 100644 --- a/includes/api/i18n/es.json +++ b/includes/api/i18n/es.json @@ -1069,6 +1069,7 @@ "apihelp-query+search-paramvalue-prop-sectiontitle": "Añade el título de la sección correspondiente.", "apihelp-query+search-paramvalue-prop-categorysnippet": "Añade un fragmento analizado de la categoría correspondiente.", "apihelp-query+search-paramvalue-prop-isfilematch": "Añade un booleano que indica si la búsqueda corresponde al contenido del archivo.", + "apihelp-query+search-paramvalue-prop-extensiondata": "Añade datos adicionales generados por las extensiones.", "apihelp-query+search-paramvalue-prop-score": "Ignorado.", "apihelp-query+search-paramvalue-prop-hasrelated": "Ignorado", "apihelp-query+search-param-limit": "Cuántas páginas en total se devolverán.", diff --git a/includes/api/i18n/fr.json b/includes/api/i18n/fr.json index a56b42f083..d9bf39c20f 100644 --- a/includes/api/i18n/fr.json +++ b/includes/api/i18n/fr.json @@ -28,7 +28,8 @@ "Pols12", "The RedBurn", "Umherirrender", - "Thibaut120094" + "Thibaut120094", + "KATRINE1992" ] }, "apihelp-main-extended-description": "
\n* [[mw:Special:MyLanguage/API:Main_page|Documentation]]\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Liste de diffusion]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Annonces de l’API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bogues et demandes]\n
\nÉtat : Toutes les fonctionnalités affichées sur cette page devraient fonctionner, mais l’API est encore en cours de développement et peut changer à tout moment. Inscrivez-vous à [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ la liste de diffusion mediawiki-api-announce] pour être informé des mises à jour.\n\nRequêtes erronées : Si des requêtes erronées sont envoyées à l’API, un entête HTTP sera renvoyé avec la clé « MediaWiki-API-Error ». La valeur de cet entête et le code d’erreur renvoyé prendront la même valeur. Pour plus d’information, voyez [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Errors and warnings]].\n\nTest : Pour faciliter le test des requêtes de l’API, voyez [[Special:ApiSandbox]].", @@ -1091,6 +1092,7 @@ "apihelp-query+search-paramvalue-prop-sectiontitle": "Ajoute le titre de la section correspondante.", "apihelp-query+search-paramvalue-prop-categorysnippet": "Ajoute un extrait analysé de la catégorie correspondante.", "apihelp-query+search-paramvalue-prop-isfilematch": "Ajoute un booléen indiquant si la recherche correspond au contenu du fichier.", + "apihelp-query+search-paramvalue-prop-extensiondata": "Va ajouter des données générées supplémentaires par extension.", "apihelp-query+search-paramvalue-prop-score": "Ignoré.", "apihelp-query+search-paramvalue-prop-hasrelated": "Ignoré.", "apihelp-query+search-param-limit": "Combien de pages renvoyer au total.", diff --git a/includes/api/i18n/nb.json b/includes/api/i18n/nb.json index f2ba86a44f..2a3bb6bdb1 100644 --- a/includes/api/i18n/nb.json +++ b/includes/api/i18n/nb.json @@ -66,6 +66,7 @@ "apihelp-compare-param-totitle": "Andre tittel å sammenligne.", "apihelp-compare-param-toid": "Andre side-ID å sammenligne.", "apihelp-compare-param-torev": "Andre revisjon å sammenligne.", + "apihelp-compare-param-torelative": "Bruk en revisjon som er relativ til revisjonen som hentes fra fromtitle, fromid eller fromrev. Alle de andre «to»-alternativene vil ignoreres.", "apihelp-compare-param-totext": "Bruk denne teksten i stedet for innholdet i revisjonen spesifisert av totitle, toid eller torev.", "apihelp-compare-param-topst": "Gjør en transformering av totext før lagring.", "apihelp-compare-param-tocontentmodel": "Innholdsmodellen til totext. Om denne ikke angis vil den bli gjettet ut fra andre parametere.", @@ -78,6 +79,7 @@ "apihelp-compare-paramvalue-prop-title": "Sidetitlene for «from»- og «to»-revisjonene.", "apihelp-compare-paramvalue-prop-user": "Brukernavnet og ID-en til «from»- og «to»-revisjonene.", "apihelp-compare-paramvalue-prop-comment": "Kommentaren til «from»- og «to»-revisjonene.", + "apihelp-compare-paramvalue-prop-parsedcomment": "Den parsede kommentaren til «from»- og «to»-revisjonene.", "apihelp-compare-paramvalue-prop-size": "Størrelsen til «from»- og «to»-revisjonene.", "apihelp-compare-example-1": "Lag en diff mellom revisjon 1 og 2.", "apihelp-createaccount-summary": "Opprett en ny brukerkonto.", @@ -126,20 +128,39 @@ "apihelp-edit-param-watch": "Legg til siden til aktuell brukers overvåkningsliste.", "apihelp-edit-param-unwatch": "Fjern siden fra aktuell brukers overvåkningsliste.", "apihelp-edit-param-prependtext": "Legg til denne teksten til starten av siden. Overstyrer $1text.", + "apihelp-edit-param-undo": "Fjern (gjør om) denne revisjonen. Overstyrer $1text, $1prependtext og $1appendtext.", + "apihelp-edit-param-undoafter": "Fjern alle revisjoner fra $1undo til denne. Om den ikke er satt, fjern kun én revisjon.", "apihelp-edit-param-redirect": "Bestem omdirigeringer automatisk.", "apihelp-edit-param-contentformat": "Innholdsserialiseringsformat brukt for inndatateksten.", "apihelp-edit-param-contentmodel": "Det nye innholdets innholdsmodell.", + "apihelp-edit-param-token": "Nøkkelen bør alltid sendes som siste parameter, eller i det minste etter parameteren $1text.", "apihelp-edit-example-edit": "Rediger en side.", + "apihelp-edit-example-prepend": "Legg til __NOTOC__ i begynnelsen av en side.", + "apihelp-edit-example-undo": "Fjerner revisjon 13579–13585 med automatisk redigeringsforklaring.", "apihelp-emailuser-summary": "Send e-post til en bruker.", "apihelp-emailuser-param-target": "Bruker som det skal sendes e-post til.", "apihelp-emailuser-param-subject": "Emne.", "apihelp-emailuser-param-text": "E-post innhold.", "apihelp-emailuser-param-ccme": "Send en kopi av denne e-posten til meg.", + "apihelp-emailuser-example-email": "Send en epost til brukeren WikiSysop med teksten Content.", "apihelp-expandtemplates-summary": "Ekspanderer alle maler i wikitekst.", "apihelp-expandtemplates-param-title": "Sidetittel.", "apihelp-expandtemplates-param-text": "Wikitekst som skal konverteres.", + "apihelp-expandtemplates-param-revid": "Revisjons-ID, for {{REVISIONID}} og lignende variabler.", + "apihelp-expandtemplates-param-prop": "Hvilken informasjon som skal hentes.\n\nMerk at om ingen verdier velges vil resultatet inneholde wikiteksten, men utdataene vil komme i et utdatert format.", "apihelp-expandtemplates-paramvalue-prop-wikitext": "Den utvidede wikiteksten.", "apihelp-expandtemplates-paramvalue-prop-categories": "Kategorier som er tilstede i innputt som ikke representeres i utputt.", + "apihelp-expandtemplates-paramvalue-prop-properties": "Sideegenskaper definert av utvidede magiske ord i wikiteksten.", + "apihelp-expandtemplates-paramvalue-prop-volatile": "Hvorvidt utdataene er ustabile og ikke burde gjenbrukes andre steder på siden.", + "apihelp-expandtemplates-paramvalue-prop-ttl": "Maksimal tid som skal ha gått før mellomlagrede resultater skal ugyldiggjøres.", + "apihelp-expandtemplates-paramvalue-prop-jsconfigvars": "Gir JavaScript-konfigurasjonsvariabler som er spesifikke for siden.", + "apihelp-expandtemplates-paramvalue-prop-encodedjsconfigvars": "Gir JavaScript-konfigurasjonsvariabler som er spesifikke for siden som en JSON-streng.", + "apihelp-expandtemplates-param-includecomments": "Hvorvidt HTML-kommentarer skal inkluderes i utdataene.", + "apihelp-expandtemplates-example-simple": "Utvid wikiteksten {{Project:Sandbox}}.", + "apihelp-feedcontributions-summary": "Returnerer en mating med brukerbidrag.", + "apihelp-feedcontributions-param-feedformat": "Matingens format.", + "apihelp-feedcontributions-param-user": "Hvilke brukere det skal hentes bidrag av.", + "apihelp-feedcontributions-param-namespace": "Hvilke navnerom bidragene skal filtreres med.", "apihelp-feedcontributions-param-year": "Fra år (og tidligere).", "apihelp-feedcontributions-param-month": "Fra måned (og tidligere).", "apihelp-feedcontributions-param-tagfilter": "Filtrer bidrag som har disse merkene.", @@ -149,6 +170,7 @@ "apihelp-feedcontributions-param-hideminor": "Skjul mindre endringer.", "apihelp-feedcontributions-param-showsizediff": "Vis størrelsesforskjellen mellom revisjoner.", "apihelp-feedcontributions-example-simple": "Returner bidrag for brukeren Example.", + "apihelp-feedrecentchanges-summary": "Returnerer en mating med siste endringer.", "apihelp-feedrecentchanges-param-feedformat": "Matingens format.", "apihelp-feedrecentchanges-param-namespace": "Navnerom resultater skal begrenses til.", "apihelp-feedrecentchanges-param-invert": "Alle navnerom utenom det valgte.", @@ -172,6 +194,9 @@ "apihelp-feedrecentchanges-example-30days": "Vis siste endringer for 30 døgn.", "apihelp-feedwatchlist-summary": "Returnerer en overvåkningslistemating.", "apihelp-feedwatchlist-param-feedformat": "Matingens format.", + "apihelp-feedwatchlist-param-linktosections": "Lenk direkte til endrede seksjoner om mulig.", + "apihelp-feedwatchlist-example-default": "Vis matingen til overvåkningslisten.", + "apihelp-feedwatchlist-example-all6hrs": "Vis alle endringer på overvåkede sider de siste 6 timene.", "apihelp-filerevert-summary": "Tilbakestill en fil til en gammel versjon.", "apihelp-filerevert-param-filename": "Målfilnavn, uten prefikset File:.", "apihelp-filerevert-param-comment": "Opplastingskommentar.", @@ -197,6 +222,7 @@ "apihelp-import-extended-description": "Merk at HTTP POST må gjøres som filopplasting (altså med bruk av multipart/form-data) når man sender en fil for parameteren xml.", "apihelp-import-param-summary": "Sammendrag for importering av loggelement.", "apihelp-import-param-xml": "Opplastet XML-fil.", + "apihelp-import-param-assignknownusers": "Tildel redigeringer til lokale brukere der den navngitte brukeren finnes lokalt.", "apihelp-import-param-interwikisource": "For interwikiimport: wiki det skal importeres fra.", "apihelp-import-param-interwikipage": "For interwikiimport: side som skal importeres.", "apihelp-import-param-fullhistory": "For interwikiimport: importer hele historikken, ikke bare den nåværende versjonen.", @@ -205,6 +231,9 @@ "apihelp-import-param-rootpage": "Importer som underside av denne siden. Kan ikke brukes sammen med $1namespace.", "apihelp-import-param-tags": "Endringstagger som skal klistres på oppføringen i importloggen og nullrevisjonen til de importerte sidene.", "apihelp-import-example-import": "Importer [[meta:Help:ParserFunctions]] til navnerom 100 med full historikk.", + "apihelp-linkaccount-summary": "Lenk en konto fra en tredjepartsleverandør til den gjeldende brukeren.", + "apihelp-linkaccount-example-link": "Start prosessen med å lenke til en konto fra Example.", + "apihelp-login-summary": "Logg inn og få autentiseringsinformasjonskapsler.", "apihelp-login-param-name": "Brukernavn.", "apihelp-login-param-password": "Passord.", "apihelp-login-param-domain": "Domene (valgfritt).", diff --git a/includes/api/i18n/pt.json b/includes/api/i18n/pt.json index b85ddc96b1..bbc83c065c 100644 --- a/includes/api/i18n/pt.json +++ b/includes/api/i18n/pt.json @@ -1070,6 +1070,7 @@ "apihelp-query+search-paramvalue-prop-sectiontitle": "Adiciona o título da secção correspondente.", "apihelp-query+search-paramvalue-prop-categorysnippet": "Adiciona um fragmento de código com a categoria correspondente, após análise sintática.", "apihelp-query+search-paramvalue-prop-isfilematch": "Adiciona um valor booleano que indica se a pesquisa encontrou correspondência no conteúdo de ficheiros.", + "apihelp-query+search-paramvalue-prop-extensiondata": "Acrescenta dados adicionais gerados por extensões.", "apihelp-query+search-paramvalue-prop-score": "Ignorado.", "apihelp-query+search-paramvalue-prop-hasrelated": "Ignorado.", "apihelp-query+search-param-limit": "O número total de páginas a serem devolvidas.", diff --git a/includes/api/i18n/qqq.json b/includes/api/i18n/qqq.json index 47afdc12b9..1724fa905b 100644 --- a/includes/api/i18n/qqq.json +++ b/includes/api/i18n/qqq.json @@ -1077,6 +1077,7 @@ "apihelp-query+search-paramvalue-prop-sectiontitle": "{{doc-apihelp-paramvalue|query+search|prop|sectiontitle}}", "apihelp-query+search-paramvalue-prop-categorysnippet": "{{doc-apihelp-paramvalue|query+search|prop|categorysnippet}}", "apihelp-query+search-paramvalue-prop-isfilematch": "{{doc-apihelp-paramvalue|query+search|prop|isfilematch}}", + "apihelp-query+search-paramvalue-prop-extensiondata": "{{doc-apihelp-paramvalue|query+search|prop|extensiondata}}", "apihelp-query+search-paramvalue-prop-score": "{{doc-apihelp-paramvalue|query+search|prop|score}}\n{{Identical|Ignored}}", "apihelp-query+search-paramvalue-prop-hasrelated": "{{doc-apihelp-paramvalue|query+search|prop|hasrelated}}\n{{Identical|Ignored}}", "apihelp-query+search-param-limit": "{{doc-apihelp-param|query+search|limit}}", diff --git a/includes/api/i18n/zh-hans.json b/includes/api/i18n/zh-hans.json index 2fb6178b5d..3f159165d7 100644 --- a/includes/api/i18n/zh-hans.json +++ b/includes/api/i18n/zh-hans.json @@ -1086,6 +1086,7 @@ "apihelp-query+search-paramvalue-prop-sectiontitle": "添加匹配章节的标题。", "apihelp-query+search-paramvalue-prop-categorysnippet": "添加已解析的匹配分类片段。", "apihelp-query+search-paramvalue-prop-isfilematch": "添加布尔值,表明搜索是否匹配文件内容。", + "apihelp-query+search-paramvalue-prop-extensiondata": "添加由扩展生成的额外数据。", "apihelp-query+search-paramvalue-prop-score": "已忽略。", "apihelp-query+search-paramvalue-prop-hasrelated": "已忽略。", "apihelp-query+search-param-limit": "返回的总计页面数。", diff --git a/includes/editpage/TextConflictHelper.php b/includes/editpage/TextConflictHelper.php index b1eaa4be9b..6e7e7ee6ee 100644 --- a/includes/editpage/TextConflictHelper.php +++ b/includes/editpage/TextConflictHelper.php @@ -140,6 +140,15 @@ class TextConflictHelper { */ public function incrementResolvedStats() { $this->stats->increment( 'edit.failures.conflict.resolved' ); + // Only include 'standard' namespaces to avoid creating unknown numbers of statsd metrics + if ( + $this->title->getNamespace() >= NS_MAIN && + $this->title->getNamespace() <= NS_CATEGORY_TALK + ) { + $this->stats->increment( + 'edit.failures.conflict.resolved.byNamespaceId.' . $this->title->getNamespace() + ); + } } /** diff --git a/includes/export/ExportProgressFilter.php b/includes/export/ExportProgressFilter.php new file mode 100644 index 0000000000..9b1571f7de --- /dev/null +++ b/includes/export/ExportProgressFilter.php @@ -0,0 +1,47 @@ + + * https://www.mediawiki.org/ + * + * 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 + */ + +/** + * @ingroup Dump + */ +class ExportProgressFilter extends DumpFilter { + /** + * @var BackupDumper + */ + private $progress; + + function __construct( &$sink, &$progress ) { + parent::__construct( $sink ); + $this->progress = $progress; + } + + function writeClosePage( $string ) { + parent::writeClosePage( $string ); + $this->progress->reportPage(); + } + + function writeRevision( $rev, $string ) { + parent::writeRevision( $rev, $string ); + $this->progress->revCount(); + } +} diff --git a/includes/installer/PostgresUpdater.php b/includes/installer/PostgresUpdater.php index c38eb6aabc..393c2e1641 100644 --- a/includes/installer/PostgresUpdater.php +++ b/includes/installer/PostgresUpdater.php @@ -657,6 +657,13 @@ END; } } + protected function dropSequence( $table, $ns ) { + if ( $this->db->sequenceExists( $ns ) ) { + $this->output( "Dropping sequence $ns\n" ); + $this->db->query( "DROP SEQUENCE $ns CASCADE" ); + } + } + protected function renameSequence( $old, $new ) { if ( $this->db->sequenceExists( $new ) ) { $this->output( "...sequence $new already exists.\n" ); diff --git a/includes/jobqueue/jobs/EnqueueJob.php b/includes/jobqueue/jobs/EnqueueJob.php index 5ffb01b45a..ea7a8d7801 100644 --- a/includes/jobqueue/jobs/EnqueueJob.php +++ b/includes/jobqueue/jobs/EnqueueJob.php @@ -24,11 +24,10 @@ /** * Router job that takes jobs and enqueues them to their proper queues * - * This can be used for several things: - * - a) Making multi-job enqueues more robust by atomically enqueueing - * a single job that pushes the actual jobs (with retry logic) - * - b) Masking the latency of pushing jobs to different queues/wikis - * - c) Low-latency enqueues to push jobs from warm to hot datacenters + * This can be used for getting sets of multiple jobs or sets of jobs intended for multiple + * queues to be inserted more robustly. This is a single job that, upon running, enqueues the + * wrapped jobs. If some of those fail to enqueue then the EnqueueJob will be retried. Due to + * the possibility of duplicate enqueues, the wrapped jobs should be idempotent. * * @ingroup JobQueue * @since 1.25 diff --git a/includes/registration/ExtensionProcessor.php b/includes/registration/ExtensionProcessor.php index 5dc0b400fb..fe617c54bb 100644 --- a/includes/registration/ExtensionProcessor.php +++ b/includes/registration/ExtensionProcessor.php @@ -378,9 +378,10 @@ class ExtensionProcessor implements Processor { protected function extractExtensionMessagesFiles( $dir, array $info ) { if ( isset( $info['ExtensionMessagesFiles'] ) ) { - $this->globals["wgExtensionMessagesFiles"] += array_map( function ( $file ) use ( $dir ) { - return "$dir/$file"; - }, $info['ExtensionMessagesFiles'] ); + foreach ( $info['ExtensionMessagesFiles'] as &$file ) { + $file = "$dir/$file"; + } + $this->globals["wgExtensionMessagesFiles"] += $info['ExtensionMessagesFiles']; } } diff --git a/includes/registration/ExtensionRegistry.php b/includes/registration/ExtensionRegistry.php index bc2f8e47d3..994de9726f 100644 --- a/includes/registration/ExtensionRegistry.php +++ b/includes/registration/ExtensionRegistry.php @@ -413,13 +413,14 @@ class ExtensionRegistry { * Fully expand autoloader paths * * @param string $dir - * @param array $info + * @param array $files * @return array */ - protected function processAutoLoader( $dir, array $info ) { + protected function processAutoLoader( $dir, array $files ) { // Make paths absolute, relative to the JSON file - return array_map( function ( $file ) use ( $dir ) { - return "$dir/$file"; - }, $info ); + foreach ( $files as &$file ) { + $file = "$dir/$file"; + } + return $files; } } diff --git a/includes/skins/SkinTemplate.php b/includes/skins/SkinTemplate.php index 532ee518a5..badd7a2ead 100644 --- a/includes/skins/SkinTemplate.php +++ b/includes/skins/SkinTemplate.php @@ -524,15 +524,48 @@ class SkinTemplate extends Skin { * @return string */ public function getPersonalToolsList() { + return $this->makePersonalToolsList(); + } + + /** + * Get the HTML for the personal tools list + * + * @since 1.31 + * + * @param array $personalTools + * @param array $options + * @return string + */ + public function makePersonalToolsList( $personalTools = null, $options = [] ) { $tpl = $this->setupTemplateForOutput(); $tpl->set( 'personal_urls', $this->buildPersonalUrls() ); $html = ''; - foreach ( $tpl->getPersonalTools() as $key => $item ) { - $html .= $tpl->makeListItem( $key, $item ); + + if ( $personalTools === null ) { + $personalTools = $tpl->getPersonalTools(); + } + + foreach ( $personalTools as $key => $item ) { + $html .= $tpl->makeListItem( $key, $item, $options ); } + return $html; } + /** + * Get personal tools for the user + * + * @since 1.31 + * + * @return array Array of personal tools + */ + public function getStructuredPersonalTools() { + $tpl = $this->setupTemplateForOutput(); + $tpl->set( 'personal_urls', $this->buildPersonalUrls() ); + + return $tpl->getPersonalTools(); + } + /** * Format language name for use in sidebar interlanguage links list. * By default it is capitalized. diff --git a/includes/specials/SpecialWatchlist.php b/includes/specials/SpecialWatchlist.php index e8e828df08..2ad70a67a8 100644 --- a/includes/specials/SpecialWatchlist.php +++ b/includes/specials/SpecialWatchlist.php @@ -117,11 +117,6 @@ class SpecialWatchlist extends ChangesListSpecialPage { ); } - public function isStructuredFilterUiEnabledByDefault() { - return $this->getConfig()->get( 'StructuredChangeFiltersOnWatchlist' ) && - $this->getUser()->getDefaultOption( 'rcenhancedfilters' ); - } - /** * Return an array of subpages that this special page will accept. * diff --git a/includes/templates/AtomHeader.mustache b/includes/templates/AtomHeader.mustache new file mode 100644 index 0000000000..60ab75e3d7 --- /dev/null +++ b/includes/templates/AtomHeader.mustache @@ -0,0 +1,8 @@ + + {{{feedID}}} + {{{title}}} + + + {{{timestamp}}}Z + {{{description}}} + MediaWiki {{{version}}} diff --git a/includes/templates/AtomItem.mustache b/includes/templates/AtomItem.mustache new file mode 100644 index 0000000000..32d2f01d66 --- /dev/null +++ b/includes/templates/AtomItem.mustache @@ -0,0 +1,10 @@ + + {{{uniqueID}}} + {{{title}}} + + {{#date}}{{{.}}}Z{{/date}} + + {{{description}}} + {{#author}}{{{.}}}{{/author}} + {{! FIXME: Need to add comments }} + diff --git a/includes/templates/RSSHeader.mustache b/includes/templates/RSSHeader.mustache new file mode 100644 index 0000000000..385369dfc2 --- /dev/null +++ b/includes/templates/RSSHeader.mustache @@ -0,0 +1,8 @@ + + + {{{title}}} + {{{url}}} + {{{description}}} + {{{language}}} + MediaWiki {{{version}}} + {{{timestamp}}} diff --git a/includes/templates/RSSItem.mustache b/includes/templates/RSSItem.mustache new file mode 100644 index 0000000000..d00c100600 --- /dev/null +++ b/includes/templates/RSSItem.mustache @@ -0,0 +1,9 @@ + + {{{title}}} + {{{url}}} + {{{uniqueID}}} + {{{description}}} + {{#date}}{{{.}}}{{/date}} + {{#author}}{{{.}}}{{/author}} + {{#comments}}{{{.}}}{{/comments}} + diff --git a/includes/user/UserIdentityValue.php b/includes/user/UserIdentityValue.php new file mode 100644 index 0000000000..e728264a3f --- /dev/null +++ b/includes/user/UserIdentityValue.php @@ -0,0 +1,70 @@ +id = $id; + $this->name = $name; + } + + /** + * @return int The user ID. May be 0 for anonymous users or for users with no local account. + */ + public function getId() { + return $this->id; + } + + /** + * @return string The user's logical name. May be an IPv4 or IPv6 address for anonymous users. + */ + public function getName() { + return $this->name; + } + +} diff --git a/languages/i18n/af.json b/languages/i18n/af.json index b28deeb2af..7751439169 100644 --- a/languages/i18n/af.json +++ b/languages/i18n/af.json @@ -570,7 +570,7 @@ "newarticle": "(Nuut)", "newarticletext": "Hierdie bladsy bestaan nie.\nTik iets in die invoerboks hier onder om 'n nuwe bladsy te skep. Meer inligting is op die [$1 hulpbladsy] beskikbaar.\nAs u per ongeluk hier uitgekom het, gebruik u blaaier se '''terug'''-knoppie.", "anontalkpagetext": "----\nHierdie is die besprekingsblad vir 'n anonieme gebruiker wat nog nie 'n rekening geskep het nie, of wat dit nie gebruik nie.\nDaarom moet ons sy/haar numeriese IP-adres vir identifikasie gebruik.\nSó 'n adres kan deur verskeie gebruikers gedeel word.\nIndien u 'n anonieme gebruiker is wat voel dat ontoepaslike kommentaar teen u gerig is, [[Special:CreateAccount|skep gerus 'n rekening]] of [[Special:UserLogin|meld aan]] om verwarring met ander anonieme gebruikers te voorkom.", - "noarticletext": "Hierdie bladsy bevat geen teks nie.\nU kan [[Special:Search/{{PAGENAME}}|vir die bladsytitel in ander bladsye soek]],\n[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} die verwante logboeke deursoek]\nof [{{fullurl:{{FULLPAGENAME}}|action=edit}} hierdie bladsy wysig].", + "noarticletext": "Hierdie bladsy bevat geen teks nie.\nU kan [[Special:Search/{{PAGENAME}}|vir die bladsytitel in ander bladsye soek]],\n[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} die verwante logboeke deursoek]\nof [{{fullurl:{{FULLPAGENAME}}|action=edit}} hierdie bladsy skep].", "noarticletext-nopermission": "Hierdie bladsy bevat geen teks nie.\nU kan vir die term [[Special:Search/{{PAGENAME}}|in ander bladsye soek]] of\n[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} die verwante logboeke deursoek], maar u kan nie die bladsy skep nie.", "missing-revision": "Die weergawe #$1 van die bladsy \"{{FULLPAGENAME}} bestaan nie.\n\nDit word meestal veroorsaak deur die volg van 'n verouderde verwysing na 'n bladsy wat verwyder is.\nMeer gegewens kan moontlik in die [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} skraplogboek] gevind word.", "userpage-userdoesnotexist": "U is besig om 'n gebruikersblad wat nie bestaan nie te wysig (gebruiker \"$1\"). Maak asseblief seker of u die bladsy wil skep/ wysig.", @@ -1116,13 +1116,15 @@ "rcfilters-activefilters": "Aktiewe filters", "rcfilters-advancedfilters": "Gevorderde filters", "rcfilters-limit-title": "Wysigings om te wys", + "rcfilters-limit-and-date-label": "{{PLURAL:$1|wysiging|$1 wysigings}}, $2", + "rcfilters-date-popup-title": "Tydperk om te deursoek", "rcfilters-days-title": "Afgelope dae", "rcfilters-hours-title": "Afgelope ure", "rcfilters-days-show-days": "$1 {{PLURAL:$1|dag|dae}}", "rcfilters-days-show-hours": "$1 {{PLURAL:$1|uur|ure}}", "rcfilters-highlighted-filters-list": "Bekleurklem: $1", "rcfilters-quickfilters": "Gestoorde filters", - "rcfilters-quickfilters-placeholder-title": "Geen gestoorde skakels", + "rcfilters-quickfilters-placeholder-title": "Geen gestoorde filters", "rcfilters-quickfilters-placeholder-description": "Om instellings te stoor en later weer te gebruik, kliek op die bladwyser-piktogram in die Aktiewe Filter-gebied onder.", "rcfilters-savedqueries-defaultlabel": "Gestoorde filters", "rcfilters-savedqueries-rename": "Hernoem", @@ -1138,7 +1140,7 @@ "rcfilters-restore-default-filters": "Stel filters terug", "rcfilters-clear-all-filters": "Verwyder alle filters", "rcfilters-show-new-changes": "Wys nuutste wysigings", - "rcfilters-search-placeholder": "Filter onlangse wysigings (blaai of begin tik)", + "rcfilters-search-placeholder": "Filter wysigings (blaai of begin tik)", "rcfilters-invalid-filter": "Ongeldig filter", "rcfilters-empty-filter": "Geen aktiewe filters. Alle wysigings word gewys.", "rcfilters-filterlist-title": "Filters", @@ -1188,6 +1190,9 @@ "rcfilters-filter-watchlist-notwatched-label": "Nie in dophoulys", "rcfilters-filter-watchlist-notwatched-description": "Alles behalwe wysigings aan bladsye op u dophoulys.", "rcfilters-filtergroup-watchlistactivity": "Dophoulys-bedrywighede", + "rcfilters-filter-watchlistactivity-unseen-label": "Nie-besigtigde wysigings", + "rcfilters-filter-watchlistactivity-unseen-description": "Wysigings aan blaaie wat u nog nie sedert die wysiging besoek het nie.", + "rcfilters-filter-watchlistactivity-seen-description": "Wysigings aan blaaie wat u reeds sedert die wysiging besoek het.", "rcfilters-filtergroup-changetype": "Soort wysiging", "rcfilters-filter-pageedits-label": "Bladsywysigings", "rcfilters-filter-pageedits-description": "Wysigings aan wiki-inhoud, besprekings en kategoriebeskrywings…", @@ -1208,6 +1213,8 @@ "rcfilters-view-tags": "Geëtiketteerde wysigings", "rcfilters-view-namespaces-tooltip": "Filtreer resultate volgens naamruimte", "rcfilters-view-tags-tooltip": "Filter resultate volgens wysigingsetikette", + "rcfilters-liveupdates-button": "Monitor bywerkings", + "rcfilters-liveupdates-button-title-off": "Wys nuwe wysigings soos hulle inrol.", "rcfilters-preference-label": "Versteek die verbeter weergawe van 'Onlangse wysigings'", "rcnotefrom": "{{PLURAL:$5|Wysiging|Wysigings}} sedert $3 om $4 (maksimum van $1 word gewys).", "rclistfrom": "Vertoon wysigings vanaf $3 $2", diff --git a/languages/i18n/ar.json b/languages/i18n/ar.json index 55fb3385b8..0dbc1e4e24 100644 --- a/languages/i18n/ar.json +++ b/languages/i18n/ar.json @@ -1069,6 +1069,7 @@ "timezoneregion-indian": "المحيط الهندي", "timezoneregion-pacific": "المحيط الهادي", "allowemail": "اسمح للمستخدمين الآخرين بإرسال بريد إلكتروني إلي", + "email-allow-new-users-label": "اسمح بالبريد الإلكتروني من المستخدمين الجدد تمامًا", "email-blacklist-label": "امنع هؤلاء المستخدمين من إرسال بريد إلكتروني لي:", "prefs-searchoptions": "البحث", "prefs-namespaces": "أسماء النطاقات", @@ -1472,7 +1473,7 @@ "rcfilters-preference-label": "أخف النسخة المحسنة من أحدث التغييرات", "rcfilters-preference-help": "يسترجع عملية إعادة تصميم الواجهة لعام 2017 وكل الأدوات التي أضيفت منذ ذلك الوقت.", "rcfilters-filter-showlinkedfrom-label": "عرض التغييرات في الصفحات الموصولة من", - "rcfilters-filter-showlinkedfrom-option-label": "اظهر التغييرات في الصفحات المرتبطة من صفحة", + "rcfilters-filter-showlinkedfrom-option-label": "أظهر التغييرات في الصفحات المرتبطة من صفحة", "rcfilters-filter-showlinkedto-label": "أظهر التغييرات في الصفحات الموصولة بصفحة", "rcfilters-filter-showlinkedto-option-label": "اظهر التغييرات في الصفحات المرتبطة إلى الصفحة", "rcfilters-target-page-placeholder": "أدخل اسم صفحة", diff --git a/languages/i18n/be-tarask.json b/languages/i18n/be-tarask.json index 9caecd44bb..73efb809ce 100644 --- a/languages/i18n/be-tarask.json +++ b/languages/i18n/be-tarask.json @@ -1013,6 +1013,7 @@ "timezoneregion-indian": "Індыйскі акіян", "timezoneregion-pacific": "Ціхі акіян", "allowemail": "Дазволіць іншым удзельнікам і ўдзельніцам дасылаць мне лісты электроннай поштай", + "email-allow-new-users-label": "Дазволіць лісты электроннай пошты ад зусім новых удзельнікаў", "email-blacklist-label": "Забараніць гэтым удзельнікам дасылаць мне лісты электроннай поштай:", "prefs-searchoptions": "Пошук", "prefs-namespaces": "Прасторы назваў", @@ -1673,6 +1674,7 @@ "uploadstash-file-too-large": "Немагчыма апрацаваць файл памерам большым за $1 байтаў.", "uploadstash-not-logged-in": "Удзельнік не ўвайшоў у сыстэму, файлы мусяць належаць удзельнікам.", "uploadstash-wrong-owner": "Гэты файл ($1) не належыць цяперашняму ўдзельніку.", + "uploadstash-no-such-key": "Няма такога ключа ($1), немагчыма выдаліць.", "invalid-chunk-offset": "Няслушнае зрушэньне фрагмэнту", "img-auth-accessdenied": "Доступ забаронены", "img-auth-nopathinfo": "Адсутнічае PATH_INFO.\nВаш сэрвэр не ўстаноўлены на пропуск гэтай інфармацыі.\nМагчма, ён працуе праз CGI і не падтрымлівае img_auth.\nГлядзіце https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Image_Authorization.", diff --git a/languages/i18n/bg.json b/languages/i18n/bg.json index e3d1c4b392..a4a26231a3 100644 --- a/languages/i18n/bg.json +++ b/languages/i18n/bg.json @@ -1518,7 +1518,7 @@ "upload-file-error": "Вътрешна грешка", "upload-file-error-text": "Вътрешна грешка при опит за създаване на временен файл на сървъра.\nОбърнете се към [[Special:ListUsers/sysop|администратор]].", "upload-misc-error": "Неизвестна грешка при качване", - "upload-misc-error-text": "Неизвестна грешка при качване. Убедете се, че адресът е верен и опитайте отново. Ако отново имате проблем, обърнете се към [[Special:ListUsers/sysop|администратор]].", + "upload-misc-error-text": "Неизвестна грешка при качване.\nУбедете се, че адресът е верен и опитайте отново.\nАко отново имате проблем, обърнете се към [[Special:ListUsers/sysop|администратор]].", "upload-too-many-redirects": "Адресът съдържа твърде много пренасочвания", "upload-http-error": "Възникна HTTP грешка: $1", "upload-dialog-title": "Качване на файл", diff --git a/languages/i18n/ce.json b/languages/i18n/ce.json index 2577a3063d..21c5df1f5f 100644 --- a/languages/i18n/ce.json +++ b/languages/i18n/ce.json @@ -1166,7 +1166,7 @@ "rcfilters-advancedfilters": "Шуьйра литтарш", "rcfilters-limit-title": "Гойту хийцамаш", "rcfilters-limit-and-date-label": "$1 {{PLURAL:$1|хийцам}}, $2", - "rcfilters-date-popup-title": "Лахарна хен", + "rcfilters-date-popup-title": "Лахарна хан", "rcfilters-days-title": "ТӀеххьара денош", "rcfilters-hours-title": "ТӀеххьара сахьташ", "rcfilters-days-show-days": "$1 {{PLURAL:$1|де}}", @@ -2981,7 +2981,7 @@ "feedback-submit": "Дахьийта", "feedback-thanks-title": "Баркалла!", "feedback-useragent": "Браузер:", - "searchsuggest-search": "Лаха {{grammar:prepositional|{{SITENAME}}}}", + "searchsuggest-search": "Лахар", "searchsuggest-containing": "чуьраниг…", "api-error-publishfailed": "Чоьхьара гӀалат: серверна хана йолу файл Ӏалашъян цаелира.", "api-error-stashfailed": "Чоьхьара гӀалат: серверна хана йолу файл Ӏалашъян цаелира.", diff --git a/languages/i18n/es.json b/languages/i18n/es.json index 969f08ae14..64d134bb19 100644 --- a/languages/i18n/es.json +++ b/languages/i18n/es.json @@ -1164,6 +1164,7 @@ "timezoneregion-indian": "Océano Índico", "timezoneregion-pacific": "Océano Pacífico", "allowemail": "Permitir que otros usuarios me envíen mensajes de correo", + "email-allow-new-users-label": "Permitir mensajes de correo de usuarios nuevos", "email-blacklist-label": "Prohibir a estos usuarios enviarme mensajes de correo:", "prefs-searchoptions": "Buscar", "prefs-namespaces": "Espacios de nombres", @@ -2835,7 +2836,7 @@ "tooltip-ca-undelete": "Restaurar las ediciones hechas a esta página antes de que fuese borrada", "tooltip-ca-move": "Trasladar esta página", "tooltip-ca-watch": "Añadir esta página a tu lista de seguimiento", - "tooltip-ca-unwatch": "Borrar esta página de su lista de seguimiento", + "tooltip-ca-unwatch": "Quitar esta página de tu lista de seguimiento", "tooltip-search": "Buscar en {{SITENAME}}", "tooltip-search-go": "Ir a la página con este nombre exacto si existe", "tooltip-search-fulltext": "Buscar este texto en las páginas", diff --git a/languages/i18n/fr.json b/languages/i18n/fr.json index 7625d13884..a3c8cd67b8 100644 --- a/languages/i18n/fr.json +++ b/languages/i18n/fr.json @@ -1171,6 +1171,7 @@ "timezoneregion-indian": "Océan indien", "timezoneregion-pacific": "Océan pacifique", "allowemail": "Autoriser les autres utilisateurs à m'envoyer des courriels", + "email-allow-new-users-label": "Autoriser les courriels émis par les nouveaux utilisateurs", "email-blacklist-label": "Empêcher ces utilisateurs de m'envoyer des courriels :", "prefs-searchoptions": "Recherche", "prefs-namespaces": "Espaces de noms", diff --git a/languages/i18n/hr.json b/languages/i18n/hr.json index a5722adaa2..f98d3fe9f9 100644 --- a/languages/i18n/hr.json +++ b/languages/i18n/hr.json @@ -1925,7 +1925,7 @@ "defemailsubject": "{{SITENAME}} e-mail od suradnika \"$1\"", "usermaildisabled": "Suradnička e-pošta je onemogućena", "usermaildisabledtext": "Ne možete slati e-poštu drugim suradnicima na ovom wikiju", - "noemailtitle": "Nema adrese primaoca", + "noemailtitle": "Nema adrese e-pošte", "noemailtext": "Ovaj suradnik nije odredio valjanu adresu e-pošte.", "nowikiemailtext": "Ovaj suradnik je odlučio ne primati e-mail od drugih suradnika.", "emailnotarget": "Nepostojeće ili nevažeće suradničko ime za primatelja.", diff --git a/languages/i18n/ka.json b/languages/i18n/ka.json index 1915f470d4..0c314d7338 100644 --- a/languages/i18n/ka.json +++ b/languages/i18n/ka.json @@ -3364,6 +3364,7 @@ "autosumm-blank": "გვერდის შიგთავსი დაცარიელდა", "autosumm-replace": "შინაარსი შეიცვალა „$1“-ით", "autoredircomment": "გადამისამართება [[$1]]-ზე", + "autosumm-removed-redirect": "წაშლილი გადამისამართება [[$1]]", "autosumm-new": "ახალი გვერდი: $1", "autosumm-newblank": "ცარიელი გვერდი შეიქმნა", "size-bytes": "$1 ბ", diff --git a/languages/i18n/ko.json b/languages/i18n/ko.json index 6b7664543d..04e37e87ee 100644 --- a/languages/i18n/ko.json +++ b/languages/i18n/ko.json @@ -1064,6 +1064,7 @@ "timezoneregion-indian": "인도양", "timezoneregion-pacific": "태평양", "allowemail": "다른 사용자가 내게 이메일을 보낼 수 있게 허용", + "email-allow-new-users-label": "처음 온 사용자들로부터 오는 이메일 허용", "email-blacklist-label": "이 사용자들이 내게 이메일을 보내는 것을 금지합니다:", "prefs-searchoptions": "검색", "prefs-namespaces": "이름공간", diff --git a/languages/i18n/lb.json b/languages/i18n/lb.json index 1865252eb3..20ce7a7ebc 100644 --- a/languages/i18n/lb.json +++ b/languages/i18n/lb.json @@ -973,7 +973,7 @@ "recentchangesdays-max": "(Maximal $1 {{PLURAL:$1|Dag|Deeg}})", "recentchangescount": "Zuel vun den Ännerungen déi als Standard gewise ginn:", "prefs-help-recentchangescount": "Inklusiv Rezent Ännerungen, Versiounshistoriquen a Logbicher.", - "prefs-help-watchlist-token2": "Dëst ass de geheime Schlëssel fir de Webfeed vun Ärer Iwwerwaachungslëscht. Jiddwereen deen e kennt kann Är Iwwerwaachungslëscht liesen, dofir sollt Dir en net weider ginn. [[Special:ResetTokens|Klickt hei wann Dir en zrécksetze musst]].", + "prefs-help-watchlist-token2": "Dëst ass de geheime Schlëssel fir de Webfeed vun Ärer Iwwerwaachungslëscht. Jiddwereen deen e kennt kann Är Iwwerwaachungslëscht liesen, dofir sollt Dir en net weider ginn. Wann Dir wëllt [[Special:ResetTokens|kënnt Dir en hei zrécksetze kënnt]].", "savedprefs": "Är Astellunge goufe gespäichert.", "savedrights": "D'Benotzergruppe vum {{GENDER:$1|$1}} goufe gespäichert.", "timezonelegend": "Zäitzon:", @@ -1417,7 +1417,7 @@ "recentchangeslinked-feed": "Ännerungen op verlinkt Säiten", "recentchangeslinked-toolbox": "Ännerungen op verlinkt Säiten", "recentchangeslinked-title": "Ännerungen a Verbindung mat \"$1\"", - "recentchangeslinked-summary": "Dëst ass eng Lëscht mat Ännerunge vu verlinkte Säiten op eng bestëmmte Säit (oder vu Membersäite vun der spezifizéierter Kategorie).\nSäite vun [[Special:Watchlist|Ärer Iwwerwaachungslëscht]] si '''fett''' geschriwwen.", + "recentchangeslinked-summary": "Gitt den Numm vun enger Säit a fir Ännerungen Säiten ze gesinn op déi oder vun deene gelinkt gëtt. Ännerungen op Säite vun [[Special:Watchlist|Ärer Iwwerwaachungslëscht]] si fett geschriwwen.", "recentchangeslinked-page": "Säitennumm:", "recentchangeslinked-to": "Weis Ännerungen zu de verlinkte Säiten aplaz vun der gefroter Säit", "recentchanges-page-added-to-category": "[[:$1]] an d'Kategorie derbäigesat", diff --git a/languages/i18n/lv.json b/languages/i18n/lv.json index e106cb08bb..5a71f4e5ee 100644 --- a/languages/i18n/lv.json +++ b/languages/i18n/lv.json @@ -1110,7 +1110,7 @@ "rcfilters-group-results-by-page": "Grupēt rezultātus pēc lapas", "rcfilters-activefilters": "Aktīvie filtri", "rcfilters-advancedfilters": "Paplašinātie filtri", - "rcfilters-limit-title": "Rādāmās izmaiņas", + "rcfilters-limit-title": "Rādāmie rezultāti", "rcfilters-limit-and-date-label": "{{PLURAL:$1|$1 izmaiņas|$1 izmaiņa|$1 izmaiņas}}, $2", "rcfilters-days-title": "Pēdējās dienas", "rcfilters-hours-title": "Pēdējās stundas", @@ -1725,7 +1725,7 @@ "enotif_reset": "Atzīmēt visas lapas kā apskatītas", "enotif_impersonal_salutation": "{{SITENAME}} lietotājs", "enotif_lastvisited": "$1 lai apskatītos visas izmaiņas kopš tava pēdējā apmeklējuma.", - "enotif_lastdiff": "$1 lai apskatītos šo izmaiņu.", + "enotif_lastdiff": "Lai apskatītu šo izmaiņu, skatīt $1", "enotif_anon_editor": "anonīms dalībnieks $1", "enotif_body": "$WATCHINGUSERNAME,\n\n\n{{grammar:ģenitīvs|{{SITENAME}}}} lapu $PAGETITLE $CHANGEDORCREATED $PAGEEDITOR, $PAGEEDITDATE, pašreizējā versja ir $PAGETITLE_URL.\n\n$NEWPAGE\n\nIzmaiņu kopsavilkums bija: $PAGESUMMARY $PAGEMINOREDIT\n\nSazināties ar attiecīgo lietotāju:\ne-pasts: $PAGEEDITOR_EMAIL\nwiki: $PAGEEDITOR_WIKI\n\nJa šo uzraugāmo lapu izmainīs vēl, turpmāku paziņojumu par to nebūs, kamēr tu to neatvērsi.\nTu arī vari atstatīt visu uzraugāmo lapu paziņojumu statusus uzraugāmo lapu sarakstā.\n\n {{grammar:ģenitīvs|{{SITENAME}}}} paziņojumu sistēma\n\n--\nLai izmainītu uzraugāmo lapu saraksta uzstādījumus:\n{{canonicalurl:{{#special:EditWatchlist}}}}\n\nLai dzēstu lapu no uzraugāmo lapu saraksta:\n$UNWATCHURL\n\nPapildinformācija:\n$HELPPAGE", "enotif_minoredit": "Šis ir maznozīmīgs labojums", @@ -2862,7 +2862,7 @@ "feedback-useragent": "Lietotāja aģents:", "searchsuggest-search": "Meklēt {{SITENAME}}", "searchsuggest-containing": "Meklējamā frāze:", - "api-error-unknown-warning": "Nezināms brīdinājums: $1", + "api-error-unknown-warning": "Nezināms brīdinājums: \"$1\".", "api-error-unknownerror": "Nezināma kļūda: \"$1\"", "duration-seconds": "$1 {{PLURAL:$1|sekundes|sekunde|sekundes}}", "duration-minutes": "$1 {{PLURAL:$1|minūtes|minūte|minūtes}}", diff --git a/languages/i18n/mk.json b/languages/i18n/mk.json index ae28e8dd09..d835015c3d 100644 --- a/languages/i18n/mk.json +++ b/languages/i18n/mk.json @@ -1025,6 +1025,7 @@ "timezoneregion-indian": "Индиски Океан", "timezoneregion-pacific": "Тихи Океан", "allowemail": "Дозволи е-пошта од други корисници", + "email-allow-new-users-label": "Дозволи е-пошта од сосем нови корисници", "email-blacklist-label": "Забрани е-пошта од следниве корисници:", "prefs-searchoptions": "Пребарување", "prefs-namespaces": "Именски простори", diff --git a/languages/i18n/mr.json b/languages/i18n/mr.json index e944f1b15d..32788bedf8 100644 --- a/languages/i18n/mr.json +++ b/languages/i18n/mr.json @@ -476,7 +476,7 @@ "nosuchusershort": "\"$1\" या नावाचा सदस्य नाही. लिहीताना आपली चूक तर नाही ना झाली?", "nouserspecified": "तुम्हाला सदस्यनाव नमूद करावे लागेल.", "login-userblocked": "हा सदस्य ’प्रतिबंधित’ आहे. त्यास सनोंद-प्रवेशाची परवानगी नाही.", - "wrongpassword": "आपण परवलीचा शब्द चुकीचा टाकला आहे, पुन्हा एकदा प्रयत्न करा.", + "wrongpassword": "सदस्यनाव अथवा परवलीचा शब्द चुकीचा टाकण्यात आला आहे. पुन्हा एकदा प्रयत्न करा.", "wrongpasswordempty": "परवलीचा शब्द कोरा आहे; पुन्हा प्रयत्न करा.", "passwordtooshort": "तुमच्या परवलीच्या शब्दात किमान {{PLURAL:$1|१ अक्षर |$1 अक्षरे}} हवीत.", "passwordtoolong": "परवलीचा शब्द हा {{PLURAL:$1|१ वर्ण पेक्षा|$1 वर्णांपेक्षा}} लांबीचा नको.", @@ -636,10 +636,10 @@ "anonpreviewwarning": "\"'''सावधान:''' तुम्ही विकिपीडियाचे सदस्य म्हणून सनोंद-प्रवेश (लॉग-इन) केलेला नाही. या पानाच्या संपादन इतिहासात तुमचा अंकपत्ता (आय.पी. अॅड्रेस) नोंदला जाईल.\"", "missingsummary": "'''आठवण:''' आपण संपादन सारांश पुरवलेला नाही.आपण 'जतन करा' वर पुन्हा टिचकी मारली तर, ते त्याशिवायच जतन होईल.", "selfredirect": "ईशारा:आपण या पानास, त्याच पानावर पुनर्निर्देशित करीता आहात.\nआपण पुनर्निर्देशनासाठी चूकिचे लक्ष्य नमूद केले आहे किंवा आपण चूकिच्या पानाचे संपादन करीत आहात.\nजर आपण पुन्हा \"$1\" टिचकले तर, कसेहीकरुन ते पुनर्निर्देशन तयार होईल.", - "missingcommenttext": "कृपया खाली प्रतिक्रिया भरा.", + "missingcommenttext": "कृपया प्रतिक्रिया टाका.", "missingcommentheader": "आठवण: आपण या लेखनाकरिता विषय दिलेला नाही. आपण पुन्हा \"$1\" वर टिचकले तर, तुमचे संपादन त्याशिवायच जतन होईल.", - "summary-preview": "आढाव्याची झलक:", - "subject-preview": "विषय झलक:", + "summary-preview": "संपादन सारांशाची झलक:", + "subject-preview": "विषयाची झलक:", "previewerrortext": "आपल्या बदलांची झलक बघण्याचे प्रयत्नादरम्यान त्रुटी उद्भवली.", "blockedtitle": "हा सदस्य प्रतिबंधित आहे", "blockedtext": "'''तुमचे सदस्यनाव अथवा IP पत्ता ब्लॉक केलेला आहे.'''\n\nहा ब्लॉक $1 यांनी केलेला आहे.\nयासाठी ''$2'' हे कारण दिलेले आहे.\n\n* ब्लॉकची सुरूवात: $8\n* ब्लॉकचा शेवट: $6\n* कुणाला ब्लॉक करायचे आहे: $7\n\nतुम्ही ह्या ब्लॉक संदर्भातील चर्चेसाठी $1 अथवा [[{{MediaWiki:Grouppage-sysop}}|प्रबंधकांशी]] संपर्क करू शकता.\nतुम्ही जोवर वैध ई-मेल पत्ता आपल्या [[Special:Preferences|'माझ्या पसंती']] पानावर देत नाही तोवर तुम्ही ’सदस्याला ई-मेल पाठवा’ हा दुवा वापरू शकत नाही. तसेच असे करण्यापासून आपल्याला ब्लॉक केलेले नाही.\nतुमचा सध्याचा IP पत्ता $3 हा आहे, व तुमचा ब्लॉक क्रमांक #$5 हा आहे.\nकृपया या संदर्भातील चर्चेमध्ये वरील सर्व तपशिल उद्घृत करा.", @@ -782,8 +782,8 @@ "page_first": "प्रथम", "page_last": "अंतिम", "histlegend": "फरक निवडणे: जुन्या आवृत्तींमधील फरक पाहण्यासाठी रेडियो बॉक्स मध्ये खूण करा व एन्टर कळ दाबा अथवा खाली दिलेल्या कळीवर टिचकी द्या.
\nविवरण: '''({{int:cur}})''' = चालू आवृत्तीशी फरक,\n(मागील) = पूर्वीच्या आवृत्तीशी फरक, छो = किरकोळ संपादन", - "history-fieldset-title": "इतिहास विंचरण करा", - "history-show-deleted": "फक्त काढून टाकलेले", + "history-fieldset-title": "आवृत्त्यांसाठी शोधा", + "history-show-deleted": "फक्त वगळलेल्या आवृत्त्या", "histfirst": "सर्वात प्राचिन", "histlast": "नविनतम", "historysize": "({{PLURAL:$1|1 बाइट|$1 बाइट्स}})", @@ -840,9 +840,9 @@ "revdelete-unsuppress": "पुर्नस्थापीत आवृत्त्यांवरील बंधने ऊठवा", "revdelete-log": "कारण:", "revdelete-submit": "निवडलेल्या {{PLURAL:$1|आवृत्तीला|आवृत्त्यांना}} लागू करा", - "revdelete-success": "'''आवृत्त्यांची दृश्यता यशस्वीपणे अद्ययावत केली.'''", + "revdelete-success": "आवृत्त्यांची दृश्यता अद्ययावत केली.", "revdelete-failure": "'''आवर्तन दृश्यता अद्ययावत करता येत नाही:'''\n$1", - "logdelete-success": "'''नोंदींची दृश्यता यशस्वी पणे स्थापिली.'''", + "logdelete-success": "नोंदींची दृश्यता स्थापिली.", "logdelete-failure": "'''नोंदींची दृश्यता स्थापिल्या गेली नाही.'''\n$1", "revdel-restore": "दृश्यता बदला", "pagehist": "पानाचा इतिहास", @@ -873,6 +873,9 @@ "mergehistory-empty": "कोणतेही आवर्तन एकत्रित करता येत नाही.", "mergehistory-done": "$1 {{PLURAL:$3|चे|ची}} $3 {{PLURAL:$3|आवर्तन|आवर्तने}} [[:$2]] मध्ये यशस्वीरीत्या एकत्रित केली.", "mergehistory-fail": "इतिहासाचे एकत्रीकरण कार्य करू शकत नाही आहे, कृपया पान आणि वेळ प्राचलांची पुनर्तपासणी करा.", + "mergehistory-fail-bad-timestamp": "वेळठसा अवैध आहे.", + "mergehistory-fail-invalid-source": "स्रोत पान अवैध आहे.", + "mergehistory-fail-invalid-dest": "लक्ष्य पान अवैध आहे.", "mergehistory-fail-toobig": "इतिहास एकत्रिकरण करणे शक्य झाले नाही कारण $1 मर्यादेपेक्षा अधिक {{PLURAL:$1|आवृत्ती|आवृत्त्या}} स्थानांतरीत केल्या जातील.", "mergehistory-no-source": "स्रोत पान $1 अस्तित्वात नाही.", "mergehistory-no-destination": "लक्ष्य पान $1 अस्तित्वात नाही.", @@ -929,9 +932,10 @@ "search-file-match": "(संचिका आशयाशी अनुरुपते)", "search-suggest": "तुम्हाला हेच म्हणायचे का: $1", "search-rewritten": "$1 साठीचे निकाल दाखवित आहे.त्याऐवजी $2 चा शोध घ्या.", - "search-interwiki-caption": "सह प्रकल्प", + "search-interwiki-caption": "सह-प्रकल्पांपासून प्राप्त निकाल", "search-interwiki-default": "$1चे निकाल:", "search-interwiki-more": "(आणखी)", + "search-interwiki-more-results": "अधिक निकाल", "search-relatedarticle": "जवळील", "searchrelated": "संबंधित", "searchall": "सर्व", @@ -1058,13 +1062,13 @@ "prefs-help-prefershttps": "हा पसंतीक्रम आपल्या पुढील सनोंद प्रवेशानंतर कार्यान्वित होईल.", "prefswarning-warning": "आपण आपल्या पसंतीक्रमात केलेला बदल अद्याप जतन झाला नाही.जर आपण \"$1\" न टिचकता, या पानावरुन दुसरीकडे गेलात तर आपला पसंतीक्रम अद्यतन होणार नाही.", "prefs-tabs-navigation-hint": "उपयुक्त सूचना:आपण कळींच्या यादीत, कळींदरम्यानच्या सुचालनास डावी व उजवी बाण-कळ वापरु शकता.", - "userrights": "सदस्य अधिकार व्यवस्थापन", - "userrights-lookup-user": "सदस्य गटांचे(ग्रूप्स) व्यवस्थापन करा.", + "userrights": "सदस्य अधिकार", + "userrights-lookup-user": "सदस्याची निवड करा", "userrights-user-editname": "सदस्य नाव टाका:", - "editusergroup": "सदस्याचे गट संपादित करा", + "editusergroup": "सदस्य गटांचे भारण करा", "editinguser": "या {{GENDER:$1|सदस्या}}चे सदस्य-अधिकारात बदल केला जात आहे[[User:$1|$1]] $2", "userrights-editusergroup": "{{GENDER:$1|सदस्य}} गट संपादित करा", - "saveusergroups": "सदस्य गट जतन करा", + "saveusergroups": "{{GENDER:$1|सदस्य}} गट जतन करा", "userrights-groupsmember": "याचा सभासद:", "userrights-groupsmember-auto": "याचा अव्यक्त सदस्य:", "userrights-groups-help": "तुम्ही एखाद्या सदस्याचे गट सदस्यत्व बदलू शकता:\n* निवडलेला चौकोन म्हणजे सदस्य त्या गटात आहे.\n* न निवडलेला चौकोन म्हणजे सदस्य त्या गटात नाही.\n* एक * चा अर्थ तुम्ही एकदा समावेश केल्यानंतर तो गट बदलू शकत नाही, किंवा काढल्यानंतर समावेश करू शकत नाही.", @@ -1268,7 +1272,9 @@ "rcfilters-group-results-by-page": "पानानुसार गट निकाल", "rcfilters-activefilters": "सक्रिय गाळण्या", "rcfilters-advancedfilters": "प्रगत गाळण्या", - "rcfilters-limit-title": "दाखविण्यासाठीचे बदल", + "rcfilters-limit-title": "दाखविण्यासाठीचे निकाल", + "rcfilters-limit-and-date-label": "{{PLURAL:$1|बदल}}, $2", + "rcfilters-date-popup-title": "शोधावयाचा कालावधी", "rcfilters-days-title": "अलीकडील दिवस", "rcfilters-hours-title": "अलीकडील तास", "rcfilters-days-show-days": "$1 {{PLURAL:$1|दिवस}}", @@ -1283,29 +1289,31 @@ "rcfilters-savedqueries-apply-label": "गाळणी तयार करा", "rcfilters-savedqueries-cancel-label": "रद्द करा", "rcfilters-savedqueries-add-new-title": "सध्या असलेल्या गाळण्यांच्या मांडण्या जतन करा", + "rcfilters-savedqueries-already-saved": "या गाळण्या पूर्वीच जतन केल्या आहेत.नवीन जतन केलेली गाळणी तयार करण्यासाठी आपल्या मांडण्या बदलवा.", "rcfilters-restore-default-filters": "अविचल गाळण्या पुनर्स्थापा", "rcfilters-clear-all-filters": "सर्व गाळण्या हटवा", "rcfilters-show-new-changes": "नवीनतम बदल बघा", - "rcfilters-search-placeholder": "अलीकडील बदल गाळा (न्याहाळा किंवा टंकन सुरू करा)", + "rcfilters-search-placeholder": "बदल गाळा (गाळण्यांच्या नावासाठी मेन्यू अथवा शोध वापरा)", "rcfilters-invalid-filter": "अवैध गाळणी", "rcfilters-empty-filter": "कोणत्याच गाळण्या सक्रिय नाहीत. सर्व योगदाने दाखविण्यात येत आहेत.", "rcfilters-filterlist-title": "गाळण्या", + "rcfilters-filterlist-whatsthis": "हे कसे काम करते?", "rcfilters-filterlist-feedbacklink": "या (नवीन) गाळणी साधनांबद्दल आपले काय म्हणणे/विचार आहेत ते आम्हास सांगा", "rcfilters-highlightbutton-title": "निकालांवर झोत टाका", "rcfilters-highlightmenu-help": "या गुणधर्मासाठी झोताचा रंग निवडा", "rcfilters-filterlist-noresults": "कोणतीच गाळणी सापडली नाही", "rcfilters-filtergroup-authorship": "योगदानांचे लेखक", - "rcfilters-filter-editsbyself-label": "आपली स्वत:ची संपादने", + "rcfilters-filter-editsbyself-label": "आपले स्वतःचे बदल", "rcfilters-filter-editsbyself-description": "आपली संपादने", - "rcfilters-filter-editsbyother-label": "इतरांची संपादने", + "rcfilters-filter-editsbyother-label": "इतरांचे बदल", "rcfilters-filter-editsbyother-description": "इतर सदस्यांनी तयार केलेली संपादने (आपण नाही).", "rcfilters-filtergroup-userExpLevel": "अनुभवाचा स्तर (फक्त नोंदणीकृत सदस्यांसाठीच)", "rcfilters-filter-user-experience-level-registered-label": "नोंदणीकृत", - "rcfilters-filter-user-experience-level-registered-description": "प्रवेशलेले सदस्य", + "rcfilters-filter-user-experience-level-registered-description": "प्रवेशलेले संपादक.", "rcfilters-filter-user-experience-level-unregistered-label": "अ-नोंदणीकृत", "rcfilters-filter-user-experience-level-unregistered-description": "संपादक जे प्रवेशित नाहीत.", "rcfilters-filter-user-experience-level-newcomer-label": "नवागत", - "rcfilters-filter-user-experience-level-newcomer-description": "१० संपादनांपेक्षा कमी व ४ दिवसांची सक्रियता असणारे नोंदणीकृत सदस्य.", + "rcfilters-filter-user-experience-level-newcomer-description": "१० संपादनांपेक्षा कमी संपादने केलेले व ४ दिवसांची सक्रियता असणारे नोंदणीकृत सदस्य.", "rcfilters-filter-user-experience-level-learner-label": "शिकाऊ", "rcfilters-filter-user-experience-level-learner-description": "\"शिकाऊ\" व \"नोंदणीकृत संपादक\" या दरम्यानचा अनुभव असणारे संपादक", "rcfilters-filter-user-experience-level-experienced-label": "अनुभवी सदस्य", @@ -1322,10 +1330,13 @@ "rcfilters-filter-major-description": "किरकोळ अशी खूण नसलेली संपादने", "rcfilters-filtergroup-watchlist": "निरीक्षणसूचीतील पाने", "rcfilters-filter-watchlist-watched-label": "निरीक्षणसूचीतील", + "rcfilters-filter-watchlist-watched-description": "आपल्या निरीक्षणसूचीत असलेल्या पानांमधील बदल.", "rcfilters-filter-watchlist-watchednew-label": "निरीक्षणसूचीतील नवीन बदल", "rcfilters-filter-watchlist-watchednew-description": "बदल झाल्यानंतर, आपण भेट न दिल्यापासून झालेले निरीक्षणसूचीच्या पानांतील बदल", "rcfilters-filter-watchlist-notwatched-label": "निरीक्षणसूचीत नसलेली", "rcfilters-filter-watchlist-notwatched-description": "आपल्या निरीक्षणसूचीतील बदलांशिवाय इतर सर्वकाही.", + "rcfilters-filter-watchlistactivity-unseen-label": "न-बघितलेले बदल", + "rcfilters-filter-watchlistactivity-seen-label": "बघितलेले बदल", "rcfilters-filtergroup-changetype": "बदलाचा प्रकार", "rcfilters-filter-pageedits-label": "पृष्ठ संपादने", "rcfilters-filter-newpages-label": "नवीन पान-निर्माण", @@ -1344,6 +1355,8 @@ "rcfilters-view-namespaces-tooltip": "नामविश्वांनुसार गाळण्यांचे निकाल", "rcfilters-view-tags-tooltip": "संपादन खूण वापरुन गाळण्यांचे निकाल", "rcfilters-view-tags-help-icon-tooltip": "खूण केलेल्या संपादनांबाबत अधिक जाणून घ्या", + "rcfilters-liveupdates-button": "सजीव अद्यतने", + "rcfilters-liveupdates-button-title-on": "सजीव अद्यतने बंद करा", "rcnotefrom": "खाली {{PLURAL:$5|हा बदल आहे|हे बदल आहेत}} $3, $4पासून ते($1पर्यंतचे बदल दाखविले आहेत).", "rclistfrom": "$2,$3 पासून सुरुवात करुन, नविन केल्या गेलेले बदल दाखवा.", "rcshowhideminor": "छोटे बदल $1", @@ -2018,6 +2031,7 @@ "changecontentmodel-title-label": "लेखपान शीर्ष", "changecontentmodel-reason-label": "कारण:", "changecontentmodel-submit": "बदला", + "changecontentmodel-success-title": "आशय नमूना बदलल्या गेला", "log-name-contentmodel": "आशय नमूना बदल नोंदी", "logentry-contentmodel-change-revertlink": "उलटवा", "logentry-contentmodel-change-revert": "उलटवा", @@ -2540,6 +2554,7 @@ "pageinfo-length": "पानाचा आकार (बाइट्समध्ये)", "pageinfo-article-id": "पृष्ठ-ओळखण", "pageinfo-language": "पान-आशय भाषा", + "pageinfo-language-change": "बदल", "pageinfo-content-model": "पान-आशय नमूना", "pageinfo-content-model-change": "बदला", "pageinfo-robot-policy": "यंत्रमानवांद्वारे अनुक्रमण", @@ -3023,6 +3038,7 @@ "confirmemail_body_set": "{{SITENAME}} वर कुणीतरी, बहुतेक आपणच, $1 या अंकपत्त्यावरून, \"$2\" या खात्याकरिताचा विपत्रपत्ता (ई-मेल), या पत्त्यास स्थापिलेला आहे.\n\nहे खाते खरोखर आपलेच आहे याची खात्री करण्यासाठी आणि {{SITENAME}} वर विपत्रपत्ता प्रारुप सक्रिय(उपलब्ध) करण्यासाठी, हा दुवा आपल्या न्याहाळकात(ब्राउजर) उघडा:\n\n$3\n\nजर हे खाते आपले *नसेल* तर, ही विपत्रपत्याचे निश्चितीकरण वगळण्यासाठी,खालील दुव्यास अनुसरा:\n\n$5\n\nहा निश्चितीकरण संकेत(कन्फर्मेशन कोड) $4 ला कालबाह्य होईल.", "confirmemail_invalidated": "इ-मेल पत्ता तपासणी रद्द करण्यात आलेली आहे", "invalidateemail": "इ-मेल तपासणी रद्द करा", + "notificationemail_subject_changed": "{{SITENAME}} वर नोंदविलेला विपत्रपत्ता बदलवल्या गेला", "scarytranscludedisabled": "[आंतरविकि आंतरन्यास अनुपलब्ध केले आहे]", "scarytranscludefailed": "[क्षमस्व;$1करिताची साचा ओढी फसली]", "scarytranscludetoolong": "[आंतरजालपत्ता खूप लांब आहे]", @@ -3221,6 +3237,7 @@ "tags-apply-blocked": "आपण प्रतिबंधित असतांना आपल्या बदलांसह, बदल खूणपताकांना लागू करु शकत नाही.", "tags-update-blocked": "आपण प्रतिबंधित असतांना बदल खूणपताकांना जोडू अथवा हटवू शकत नाही.", "tags-edit-reason": "कारण:", + "tags-edit-success": "बदल लागू केल्या गेलेत.", "tags-edit-none-selected": "जोडण्यास किंवा हटविण्यास किमान एक खूणपताका निवडा.", "comparepages": "पानांची तुलना करा", "compare-page1": "पान १", @@ -3437,6 +3454,7 @@ "sessionprovider-nocookies": "कुकिज अक्षम असू शकतात. याची खात्री करा कि कुकिज सक्षम केल्या आहेत व पुन्हा सुरुवात करा.", "randomrootpage": "अविशिष्ट मूळ पान", "log-action-filter-contentmodel": "आशय नमूना बदलाचा प्रकार", + "log-action-filter-rights-rights": "मानवी बदल", "log-action-filter-suppress-block": "रोधामार्फत सदस्य दाबणे", "changecredentials": "अधिकारपत्रे (क्रेडेंटियल्स)बदला", "removecredentials": "अधिकारपत्रे (क्रेडेंटियल्स) हटवा" diff --git a/languages/i18n/mt.json b/languages/i18n/mt.json index 35e35ee673..5202c2e0d8 100644 --- a/languages/i18n/mt.json +++ b/languages/i18n/mt.json @@ -531,6 +531,7 @@ "minoredit": "Din hija modifika minuri", "watchthis": "Segwi din il-paġna", "savearticle": "Salva l-paġna", + "publishpage": "Ippubblika l-paġna", "publishchanges": "Ippubblika l-modifiki", "preview": "Dehra proviżorja", "showpreview": "Dehra proviżorja", diff --git a/languages/i18n/nb.json b/languages/i18n/nb.json index 5aa5b97608..aa9636f31b 100644 --- a/languages/i18n/nb.json +++ b/languages/i18n/nb.json @@ -1049,6 +1049,7 @@ "timezoneregion-indian": "Indiahavet", "timezoneregion-pacific": "Stillehavet", "allowemail": "Tillat andre å sende meg e-post", + "email-allow-new-users-label": "Tillat e-poster fra helt nyregistrerte brukere", "email-blacklist-label": "Forhindre disse brukerne fra å sende meg e-post:", "prefs-searchoptions": "Søk", "prefs-namespaces": "Navnerom", diff --git a/languages/i18n/nl.json b/languages/i18n/nl.json index cf9db33f01..b8d8b37f8f 100644 --- a/languages/i18n/nl.json +++ b/languages/i18n/nl.json @@ -97,17 +97,17 @@ "tog-hidepatrolled": "Gemarkeerde wijzigingen verbergen in recente wijzigingen", "tog-newpageshidepatrolled": "Gemarkeerde pagina's verbergen in de lijst met nieuwe pagina's", "tog-hidecategorization": "Categorisatie van pagina's verbergen", - "tog-extendwatchlist": "Uitgebreide volglijst gebruiken om alle wijzigingen te bekijken, en niet alleen de laatste", + "tog-extendwatchlist": "Volglijst uitbreiden om alle wijzigingen te tonen, en niet alleen de recentste", "tog-usenewrc": "Wijzigingen per pagina weergeven in recente wijzigingen en volglijst", "tog-numberheadings": "Koppen automatisch nummeren", "tog-showtoolbar": "Bewerkingswerkbalk weergeven", "tog-editondblclick": "Dubbelklikken voor bewerken", "tog-editsectiononrightclick": "Bewerken van deelpagina’s mogelijk maken met een rechtermuisklik op een tussenkop", - "tog-watchcreations": "Pagina's die ik aanmaak en bestanden die ik upload automatisch volgen", - "tog-watchdefault": "Pagina’s en bestanden die ik bewerk automatisch volgen", + "tog-watchcreations": "Pagina's die ik aanmaak en bestanden die ik upload aan mijn volglijst toevoegen", + "tog-watchdefault": "Pagina’s en bestanden die ik bewerk aan mijn volglijst toevoegen", "tog-watchmoves": "Pagina’s en bestanden die ik hernoem automatisch volgen", "tog-watchdeletion": "Pagina’s en bestanden die ik verwijder automatisch volgen", - "tog-watchuploads": "Nieuwe bestanden die ik upload toevoegen aan mijn volglijst", + "tog-watchuploads": "Nieuwe bestanden die ik upload aan mijn volglijst toevoegen", "tog-watchrollback": "Pagina's waarop ik heb teruggedraaid automatisch volgen", "tog-minordefault": "Mijn bewerkingen standaard als kleine bewerking markeren", "tog-previewontop": "Voorvertoning boven bewerkingsveld weergeven", @@ -125,8 +125,8 @@ "tog-watchlisthidebots": "Botbewerkingen op mijn volglijst verbergen", "tog-watchlisthideminor": "Kleine bewerkingen op mijn volglijst verbergen", "tog-watchlisthideliu": "Bewerkingen van aangemelde gebruikers op mijn volglijst verbergen", - "tog-watchlistreloadautomatically": "Herlaad de volglijst automatisch wanneer er een filter is veranderd (JavaScript vereist)", - "tog-watchlistunwatchlinks": "Voeg volgen/niet volgen-links toe aan regels in de volglijst (JavaScript vereist voor deze functionaliteit)", + "tog-watchlistreloadautomatically": "De volglijst automatisch herladen wanneer er een filter wordt veranderd (JavaScript vereist)", + "tog-watchlistunwatchlinks": "Volgen/niet volgen-links toevoegen aan regels in de volglijst (JavaScript vereist voor schakelfunctionaliteit)", "tog-watchlisthideanons": "Bewerkingen van anonieme gebruikers op mijn volglijst verbergen", "tog-watchlisthidepatrolled": "Gemarkeerde wijzigingen op mijn volglijst verbergen", "tog-watchlisthidecategorization": "Categorisatie van pagina's verbergen", @@ -496,21 +496,21 @@ "createacct-email-ph": "Geef uw e-mailadres op", "createacct-another-email-ph": "Geef een e-mailadres op", "createaccountmail": "Gebruik een tijdelijk willekeurig wachtwoord en stuur het naar het opgegeven e-mailadres", - "createaccountmail-help": "Kan worden gebruikt voor het aanmaken van een account voor een andere persoon zonder het wachtwoord te leren.", + "createaccountmail-help": "Kan worden gebruikt voor het aanmaken van een account voor een andere persoon zonder het wachtwoord te vernemen.", "createacct-realname": "Echte naam (optioneel)", "createacct-reason": "Reden", "createacct-reason-ph": "Waarom u een ander account aanmaakt", "createacct-reason-help": "Weergegeven bericht in het logbestand van aangemaakte gebruikers", - "createacct-submit": "Account aanmaken", + "createacct-submit": "Uw account aanmaken", "createacct-another-submit": "Account aanmaken", - "createacct-continue-submit": "Doorgaan met het maken van een account", - "createacct-another-continue-submit": "Doorgaan met het maken van een account", + "createacct-continue-submit": "Doorgaan met het aanmaken van een account", + "createacct-another-continue-submit": "Doorgaan met het aanmaken van een account", "createacct-benefit-heading": "{{SITENAME}} wordt gemaakt door mensen zoals u.", "createacct-benefit-body1": "bewerking{{PLURAL:$1||en}}", "createacct-benefit-body2": "pagina{{PLURAL:$1||'s}}", "createacct-benefit-body3": "recente bijdrager{{PLURAL:$1||s}}", "badretype": "De ingevoerde wachtwoorden verschillen van elkaar.", - "usernameinprogress": "Het aanmaken van een account met die naam is al bezig.\nEven geduld alstublieft.", + "usernameinprogress": "Het aanmaken van een account met die naam is al in behandeling.\nEven geduld alstublieft.", "userexists": "De gekozen gebruikersnaam is al in gebruik.\nKies een andere naam.", "loginerror": "Aanmeldfout", "createacct-error": "Fout tijdens aanmaken account", @@ -543,7 +543,7 @@ "eauthentsent": "Er is ter bevestiging een e-mail naar het opgegeven e-mailadres gezonden.\nVolg de aanwijzingen in de e-mail om te bevestigen dat het uw account is.\nTot die tijd wordt er geen andere e-mail naar het account gezonden.", "throttled-mailpassword": "In {{PLURAL:$1|het laatste uur|de laatste $1 uur}} is al een wachtwoordherinnering verzonden.\nOm misbruik te voorkomen wordt er slechts één wachtwoordherinnering per {{PLURAL:$1|uur|$1 uur}} verzonden.", "mailerror": "Fout bij het verzenden van e-mail: $1", - "acct_creation_throttle_hit": "Bezoekers van deze wiki hebben vanaf uw IP-adres de afgelopen $2 al {{PLURAL:$1|1 account|$1 accounts}} aangemaakt, wat het maximale toegestane aantal is voor deze periode.\nDaarom kunt u momenteel vanaf dit IP-adres geen nieuwe accounts aanmaken.", + "acct_creation_throttle_hit": "Bezoekers van deze wiki hebben vanaf uw IP-adres de afgelopen $2 al {{PLURAL:$1|een account|$1 accounts}} aangemaakt, wat het maximaal toegestane aantal is voor deze periode.\nDaarom kunt u momenteel vanaf dit IP-adres geen nieuwe accounts aanmaken.", "emailauthenticated": "Uw e-mailadres is bevestigd op $2 om $3.", "emailnotauthenticated": "Uw e-mailadres is niet bevestigd.\nDe volgende functies verzenden nog geen e-mail.", "noemailprefs": "Geef een e-mailadres op in uw voorkeuren om deze functies te gebruiken.", @@ -557,7 +557,7 @@ "createaccount-text": "Iemand heeft een account voor uw e-mailadres op {{SITENAME}} ($4) aangemaakt genaamd \"$2\", met wachtwoord \"$3\".\nMeld u aan en wijzig uw wachtwoord.\n\nU kunt dit bericht negeren als dit account zonder uw medeweten is aangemaakt.", "login-throttled": "U heeft recentelijk te veel aanmeldpogingen gedaan.\nWacht alstublieft $1 voordat u het opnieuw probeert.", "login-abort-generic": "Uw aanmelding is mislukt - Afgebroken", - "login-migrated-generic": "Uw gebruikersnaam is hernoemd, en uw gebruikersnaam bestaat niet langer op deze wiki.", + "login-migrated-generic": "Uw account is hernoemd, en uw gebruikersnaam bestaat niet langer op deze wiki.", "loginlanguagelabel": "Taal: $1", "suspicious-userlogout": "Uw verzoek om af te melden is genegeerd, omdat het lijkt alsof het verzoek is verzonden door een browser of cacheproxy die stuk is.", "createacct-another-realname-tip": "Een echte naam is optioneel.\nAls u een naam opgeeft, wordt deze gebruikt ter erkenning voor diens werk.", @@ -580,9 +580,9 @@ "changepassword-success": "Uw wachtwoord is gewijzigd!", "changepassword-throttled": "U heeft recentelijk te veel mislukte aanmeldpogingen gedaan.\nWacht alstublieft $1 voordat u het opnieuw probeert.", "botpasswords": "Botwachtwoorden", - "botpasswords-summary": "Botwachtwoorden zorgen voor toegang tot de API via een gebruikersaccount zonder gebruik te maken van de aanmeldgegevens van dat account. De gebruikersrechten die beschikbaar zijn kunnen afwijken indien er aangemeld is met een botwachtwoord.\n\nAls u niet weet wat de gevolgen hiervan zijn, is het handiger om dit ook dan niet te doen. Niemand hoort u te vragen om een botwachtwoord aan te maken en deze vervolgens aan hem of haar te geven.", + "botpasswords-summary": "Botwachtwoorden zorgen voor toegang tot een gebruikersaccount via de API, zonder gebruik te maken van de aanmeldgegevens van dat account. De beschikbare gebruikersrechten zijn mogelijk beperkt wanneer er met een botwachtwoord is aangemeld.\n\nAls u niet weet waarom u een botwachtwoord zou willen aanmaken, is het raadzaam het niet te doen. Niemand hoort u te vragen om een botwachtwoord aan te maken en dit aan hem of haar te geven.", "botpasswords-disabled": "Botwachtwoorden zijn uitgeschakeld.", - "botpasswords-no-central-id": "Om botwachtwoorden te gebruiken, moet u ingelogd zijn met een gecentraliseerd account", + "botpasswords-no-central-id": "Om botwachtwoorden te gebruiken, moet u ingelogd zijn met een gecentraliseerd account.", "botpasswords-existing": "Bestaande botwachtwoorden", "botpasswords-createnew": "Een nieuw botwachtwoord aanmaken", "botpasswords-editexisting": "Een bestaand botwachtwoord bewerken", @@ -593,7 +593,7 @@ "botpasswords-label-delete": "Verwijderen", "botpasswords-label-resetpassword": "Het wachtwoord opnieuw instellen", "botpasswords-label-grants": "Van toepassing zijnde rechten:", - "botpasswords-help-grants": "Toestemmingen geven toegang tot gebruikersrechten die u al heeft. Het geven van een toestemming op deze plek geeft u geen toegang tot gebruikersrechten die u anders niet zou hebben. Zie het [[Special:ListGrants|overzicht van toestemmingen]] voor meer informatie.", + "botpasswords-help-grants": "Toestemmingen geven toegang tot rechten die uw gebruikersaccount al heeft. Het geven van een toestemming op deze plek geeft geen toegang tot rechten die uw gebruikersaccount anders niet zou hebben. Zie het [[Special:ListGrants|overzicht van toestemmingen]] voor meer informatie.", "botpasswords-label-grants-column": "Toegewezen", "botpasswords-bad-appid": "De botnaam \"$1\" is niet geldig.", "botpasswords-insert-failed": "Toevoegen van botnaam \"$1\" mislukt. Is deze misschien al toegevoegd?", @@ -631,7 +631,7 @@ "passwordreset-domain": "Domein:", "passwordreset-email": "E-mailadres:", "passwordreset-emailtitle": "Accountgegevens op {{SITENAME}}", - "passwordreset-emailtext-ip": "Iemand (waarschijnlijk u, vanaf IP-adres $1) heeft een aanvraag gedaan om uw wachtwoord voor {{SITENAME}} ($4) opnieuw in te stellen. {{PLURAL:$3|Het volgende gebruikersaccount is|De volgende gebruikersaccounts zijn}} gekoppeld aan dit e-mailadres:\n\n$2\n\n{{PLURAL:$3|Dit tijdelijke wachtwoord vervalt|Deze tijdelijke wachtwoorden vervallen}} over {{PLURAL:$5|een dag|$5 dagen}}. Meld u aan en wijzig het wachtwoord nu. Als u dit verzoek niet zelf heeft gedaan, of als u het oorspronkelijke wachtwoord nog kent en het niet wilt wijzigen, negeer dit bericht dan en blijf uw oude wachtwoord gebruiken.", + "passwordreset-emailtext-ip": "Iemand (waarschijnlijk u, vanaf IP-adres $1) heeft een aanvraag gedaan om uw wachtwoord voor {{SITENAME}} ($4) opnieuw in te stellen. {{PLURAL:$3|Het volgende gebruikersaccount is|De volgende gebruikersaccounts zijn}} gekoppeld aan dit e-mailadres:\n\n$2\n\n{{PLURAL:$3|Dit tijdelijke wachtwoord vervalt|Deze tijdelijke wachtwoorden vervallen}} over {{PLURAL:$5|een dag|$5 dagen}}. Meld u aan en wijzig het wachtwoord nu. Als u dit verzoek niet zelf heeft gedaan, of als u het oorspronkelijke wachtwoord herinnert en het niet wilt wijzigen, negeer dit bericht dan en blijf uw oude wachtwoord gebruiken.", "passwordreset-emailtext-user": "Gebruiker $1 op {{SITENAME}} heeft een aanvraag gedaan om uw wachtwoord voor {{SITENAME}} ($4) opnieuw in te stellen. {{PLURAL:$3|Het volgende gebruikersaccount is|De volgende gebruikersaccounts zijn}} gekoppeld aan dit e-mailadres:\n\n$2\n\n{{PLURAL:$3|Dit tijdelijke wachtwoord vervalt|Deze tijdelijke wachtwoorden vervallen}} over {{PLURAL:$5|een dag|$5 dagen}}.\nMeld u aan en wijzig het wachtwoord nu. Als u dit verzoek niet zelf heeft gedaan, of als u het oorspronkelijke wachtwoord nog kent en het niet wilt wijzigen, negeer dit bericht dan en blijf uw oude wachtwoord gebruiken.", "passwordreset-emailelement": "Gebruikersnaam: \n$1\n\nTijdelijk wachtwoord: \n$2", "passwordreset-emailsentemail": "Als dit e-mailadres aan uw account gekoppeld is, dan wordt er een e-mail verzonden om uw wachtwoord opnieuw in te stellen.", @@ -642,18 +642,18 @@ "passwordreset-invalidemail": "Ongeldig e-mailadres", "passwordreset-nodata": "Er is geen gebruikersnaam of e-mailadres opgegeven", "changeemail": "E-mailadres wijzigen of verwijderen", - "changeemail-header": "Vul dit formulier in om uw e-mailadres te wijzigen. Als u het e-mailadres wilt ontkoppelen van uw account, laat het e-mailadres dan leeg als u het formulier opslaat.", + "changeemail-header": "Vul dit formulier in om uw e-mailadres te wijzigen. Als u geen enkel e-mailadres aan uw account gekoppeld wilt hebben, laat het veld voor het nieuwe e-mailadres dan leeg.", "changeemail-no-info": "U moet aangemeld zijn om rechtstreeks toegang te hebben tot deze pagina.", "changeemail-oldemail": "Huidig e-mailadres:", "changeemail-newemail": "Nieuw e-mailadres:", - "changeemail-newemail-help": "Laat dit veld leeg als u uw e-mailadres wilt verwijderen. Na het verwijderen kunt u niet langer een vergeten wachtwoord opnieuw instellen en u ontvangt geen e-mails van deze wiki meer.", + "changeemail-newemail-help": "Laat dit veld leeg als u uw e-mailadres wilt verwijderen. Zonder gekoppeld e-mailadres kunt u een vergeten wachtwoord niet opnieuw instellen en ontvangt u geen e-mails van deze wiki meer.", "changeemail-none": "(geen)", "changeemail-password": "Uw wachtwoord voor {{SITENAME}}:", "changeemail-submit": "E-mailadres wijzigen", "changeemail-throttled": "U heeft recentelijk te veel mislukte aanmeldpogingen gedaan.\nWacht alstublieft $1 voordat u het opnieuw probeert.", "changeemail-nochange": "Geef een ander e-mailadres op.", "resettokens": "Tokens opnieuw instellen", - "resettokens-text": "U kunt tokens opnieuw instellen die toegang geven tot bepaalde persoonlijke gegevens die aan uw account zijn verbonden.\n\nU zou dit moeten doen als u ze per ongeluk gedeeld heeft met anderen of als onbevoegden toegang tot uw account hebben gehad.", + "resettokens-text": "U kunt hier tokens die toegang geven tot bepaalde aan uw account gekoppelde privégegevens opnieuw instellen.\n\nU zou dit moeten doen als u ze per ongeluk met anderen hebt gedeeld of als onbevoegden toegang tot uw account hebben gehad.", "resettokens-no-tokens": "Er zijn geen tokens om opnieuw in te stellen.", "resettokens-tokens": "Tokens:", "resettokens-token-label": "$1 (huidige waarde: $2)", @@ -690,7 +690,7 @@ "showpreview": "Bewerking ter controle bekijken", "showdiff": "Wijzigingen bekijken", "blankarticle": "Waarschuwing: de pagina die u wilt aanmaken is leeg.\nAls u opnieuw op \"$1\" klikt, wordt de pagina aangemaakt zonder enige inhoud.", - "anoneditwarning": "Waarschuwing: U bent niet aangemeld.\nUw IP-adres zal voor iedereen zichtbaar zijn als u wijzigingen op deze pagina maakt. Wanneer u [$1 zich aanmeldt] of [$2 een account aanmaakt], verschijnen uw bewerkingen onder uw gebruikersnaam, naast andere voordelen.", + "anoneditwarning": "Waarschuwing: U bent niet aangemeld.\nUw IP-adres zal voor iedereen zichtbaar zijn als u wijzigingen op deze pagina maakt. Wanneer u [$1 zich aanmeldt] of [$2 een account aanmaakt], worden uw bewerkingen aan uw gebruikersnaam toegeschreven, naast andere voordelen.", "anonpreviewwarning": "U bent niet aangemeld. Door uw bewerking op te slaan wordt uw IP-adres in de paginageschiedenis opgenomen.", "missingsummary": "'''Let op:''' u hebt geen bewerkingssamenvatting opgegeven.\nAls u nogmaals op \"$1\" klikt wordt de bewerking zonder samenvatting opgeslagen.", "selfredirect": "Waarschuwing: U heeft een doorverwijzing gemaakt naar deze pagina. Mogelijk heeft u de verkeerde bestemming voor de doorverwijzing gebruikt, of bewerkt u de verkeerde pagina. Door nogmaals op \"$1\" te klikken word de doorverwijzing alsnog aangemaakt.", @@ -720,7 +720,7 @@ "noarticletext-nopermission": "Deze pagina bevat geen tekst.\nU kunt [[Special:Search/{{PAGENAME}}|naar deze term zoeken]] in andere pagina's of\n[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} de logboeken doorzoeken], maar u mag de pagina niet aanmaken.", "missing-revision": "De versie #$1 van de pagina \"{{FULLPAGENAME}}\" bestaat niet.\n\nDit wordt meestal veroorzaakt door het volgen van een verouderde koppeling naar een pagina die is verwijderd.\nMeer gegevens zijn mogelijk te vinden in het [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} verwijderingslogboek].", "userpage-userdoesnotexist": "Gebruikersaccount \"$1\" bestaat niet.\nControleer of u deze pagina wel wilt aanmaken/bewerken.", - "userpage-userdoesnotexist-view": "Gebruikersaccount \"$1\" is niet geregistreerd.", + "userpage-userdoesnotexist-view": "Gebruikersaccount \"$1\" bestaat niet.", "blocked-notice-logextract": "Deze gebruiker is momenteel geblokkeerd.\nDe laatste regel uit het blokkeerlogboek wordt hieronder ter referentie weergegeven:", "clearyourcache": "Opmerking: nadat u de wijzigingen hebt opgeslagen is het wellicht nodig uw browsercache te legen.\n* Firefox / Safari: houd Shift ingedrukt terwijl u op Vernieuwen klikt of druk op Ctrl-F5 of Ctrl-R (⌘-Shift-R op een Mac)\n* Google Chrome: druk op Ctrl-Shift-R (⌘-Shift-R op een Mac)\n* Internet Explorer: houd Ctrl ingedrukt terwijl u op Vernieuwen klikt of druk op Ctrl-F5\n* '''Opera:''' ga naar Menu → Instellingen (Opera → Voorkeuren op een Mac) en daarna naar Privacy & beveiliging → Browsegegevens wissen... → Tijdelijk opgeslgen afbeeldingen en bestanden.", "usercssyoucanpreview": "'''Tip:''' gebruik de knop \"{{int:showpreview}}\" om uw nieuwe CSS te testen alvorens op te slaan.", @@ -1049,7 +1049,7 @@ "prefs-watchlist-days-max": "Maximaal $1 {{PLURAL:$1|dag|dagen}}", "prefs-watchlist-edits": "Maximaal aantal bewerkingen in de volglijst:", "prefs-watchlist-edits-max": "Maximale aantal: 1000", - "prefs-watchlist-token": "Volglijstsleutel:", + "prefs-watchlist-token": "Volglijsttoken:", "prefs-misc": "Diversen", "prefs-resetpass": "Wachtwoord wijzigen", "prefs-changeemail": "E-mailadres wijzigen of verwijderen", @@ -1067,7 +1067,7 @@ "recentchangesdays-max": "(maximaal $1 {{PLURAL:$1|dag|dagen}})", "recentchangescount": "Standaard aantal weer te geven bewerkingen:", "prefs-help-recentchangescount": "Dit geldt voor recente wijzigingen, paginageschiedenis en logboekpagina's.", - "prefs-help-watchlist-token2": "Dit is de geheime sleutel voor de webfeed van uw volglijst.\nIedereen die het token kent, kan uw volglijst bekijken, dus deel dit token niet.\nU kunt de [[Special:ResetTokens|tokens opnieuw instellen]] als u dat wilt.", + "prefs-help-watchlist-token2": "Dit is de geheime sleutel voor de webfeed van uw volglijst.\nIedereen die het token kent, kan uw volglijst bekijken, dus deel dit token niet.\nIndien nodig kunt u [[Special:ResetTokens|tokens opnieuw instellen]].", "savedprefs": "Uw voorkeuren zijn opgeslagen.", "savedrights": "De gebruikergroepen van {{GENDER:$1|$1}} zijn opgeslagen.", "timezonelegend": "Tijdzone:", @@ -1087,6 +1087,7 @@ "timezoneregion-indian": "Indische Oceaan", "timezoneregion-pacific": "Stille Oceaan", "allowemail": "Andere gebruikers toestaan mij e-mails te sturen", + "email-allow-new-users-label": "E-mails van gloednieuwe gebruikers toestaan", "email-blacklist-label": "Voorkom dat deze gebruikers e-mails naar mij kunnen sturen:", "prefs-searchoptions": "Zoeken", "prefs-namespaces": "Naamruimten", @@ -1540,7 +1541,7 @@ "recentchangeslinked-feed": "Verwante wijzigingen", "recentchangeslinked-toolbox": "Verwante wijzigingen", "recentchangeslinked-title": "Wijzigingen verwant aan \"$1\"", - "recentchangeslinked-summary": "Deze speciale pagina geeft de laatste bewerkingen weer op pagina's waarheen verwezen wordt vanaf een opgegeven pagina of op pagina's in een opgegeven categorie.\nPagina's die op [[Special:Watchlist|uw volglijst]] staan worden '''vet''' weergegeven.", + "recentchangeslinked-summary": "Voer een paginanaam in om bewerkingen te zien van pagina's waarheen vanaf die pagina verwezen wordt of die ernaar verwijzen. (Om leden van een categorie te zien, voert u Categorie:''Naam van categorie'' in.) Bewerkingen van pagina's op [[Special:Watchlist|uw volglijst]] worden vet weergegeven.", "recentchangeslinked-page": "Paginanaam:", "recentchangeslinked-to": "Wijzigingen aan pagina's met koppelingen naar deze pagina bekijken", "recentchanges-page-added-to-category": "[[:$1]] aan categorie toegevoegd", @@ -1737,9 +1738,11 @@ "uploadstash-bad-path-invalid": "Pad is ongeldig.", "uploadstash-bad-path-unknown-type": "Onbekend type \"$1\".", "uploadstash-bad-path-unrecognized-thumb-name": "Miniatuurnaam onbekend.", + "uploadstash-bad-path-no-handler": "Geen handler gevonden voor mime $1 van bestand $2.", "uploadstash-bad-path-bad-format": "Sleutel \"$1\" is niet in het juiste formaat.", "uploadstash-file-not-found": "Sleutel \"$1\" niet gevonden in de opslag.", "uploadstash-file-not-found-no-thumb": "Kon geen miniatuur verkrijgen.", + "uploadstash-file-not-found-no-local-path": "Geen lokaal pad voor geschaalde afbeelding.", "uploadstash-file-not-found-no-object": "Kan geen lokaal bestandsobject voor het miniatuur aanmaken.", "uploadstash-file-not-found-no-remote-thumb": "Ophalen van het miniatuur mislukt: $1\nurl = $2", "uploadstash-file-not-found-missing-content-type": "content-type koptekst ontbreekt.", @@ -1782,7 +1785,7 @@ "listfiles-delete": "verwijderen", "listfiles-summary": "Op deze speciale pagina zijn alle toegevoegde bestanden te bekijken.", "listfiles_search_for": "Zoeken naar bestand:", - "listfiles-userdoesnotexist": "Het gebruikersaccount \"$1\" bestaat niet.", + "listfiles-userdoesnotexist": "Gebruikersaccount \"$1\" bestaat niet.", "imgfile": "bestand", "listfiles": "Bestandslijst", "listfiles_thumb": "Miniatuur", @@ -2123,7 +2126,7 @@ "listgrouprights-removegroup": "Gebruikers uit de volgende {{PLURAL:$2|groep|groepen}} verwijderen: $1", "listgrouprights-addgroup-all": "Gebruikers aan alle groepen toevoegen", "listgrouprights-removegroup-all": "Gebruikers uit alle groepen verwijderen", - "listgrouprights-addgroup-self": "De volgende {{PLURAL:$2|groep|groepen}} toevoegen aan eigen gebruiker: $1", + "listgrouprights-addgroup-self": "De volgende {{PLURAL:$2|groep|groepen}} toevoegen aan eigen account: $1", "listgrouprights-removegroup-self": "De volgende {{PLURAL:$2|groep|groepen}} verwijderen van eigen gebruiker: $1", "listgrouprights-addgroup-self-all": "Alle groepen toevoegen aan eigen account", "listgrouprights-removegroup-self-all": "Alle groepen verwijderen van eigen account", @@ -2131,7 +2134,7 @@ "listgrouprights-namespaceprotection-namespace": "Naamruimte", "listgrouprights-namespaceprotection-restrictedto": "Recht(en) waardoor gebruiker kan bewerken", "listgrants": "Toestemmingen", - "listgrants-summary": "Hieronder staat een lijst met toestemmingen en de bijbehorende gebruikersrechten. Gebruikers kunnen toepassingen machtigen voor toegang tot hun account, maar met beperkte rechten gebaseerd op de toestemmingen die de gebruiker aan de toepassing heeft gegeven. Een toepassing die namens een gebruiker handelt, kan nooit rechten gebruiken die een gebruiker niet heeft.\nEr zijn mogelijk [[{{MediaWiki:Listgrouprights-helppage}}|aanvullende gegevens]] over individuele rechten.", + "listgrants-summary": "Hieronder staat een lijst met toestemmingen en de bijbehorende gebruikersrechten. Gebruikers kunnen toepassingen machtigen hun account te gebruiken, maar met beperkte rechten gebaseerd op de toestemmingen die de gebruiker aan de toepassing heeft gegeven. Een toepassing die namens een gebruiker handelt, kan echter geen rechten gebruiken die de gebruiker niet heeft.\nEr is mogelijk [[{{MediaWiki:Listgrouprights-helppage}}|aanvullende informatie]] over individuele rechten.", "listgrants-grant": "Toestemming", "listgrants-rights": "Rechten", "trackingcategories": "Volgcategorieën", @@ -2323,7 +2326,7 @@ "protect-text": "Hier kunt u het beveiligingsniveau voor de pagina '''$1''' bekijken en wijzigen.", "protect-locked-blocked": "U kunt het beveiligingsniveau niet wijzigen terwijl u geblokkeerd bent.\nDit zijn de huidige instellingen voor de pagina '''$1''':", "protect-locked-dblock": "Het beveiligingsniveau kan niet worden gewijzigd, omdat de database gesloten is.\nHier zijn de huidige instellingen voor de pagina '''$1''':", - "protect-locked-access": "U hebt geen rechten om het beveiligingsniveau te wijzigen.\nDit zijn de huidige instellingen voor de pagina '''$1''':", + "protect-locked-access": "Uw account heeft geen rechten om het beveiligingsniveau van pagina's te wijzigen.\nDit zijn de huidige instellingen voor de pagina $1:", "protect-cascadeon": "Deze pagina is beveiligd, omdat die in de volgende {{PLURAL:$1|pagina|pagina's}} is opgenomen, die beveiligd {{PLURAL:$1|is|zijn}} met de cascade-optie.\nWijzigingen aan beveiligingsniveau hebben geen invloed op de cascadebeveiliging.", "protect-default": "Toestaan voor alle gebruikers", "protect-fallback": "Alleen gebruikers met het recht \"$1\" toestaan", @@ -2406,14 +2409,14 @@ "mycontris": "Bijdragen", "anoncontribs": "Bijdragen", "contribsub2": "Voor {{GENDER:$3|$1}} ($2)", - "contributions-userdoesnotexist": "De account \"$1\" is niet geregistreerd.", + "contributions-userdoesnotexist": "Gebruikersaccount \"$1\" bestaat niet.", "nocontribs": "Geen wijzigingen gevonden die aan de gestelde criteria voldoen.", "uctop": "(laatste wijziging)", "month": "Van maand (en eerder):", "year": "Van jaar (en eerder):", - "sp-contributions-newbies": "Alleen de bijdragen van nieuwe gebruikers bekijken", + "sp-contributions-newbies": "Alleen bijdragen van nieuwe accounts bekijken", "sp-contributions-newbies-sub": "Voor nieuwelingen", - "sp-contributions-newbies-title": "Bijdragen van nieuwe gebruikers", + "sp-contributions-newbies-title": "Gebruikersbijdragen van nieuwe accounts", "sp-contributions-blocklog": "blokkeerlogboek", "sp-contributions-suppresslog": "onderdrukte {{GENDER:$1|gebruikersbijdragen}}", "sp-contributions-deleted": "verwijderde {{GENDER:$1|gebruiker}}sbijdragen", @@ -2456,7 +2459,7 @@ "ipaddressorusername": "IP-adres of gebruikersnaam:", "ipbexpiry": "Vervalt (maak een keuze):", "ipbreason": "Reden:", - "ipbreason-dropdown": "*Veelvoorkomende redenen voor blokkades\n** Foutieve informatie invoeren\n** Verwijderen van informatie uit pagina's\n** Spamkoppeling naar externe websites\n** Invoegen van nonsens in pagina's\n** Intimiderend gedrag\n** Misbruik door meerdere gebruikers\n** Onaanvaardbare gebruikersnaam", + "ipbreason-dropdown": "*Veelvoorkomende redenen voor blokkades\n** Foutieve informatie invoeren\n** Informatie uit pagina's verwijderen\n** Veelvuldig koppelingen naar externe websites plaatsen\n** Nonsens/gebrabbel in pagina's opnemen\n** Intimiderend gedragen/anderen lastigvallen\n** Van meerdere accounts misbruik maken\n** Een onaanvaardbare gebruikersnaam kiezen", "ipb-hardblock": "Aangemelde gebruikers de mogelijkheid ontnemen om vanaf dit IP-adres te bewerken", "ipbcreateaccount": "Registreren accounts blokkeren", "ipbemailban": "Gebruiker de mogelijkheid ontnemen om e-mail te versturen", @@ -2544,7 +2547,7 @@ "ipb_expiry_invalid": "Ongeldige duur.", "ipb_expiry_old": "Vervaldatum is in het verleden.", "ipb_expiry_temp": "Blokkades voor verborgen gebruikers moeten permanent zijn.", - "ipb_hide_invalid": "Het is niet mogelijk dit account te verbergen; het heeft meer dan {{PLURAL:$1|een bewerking|$1 bewerkingen}}.", + "ipb_hide_invalid": "Het is niet mogelijk dit account te verbergen; het heeft meer dan {{PLURAL:$1|één bewerking|$1 bewerkingen}}.", "ipb_already_blocked": "\"$1\" is al geblokkeerd", "ipb-needreblock": "$1 is al geblokkeerd.\nWilt u de instellingen wijzigen?", "ipb-otherblocks-header": "Andere {{PLURAL:$1|blokkade|blokkades}}", @@ -2952,7 +2955,7 @@ "newimages-legend": "Bestandsnaam", "newimages-label": "Bestandsnaam (of deel daarvan):", "newimages-user": "IP-adres of gebruikersnaam", - "newimages-newbies": "Alleen de bijdragen van nieuwe gebruikers bekijken", + "newimages-newbies": "Alleen bijdragen van nieuwe accounts bekijken", "newimages-showbots": "Uploads door bots weergeven", "newimages-hidepatrolled": "Gecontroleerde uploads verbergen", "newimages-mediatype": "Mediatype:", @@ -3727,11 +3730,11 @@ "logentry-move-move_redir-noredirect": "$1 {{GENDER:$2|heeft}} pagina $3 naar $4 hernoemd over een doorverwijzing zonder een doorverwijzing achter te laten", "logentry-patrol-patrol": "$1 {{GENDER:$2|heeft}} versie $4 van pagina $3 gemarkeerd als gecontroleerd", "logentry-patrol-patrol-auto": "$1 {{GENDER:$2|heeft}} versie $4 van pagina $3 automatisch gemarkeerd als gecontroleerd", - "logentry-newusers-newusers": "Gebruikersaccount $1 {{GENDER:$2|is}} aangemaakt", + "logentry-newusers-newusers": "Gebruikersaccount $1 is {{GENDER:$2|aangemaakt}}", "logentry-newusers-create": "Gebruikersaccount $1 {{GENDER:$2|is}} aangemaakt", "logentry-newusers-create2": "Gebruikersaccount $3 is {{GENDER:$2|aangemaakt}} door $1", "logentry-newusers-byemail": "Gebruikersaccount $3 is {{GENDER:$2|aangemaakt}} door $1 en het wachtwoord is per e-mail verzonden", - "logentry-newusers-autocreate": "Gebruikersaccount $1 {{GENDER:$2|is}} automatisch aangemaakt", + "logentry-newusers-autocreate": "Gebruikersaccount $1 is automatisch {{GENDER:$2|aangemaakt}}", "logentry-protect-move_prot": "$1 heeft de beveiligingsinstellingen {{GENDER:$2|verplaatst}} van $4 naar $3", "logentry-protect-unprotect": "$1 heeft de beveiliging {{GENDER:$2|opgeheven}} van $3", "logentry-protect-protect": "$1 heeft $3 {{GENDER:$2|beveiligd}} $4", @@ -3784,7 +3787,7 @@ "feedback-useragent": "Useragent:", "searchsuggest-search": "Doorzoek {{SITENAME}}", "searchsuggest-containing": "bevat...", - "api-error-badtoken": "Interne fout: het token klopt niet.", + "api-error-badtoken": "Interne fout: Foutief token.", "api-error-emptypage": "Het aanmaken van nieuwe, lege pagina's is niet toegestaan.", "api-error-publishfailed": "Interne fout: de server kon het tijdelijke bestand niet publiceren.", "api-error-stashfailed": "Interne fout: de server kon het tijdelijke bestand niet opslaan.", @@ -4003,9 +4006,9 @@ "authmanager-provider-password-domain": "Wachtwoord- en domeingebaseerde authentificatie", "authmanager-provider-temporarypassword": "Tijdelijk wachtwoord", "authprovider-confirmlink-message": "Op basis van uw recente aanmeldpogingen kunnen de volgende accounts aan uw wiki-account worden gekoppeld. Het koppelen stelt u in staat in te loggen via deze accounts. Selecteer welke accounts gekoppeld moeten worden.", - "authprovider-confirmlink-request-label": "Accounts die aan elkaar moeten worden gekoppeld.", + "authprovider-confirmlink-request-label": "Accounts die aan elkaar gekoppeld moeten worden.", "authprovider-confirmlink-success-line": "$1: Succesvol gekoppeld.", - "authprovider-confirmlink-failed": "Account koppelen is niet volledig gelukt: $1", + "authprovider-confirmlink-failed": "Het koppelen van accounts is niet volledig gelukt: $1", "authprovider-confirmlink-ok-help": "Doorgaan na het weergeven van de storingsmeldingen over het koppelen.", "authprovider-resetpass-skip-label": "Overslaan", "authprovider-resetpass-skip-help": "Sla het resetten van het wachtwoord over.", @@ -4018,10 +4021,10 @@ "specialpage-securitylevel-not-allowed": "Sorry, het is u niet toegestaan gebruik te maken van deze pagina omdat uw identiteit niet kon worden geverifieerd.", "authpage-cannot-login": "Niet in staat om aan te melden.", "authpage-cannot-login-continue": "Niet in staat om in te loggen. Uw sessie is waarschijnlijk verlopen.", - "authpage-cannot-create": "Kon het account aanmaken niet starten.", - "authpage-cannot-create-continue": "Niet in staat het account aan te maken. Uw sessie is waarschijnlijk verlopen.", - "authpage-cannot-link": "Niet in staat om het account te koppelen.", - "authpage-cannot-link-continue": "Niet in staat het account te koppelen. Uw sessie is waarschijnlijk verlopen.", + "authpage-cannot-create": "Niet in staat het aanmaken van het account te starten.", + "authpage-cannot-create-continue": "Niet in staat het aanmaken van het account voort te zetten. Uw sessie is waarschijnlijk verlopen.", + "authpage-cannot-link": "Niet in staat het koppelen van de accounts te starten.", + "authpage-cannot-link-continue": "Niet in staat het koppelen van de accounts voort te zetten. Uw sessie is waarschijnlijk verlopen.", "cannotauth-not-allowed-title": "Geen toegang", "cannotauth-not-allowed": "U hebt geen toestemming om deze pagina te gebruiken", "changecredentials": "Authenticatiegegevens wijzigen", @@ -4033,7 +4036,7 @@ "removecredentials-invalidsubpage": "$1 is geen geldig identificatietype.", "removecredentials-success": "Uw authenticatiegegevens zijn verwijderd.", "credentialsform-provider": "Soort authenticatiegegevens:", - "credentialsform-account": "Gebruikersnaam:", + "credentialsform-account": "Accountnaam:", "cannotlink-no-provider-title": "Er zijn geen accounts om te koppelen", "cannotlink-no-provider": "Er zijn geen accounts om te koppelen.", "linkaccounts": "Accounts koppelen", diff --git a/languages/i18n/ps.json b/languages/i18n/ps.json index ecc4824560..7427f03122 100644 --- a/languages/i18n/ps.json +++ b/languages/i18n/ps.json @@ -699,6 +699,8 @@ "defaultmessagetext": "تلواليزه پيغام متن", "invalid-content-data": "د ناباوره منځپانګې ډاټا", "content-not-allowed-here": "\"$1\" په پاڼه کې منځپانګې ته اجازه نشته [[$2]]", + "editpage-invalidcontentmodel-text": "د منځپانګې موډيول \"$1\" ملاتړ ندی شوی.", + "editpage-notsupportedcontentformat-title": "د منځپانګې بڼه نده ملاتړ شوې", "content-model-wikitext": "ويکي متن", "content-model-text": "ساده متن", "content-model-javascript": "جاواسکرېپټ", @@ -713,6 +715,8 @@ "post-expand-template-inclusion-category": "هغه مخونه چې په کې د کارېدلو کينډيو شمېر له ټاکلې کچې ډېر دی", "post-expand-template-argument-warning": "'''گواښنه:''' دا مخ لږ تر لږه د يوې کينډۍ عاملين لري چې بې حده لوی دی.\nدا عاملين ړنگ شول.", "post-expand-template-argument-category": "هغه مخونه چې د کينډۍ ړنگ شوي عاملين لري.", + "parser-template-loop-warning": "د کينډۍ پته ترلاسه شوه: [[$1]]", + "template-loop-category": "مخونه له کينډۍ پته ترلاسه شوو سره", "undo-failure": "د منازعې منځنۍ برخې د بدلونونو له امله دا سمون ندی رد شوی.", "undo-norev": "دا سمون ناکړل کېدای نه شي دا ځکه چې دا سمون نشته او يا هم ړنگ شوی.", "viewpagelogs": "د دې مخ يادښتونه کتل", @@ -804,6 +808,7 @@ "mergehistory-list": "د اخږلو وړ سمون پېښليک", "mergehistory-go": "اخږلو وړ سمونونه ښکاره کول", "mergehistory-submit": "بڼې سره يوځای کول", + "mergehistory-empty": "هیڅ بدلون نه شي کیدای.", "mergehistory-done": "د $1 $3 {{PLURAL:$3|بڼه|بڼې}} په برياليتوب سره و [[:$2]] کې {{PLURAL:$3|واخږل شو|واخږل شول}}.", "mergehistory-fail-bad-timestamp": "وخت ټاپه ناسمه ده.", "mergehistory-fail-invalid-source": "د مخ سرچينې ناباوره دي.", @@ -978,6 +983,7 @@ "prefs-editor": "سمونگر", "prefs-preview": "مخليدنه", "prefs-advancedrc": "پرمختللې خوښنې", + "prefs-opt-out": "د پرمختګونو څخه لرې کول", "prefs-advancedrendering": "پرمختللې خوښنې", "prefs-advancedsearchoptions": "پرمختللې خوښنې", "prefs-advancedwatchlist": "پرمختللې خوښنې", @@ -1164,12 +1170,15 @@ "recentchanges-label-plusminus": "د بايټونو د شمېر له مخې د مخ د بدلون کچه", "recentchanges-legend-heading": "لنډونونه:", "recentchanges-legend-newpage": "{{int:recentchanges-label-newpage}} ([[Special:NewPages|د نويو مخونو لړليک]] هم وگورئ)", - "recentchanges-legend-plusminus": "(±123)", + "recentchanges-legend-plusminus": "(±۱۲۳)", "recentchanges-submit": "ښکاره کول", "rcfilters-tag-remove": "لرې کړئ'$1'", + "rcfilters-legend-heading": "د لنډیزونو لړليک:", + "rcfilters-other-review-tools": "د بیاکتنې نور وسايل", "rcfilters-activefilters": "فعال فيلټرونه", "rcfilters-advancedfilters": "پرمختللي فلټرونه", "rcfilters-limit-title": "د ښودلو لپاره بدلونونه", + "rcfilters-limit-and-date-label": "{{PLURAL:$1|بدلونونه|$1 بدلونونه}}، $2", "rcfilters-days-title": "وروستي ورځي", "rcfilters-hours-title": "وروستي ساعتونه", "rcfilters-days-show-days": "$1 {{PLURAL:$1|day|ورځې}}", @@ -1177,6 +1186,7 @@ "rcfilters-highlighted-filters-list": "لوړ شوی: $1", "rcfilters-quickfilters": "خوندي شوی فلټرونه", "rcfilters-quickfilters-placeholder-title": "هيڅ فيلټر نه دي صفت سوي", + "rcfilters-quickfilters-placeholder-description": "ددي لپاره چي د خپل فلټر امستنې سم کړي، او بيايې په دوهم پړاو کې وکاروي، د فعال فلټر ساحې لاندې د بکمارک په نښه کېکاږئ.", "rcfilters-savedqueries-defaultlabel": "خوندي شوی فيلټرونه", "rcfilters-savedqueries-rename": "نوم بدلول", "rcfilters-savedqueries-setdefault": "د فرض په ډول کښېږدي.", @@ -1188,7 +1198,11 @@ "rcfilters-savedqueries-apply-and-setdefault-label": "د فرض په ډول د فيلټر جوړول", "rcfilters-savedqueries-cancel-label": "ناگارل", "rcfilters-savedqueries-add-new-title": "د امستنې اوسنۍ فيلټر خوندي کړي", + "rcfilters-search-placeholder": "د فلټر بدلونونه (د مینو کارول یا د فلټر نوم لټونه)", + "rcfilters-invalid-filter": "غلط فلټر", + "rcfilters-empty-filter": "هيڅ فعال فلټر نشته. ټولي سمونې ښکاره شوي.", "rcfilters-filterlist-title": "چاڼگران", + "rcfilters-filterlist-whatsthis": "دوي سنګه کار کوي؟", "rcfilters-highlightmenu-title": "يو رنګ وټاکۍ", "rcfilters-filter-editsbyself-label": "بدلونونه ستاسو لخوا", "rcfilters-filter-editsbyself-description": "ستاسو خپل بدلونونه.", @@ -1217,6 +1231,7 @@ "rcfilters-filter-unpatrolled-description": "سمونې چي د ګزمې په توګه نه دي په نښه شوي.", "rcfilters-filtergroup-significance": "ارزښت", "rcfilters-filter-minor-label": "وړوکي سمونونه", + "rcfilters-filtergroup-watchlist": "د کتنلړ مخونه", "rcfilters-filter-watchlist-watched-label": "په کتنلړ کي", "rcfilters-filter-watchlist-notwatched-label": "په کتنلړ کې ندی", "rcfilters-filter-watchlist-notwatched-description": "هرڅه ستاسو په کتنلړ کې پرته ستاسو د بدلونونو مخونه.", @@ -1412,6 +1427,7 @@ "zip-wrong-format": "ځانگړې شوې دوتنه يوه ZIP دوتنه نه وه.", "uploadstash": "پورته کول سټش", "uploadstash-refresh": "د دوتنو لړليک بياتازه کول", + "uploadstash-bad-path-unknown-type": "ناڅرگنده ډول \"$1\".", "img-auth-accessdenied": "لاسرسی رد شو", "img-auth-nofile": "د $1 په نوم کومه دوتنه نشته.", "img-auth-streaming": "سټريمينګ \"$1\".", @@ -1638,6 +1654,8 @@ "apisandbox-results": "پايلې", "apisandbox-request-url-label": "د URL غوښتنه کول:", "apisandbox-request-time": "د غوښتنې وخت: {{PLURAL:$1|$1 م.ث}}", + "apisandbox-continue": "پرله پورې", + "apisandbox-continue-clear": "سپينول", "booksources": "د کتاب سرچينې", "booksources-search-legend": "د کتابي سرچينو پلټنه", "booksources-isbn": "ISBN:", @@ -1711,6 +1729,7 @@ "listgrouprights-namespaceprotection-header": "د نومتشيال محدوديتونه", "listgrouprights-namespaceprotection-namespace": "نوم-تشيال", "listgrouprights-namespaceprotection-restrictedto": "د کارن سمون ترسره کولو رښته(رښتې)", + "listgrants": "منلې", "listgrants-rights": "رښتې", "trackingcategories": "موندونکې وېشنيزې", "trackingcategories-summary": "په دې مخ کې هغه موندونکې وېشنيزې چې په اتوماتيک ډول د مېډياويکي ساوترې لخوا ډکېږي، د لړليک په توگه راغلي. د وېشنيزو نومونه د اړونده غونډال پيغامونو په بدلون سره چې د {{ns:8}} په نومتشيال کې دي، د بدلېدلو وړتيا لري.", @@ -1927,7 +1946,7 @@ "sp-contributions-uploads": "پورته کېدنې", "sp-contributions-logs": "يادښتونه", "sp-contributions-talk": "خبرې اترې", - "sp-contributions-userrights": "د کارن رښتو سمبالښت", + "sp-contributions-userrights": "د {{GENDER:$1|کارن}} رښتو سمبالښت", "sp-contributions-blocked-notice": "دم مهال په دې کارن بنديز لگېدلی.\nد بنديز يادښت تازه مالومات په لاندې توگه دي:", "sp-contributions-search": "د ونډو پلټنه", "sp-contributions-username": "IP پته يا کارن-نوم:", @@ -1954,7 +1973,7 @@ "block": "په کارن بنديز لگول", "unblock": "کارن له بنديزه وېستل", "blockip": "په {{GENDER:$1|کارن}} بنديز لگول", - "blockiptext": "د لاندينۍ فورمې په کارولو سره تاسې يو کارن او يا هم يوې ځانگړې IP پتې باندې د ليکلو بنديزونه لگولی شی. \nدا بايد د پوهې سره دښمنۍ او ورانکارۍ د مخنيولو په تکل او د پښتو ويکيپېډيا د [[{{MediaWiki:Policy-url}}|تگلارې]] سره سم پلي شي.\nد بنديز لپاره مو يو ځانگړی دليل لاندې روښانه کړئ (د ساري په توگه، هغه مخونو ښکاره کول چې ورانکاري په کې ترسره شوې).", + "blockiptext": "د لاندينۍ فورمې په کارولو سره تاسې يو کارن او يا هم يوې ځانگړې IP پتې باندې د ليکلو بنديزونه لگولی شی. \nدا بايد د پوهې سره دښمنۍ او ورانکارۍ د مخنيولو په تکل او د پښتو ويکيپېډيا د [[{{MediaWiki:Policy-url}}|تگلارې]] سره سم پلي شي.\nد بنديز لپاره مو يو ځانگړی دليل لاندې روښانه کړئ (د ساري په توگه، هغه مخونو ښکاره کول چې ورانکاري په کې ترسره شوې).\n[https://ps.wikipedia.org/wiki/Classless_Inter-Domain_Routing سي ډي اي ار] نخښه; the آر اجازه ورکول رینج دی /$1 لپاره د اي پي وي ۴ او /$2 لپاره د اي پي وي ۶.", "ipaddressorusername": "IP پته يا کارن نوم", "ipbexpiry": "د پای نېټه:", "ipbreason": "سبب:", @@ -2288,10 +2307,12 @@ "newimages-legend": "چاڼگر", "newimages-label": "د دوتنې نوم (يا د دې برخه):", "newimages-showbots": "د روباټونو لخوا پورته کېدنې ښکاره کول", + "newimages-mediatype": "د رسنۍ ډول:", "noimages": "د کتلو لپاره څه نشته.", "ilsubmit": "پلټل", "bydate": "د نېټې له مخې", "sp-newimages-showfrom": "هغه نوې دوتنې چې په $1 په $2 بجو پيلېږي ښکاره کول", + "minutes-abbrev": "$1 دقیقي", "hours-abbrev": "$1 گ", "seconds": "{{PLURAL:$1|$1 ثانيه|$1 ثانيې}}", "minutes": "{{PLURAL:$1|$1 دقيقه|$1 دقيقې}}", @@ -2912,9 +2933,13 @@ "authform-notoken": "نادرکه نښه", "authform-wrongtoken": "ناسمه نښه", "specialpage-securitylevel-not-allowed-title": "اجازه نسته", + "cannotauth-not-allowed-title": "د اجازې تفصيل", "changecredentials-submit": "بدلول", "removecredentials-submit": "غورځول", + "credentialsform-provider": "د اعتبار وړ ډول:", "credentialsform-account": "گڼون نوم:", + "cannotlink-no-provider-title": "دلته د منلو وړ حساب شتون نلري.", + "cannotlink-no-provider": "دلته د منلو وړ حساب شتون نلري.", "linkaccounts": "ورګډ سوي ګڼونونه", "linkaccounts-success-text": "ګڼون ورګډ سو.", "linkaccounts-submit": "لينک کڼوڼونه", diff --git a/languages/i18n/pt.json b/languages/i18n/pt.json index 6808e60629..4832e91548 100644 --- a/languages/i18n/pt.json +++ b/languages/i18n/pt.json @@ -1084,6 +1084,7 @@ "timezoneregion-indian": "Oceano Índico", "timezoneregion-pacific": "Oceano Pacífico", "allowemail": "Permitir que outros utilizadores me enviem correio eletrónico", + "email-allow-new-users-label": "Permitir mensagens de correio de utilizadores novos", "email-blacklist-label": "Proibir estes utilizadores de me enviarem correio eletrónico:", "prefs-searchoptions": "Pesquisa", "prefs-namespaces": "Domínios", @@ -1496,7 +1497,7 @@ "rcfilters-filter-showlinkedfrom-option-label": "Mostrar mudanças de páginas PARA AS QUAIS uma página contém hiperligações", "rcfilters-filter-showlinkedto-label": "Mostrar mudanças de páginas que contêm hiperligações para a página", "rcfilters-filter-showlinkedto-option-label": "Mostrar mudanças de páginas QUE CONTÊM hiperligações para uma página", - "rcfilters-target-page-placeholder": "Selecionar uma página", + "rcfilters-target-page-placeholder": "Introduzir o nome de uma página", "rcnotefrom": "Abaixo {{PLURAL:$5|está a mudança|estão as mudanças}} desde $2 (mostradas até $1).", "rclistfromreset": "Reiniciar a seleção da data", "rclistfrom": "Mostrar as novas mudanças a partir das $2 de $3", @@ -1541,7 +1542,7 @@ "recentchangeslinked-feed": "Alterações relacionadas", "recentchangeslinked-toolbox": "Alterações relacionadas", "recentchangeslinked-title": "Alterações relacionadas com \"$1\"", - "recentchangeslinked-summary": "Esta é uma lista de mudanças recentes a todas as páginas para as quais a página fornecida contém hiperligações (ou de todas as que pertencem à categoria fornecida).\nAs suas [[Special:Watchlist|páginas vigiadas]] aparecem a negrito.", + "recentchangeslinked-summary": "Introduza o nome de uma página para ver as mudanças a todas as páginas que contêm hiperligações para ela ou para as quais a página fornecida contém hiperligações (para ver as que pertencem a uma categoria, introduza Categoria:Nome da categoria). As mudanças às suas [[Special:Watchlist|páginas vigiadas]] aparecem a negrito.", "recentchangeslinked-page": "Nome da página:", "recentchangeslinked-to": "Inversamente, mostrar mudanças às páginas que contêm hiperligações para esta", "recentchanges-page-added-to-category": "[[:$1]] foi adicionada à categoria", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index f3f44c8eb5..209838e0ae 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -2666,7 +2666,7 @@ "mycontris": "In the personal urls page section - right upper corner.\n\nSee also:\n* {{msg-mw|Mycontris}}\n* {{msg-mw|Accesskey-pt-mycontris}}\n* {{msg-mw|Tooltip-pt-mycontris}}\n{{Identical|Contribution}}", "anoncontribs": "Same as {{msg-mw|mycontris}} but used for non-logged-in users.\n\nSee also:\n* {{msg-mw|Accesskey-pt-anoncontribs}}\n* {{msg-mw|Tooltip-pt-anoncontribs}}\n{{Identical|Contribution}}", "contribsub2": "Contributions for \"user\" (links). Parameters:\n* $1 is an IP address or a username, with a link which points to the user page (if registered user).\n* $2 is list of tool links. The list contains a link which has text {{msg-mw|Sp-contributions-talk}}.\n* $3 is a plain text username used for GENDER.\n{{Identical|For $1}}", - "contributions-userdoesnotexist": "This message is used in [[Special:Contributions]]. It is used to tell the user that the name he searched for doesn't exists.\n\nParameters:\n* $1 - a username\n{{Identical|Userdoesnotexist}}", + "contributions-userdoesnotexist": "This message is used in [[Special:Contributions]]. It is used to tell the user that the name he searched for doesn't exist.\n\nParameters:\n* $1 - a username\n{{Identical|Userdoesnotexist}}", "nocontribs": "Used in [[Special:Contributions]] and [[Special:DeletedContributions]].\n\nSee examples: [[Special:Contributions/x]] and [[Special:DeletedContributions/x]].\n\nParameters:\n* $1 - (Unused) the user name", "uctop": "This message is used in [[Special:Contributions]]. It is used to show that a particular edit was the last made to a page. Example: 09:57, 11 February 2008 (hist) (diff) Pagename‎ (edit summary) (current)\n{{Identical|Current}}", "month": "Used in [[Special:Contributions]] and history pages ([{{fullurl:Sandbox|action=history}} example]), as label for a dropdown box to select a specific month to view the edits made in that month, and the earlier months. See also {{msg-mw|year}}.", diff --git a/languages/i18n/ru.json b/languages/i18n/ru.json index c898e138c4..1c3f0a1af8 100644 --- a/languages/i18n/ru.json +++ b/languages/i18n/ru.json @@ -1523,6 +1523,7 @@ "rcfilters-watchlist-showupdated": "Изменения страниц, которые вы не посещали с того момента, как они изменились, выделены жирным и отмечены полным маркером.", "rcfilters-preference-label": "Скрыть улучшенную версию Последних изменений", "rcfilters-preference-help": "Откатывает редизайн интерфейса 2017 года и все инструменты, добавленные с тех пор.", + "rcfilters-target-page-placeholder": "Введите имя страницы", "rcnotefrom": "Ниже {{PLURAL:$5|указано изменение|перечислены изменения}} с $3, $4 (показано не более $1).", "rclistfromreset": "Сбросить выбор даты", "rclistfrom": "Показать изменения с $3 $2.", @@ -3433,6 +3434,8 @@ "autosumm-blank": "Полностью удалено содержимое страницы", "autosumm-replace": "Содержимое страницы заменено на «$1»", "autoredircomment": "Перенаправление на [[$1]]", + "autosumm-removed-redirect": "Удалённое перенаправление на [[$1]]", + "autosumm-changed-redirect-target": "Перенаправление изменено с [[$1]] на [[$2]]", "autosumm-new": "Новая страница: «$1»", "autosumm-newblank": "Создана пустая страница", "size-bytes": "$1 {{PLURAL:$1|байт|байта|байт}}", @@ -3642,7 +3645,7 @@ "tag-mw-blank-description": "Правки, которые очищают страницу", "tag-mw-replace": "Заменено", "tag-mw-replace-description": "Правки, которые удаляют более 90 % содержимого страницы", - "tag-mw-rollback": "Откат", + "tag-mw-rollback": "откат", "tag-mw-rollback-description": "Правки, которые откатывают предыдущие правки по нажатию ссылки отката", "tags-title": "Метки", "tags-intro": "На этой странице приведён список меток, которыми программное обеспечение отмечает правки, а также значения этих меток.", diff --git a/languages/i18n/sl.json b/languages/i18n/sl.json index 6ccd85153c..978fcdd5e5 100644 --- a/languages/i18n/sl.json +++ b/languages/i18n/sl.json @@ -1014,6 +1014,7 @@ "timezoneregion-indian": "Indijski ocean", "timezoneregion-pacific": "Tihi ocean", "allowemail": "Drugim uporabnikom omogoči pošiljanje e-pošte", + "email-allow-new-users-label": "Dovoli e-pošto od čisto novih uporabnikov", "email-blacklist-label": "Prepreči naslednjim uporabnikom, da mi pošiljajo e-pošto:", "prefs-searchoptions": "Iskanje", "prefs-namespaces": "Imenski prostori", diff --git a/languages/i18n/th.json b/languages/i18n/th.json index c7911a04bf..cfb1ef8c7d 100644 --- a/languages/i18n/th.json +++ b/languages/i18n/th.json @@ -1343,7 +1343,7 @@ "rcfilters-filter-user-experience-level-unregistered-description": "ผู้ใช้ไม่ล็อกอิน", "rcfilters-filter-user-experience-level-newcomer-label": "ผู้ที่มาใหม่", "rcfilters-filter-user-experience-level-newcomer-description": "ผู้ใช้ลงทะเบียนที่แก้ไขน้อยกว่า 10 ครั้งหรืออายุน้อยกว่า 4 วัน", - "rcfilters-filter-user-experience-level-learner-label": "ผู้เรียน", + "rcfilters-filter-user-experience-level-learner-label": "ผู้เรียนรู้", "rcfilters-filter-user-experience-level-learner-description": "ผู้ใช้ลงทะเบียนที่มีประสบการณ์อยู่ระหว่าง \"ผู้มาใหม่\" กับ \"ผู้ใช้มีประสบการณ์\"", "rcfilters-filter-user-experience-level-experienced-label": "ผู้ใช้ที่มีความเชี่ยวชาญ", "rcfilters-filter-user-experience-level-experienced-description": "ผู้ใช้ลงทะเบียนที่มีการแก้ไขมากกว่า 500 ครั้งและอายุมากกว่า 30 วัน", @@ -1524,7 +1524,7 @@ "fileexists-forbidden": "มีไฟล์ชื่อนี้แล้ว และไม่สามารถเขียนทับได้\nหากคุณยังต้องการอัปโหลดไฟล์ของคุณ กรุณาย้อนกลับและใช้ชื่อใหม่ \n[[File:$1|thumb|center|$1]]", "fileexists-shared-forbidden": "ไฟล์ที่ใช้ชื่อนี้มีอยู่แล้วในระบบเก็บไฟล์ในส่วนกลาง\nถ้าคุณยังคงต้องการอัปโหลดไฟล์ของคุณ กรุณาย้อนกลับไปตั้งชื่อใหม่\n[[File:$1|thumb|center|$1]]", "fileexists-no-change": "ไฟล์ที่อัปโหลดเป็นคู่พอดีของ [[:$1]] รุ่นปัจจุบัน", - "fileexists-duplicate-version": "ไฟล์ที่อัปโหลดเป็นคู่พอดีของ [[:$1]] รุ่นก่อน", + "fileexists-duplicate-version": "ไฟล์ที่อัปโหลดซ้ำกับ [[:$1]] {{PLURAL:$2|}}รุ่นก่อนพอดี", "file-exists-duplicate": "ไฟล์นี้ซ้ำกับ{{PLURAL:$1|ไฟล์|ไฟล์}}ต่อไปนี้:", "file-deleted-duplicate": "ไฟล์ที่เหมือนไฟล์นี้ ([[:$1]]) เคยถูกลบไปก่อนหน้านี้แล้ว\nคุณควรตรวจสอบว่าประวัติการลบของไฟล์ก่อนดำเนินการอัปโหลดใหม่", "file-deleted-duplicate-notitle": "ไฟล์ที่เหมือนกับไฟล์นี้เคยถูกลบมาก่อน และชื่อดังกล่าวถูกห้ามใช้ คุณควรสอบถามผู้ที่สามารถดูข้อมูลไฟล์ที่ถูกระงับเพื่อทบทวนสถานการณ์ก่อนดำเนินการอัปโหลดไฟล์อีกครั้ง", @@ -2103,8 +2103,8 @@ "unprotectedarticle": "ยกเลิกการล็อกจาก \"[[$1]]\"", "movedarticleprotection": "ย้ายการตั้งค่าการล็อกจาก \"[[$2]]\" ไป \"[[$1]]\"", "protectedarticle-comment": "ล็อก \"[[$1]]\"", - "modifiedarticleprotection-comment": "เปลี่ยนระดับการล็อกสำหรับ \"[[$1]]\"", - "unprotectedarticle-comment": "ปลดล็อก \"[[$1]]\"", + "modifiedarticleprotection-comment": "{{GENDER:$2|}}เปลี่ยนระดับการล็อกสำหรับ \"[[$1]]\"", + "unprotectedarticle-comment": "{{GENDER:$2|}}ปลดล็อก \"[[$1]]\"", "protect-title": "เปลี่ยนระดับการล็อกสำหรับ \"$1\"", "protect-title-notallowed": "ดูระดับการล็อกของ \"$1\"", "prot_1movedto2": "เปลี่ยนชื่อ [[$1]] เป็น [[$2]]", @@ -3272,9 +3272,9 @@ "htmlform-user-not-exists": "ไม่มี $1", "htmlform-user-not-valid": "$1 มิใช่ชื่อผู้ใช้ที่สมเหตุสมผล", "logentry-delete-delete": "$1 ลบหน้า $3", - "logentry-delete-delete_redir": "$1 ลบหน้าเปลี่ยนทาง $3 โดยการเขียนทับ", + "logentry-delete-delete_redir": "$1 {{GENDER:$2|}}ลบหน้าเปลี่ยนทาง $3 โดยการเขียนทับ", "logentry-delete-restore": "$1 กู้คืนหน้า $3 ($4)", - "logentry-delete-restore-nocount": "$1 กู้คืนหน้า $3", + "logentry-delete-restore-nocount": "$1 {{GENDER:$2|}}กู้คืนหน้า $3", "restore-count-revisions": "$1 รุ่น", "restore-count-files": "$1 ไฟล์", "logentry-delete-event": "$1 เปลี่ยนทัศนวิสัยของ $5 รายการปูมใน $3: $4", diff --git a/languages/i18n/zh-hans.json b/languages/i18n/zh-hans.json index 24f73ab03c..14ac6496b4 100644 --- a/languages/i18n/zh-hans.json +++ b/languages/i18n/zh-hans.json @@ -1098,6 +1098,7 @@ "timezoneregion-indian": "印度洋", "timezoneregion-pacific": "太平洋", "allowemail": "允许其他用户向我发送电子邮件", + "email-allow-new-users-label": "允许来自新用户的电子邮件", "email-blacklist-label": "禁止这些用户给我发送电子邮件:", "prefs-searchoptions": "搜索", "prefs-namespaces": "名字空间", diff --git a/maintenance/backup.inc b/maintenance/backup.inc index 341a2992ff..00dbd00c86 100644 --- a/maintenance/backup.inc +++ b/maintenance/backup.inc @@ -25,7 +25,6 @@ */ require_once __DIR__ . '/Maintenance.php'; -require_once __DIR__ . '/../includes/export/DumpFilter.php'; use Wikimedia\Rdbms\LoadBalancer; use Wikimedia\Rdbms\IDatabase; @@ -420,20 +419,3 @@ class BackupDumper extends Maintenance { } } } - -class ExportProgressFilter extends DumpFilter { - function __construct( &$sink, &$progress ) { - parent::__construct( $sink ); - $this->progress = $progress; - } - - function writeClosePage( $string ) { - parent::writeClosePage( $string ); - $this->progress->reportPage(); - } - - function writeRevision( $rev, $string ) { - parent::writeRevision( $rev, $string ); - $this->progress->revCount(); - } -} diff --git a/maintenance/storage/checkStorage.php b/maintenance/storage/checkStorage.php index 4071a06b4c..6348e96b21 100644 --- a/maintenance/storage/checkStorage.php +++ b/maintenance/storage/checkStorage.php @@ -208,7 +208,9 @@ class CheckStorage { $blobsTable = $this->dbStore->getTable( $extDb ); $res = $extDb->select( $blobsTable, [ 'blob_id' ], - [ 'blob_id IN( ' . implode( ',', $blobIds ) . ')' ], __METHOD__ ); + [ 'blob_id' => $blobIds ], + __METHOD__ + ); foreach ( $res as $row ) { unset( $xBlobIds[$row->blob_id] ); } @@ -410,7 +412,9 @@ class CheckStorage { $headerLength = strlen( self::CONCAT_HEADER ); $res = $extDb->select( $blobsTable, [ 'blob_id', "LEFT(blob_text, $headerLength) AS header" ], - [ 'blob_id IN( ' . implode( ',', $blobIds ) . ')' ], __METHOD__ ); + [ 'blob_id' => $blobIds ], + __METHOD__ + ); foreach ( $res as $row ) { if ( strcasecmp( $row->header, self::CONCAT_HEADER ) ) { $this->addError( diff --git a/resources/lib/oojs-ui/oojs-ui-core.js b/resources/lib/oojs-ui/oojs-ui-core.js index e0d165f9b1..b3a901200f 100644 --- a/resources/lib/oojs-ui/oojs-ui-core.js +++ b/resources/lib/oojs-ui/oojs-ui-core.js @@ -4832,7 +4832,7 @@ OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) * @return {string} 'left' or 'right' */ OO.ui.mixin.ClippableElement.prototype.getHorizontalAnchorEdge = function () { - if ( this.computePosition && this.computePosition().right !== '' ) { + if ( this.computePosition && this.positioning && this.computePosition().right !== '' ) { return 'right'; } return 'left'; @@ -4854,7 +4854,7 @@ OO.ui.mixin.ClippableElement.prototype.getHorizontalAnchorEdge = function () { * @return {string} 'top' or 'bottom' */ OO.ui.mixin.ClippableElement.prototype.getVerticalAnchorEdge = function () { - if ( this.computePosition && this.computePosition().bottom !== '' ) { + if ( this.computePosition && this.positioning && this.computePosition().bottom !== '' ) { return 'bottom'; } return 'top'; diff --git a/resources/src/mediawiki.legacy/wikibits.js b/resources/src/mediawiki.legacy/wikibits.js index f5bdfd8058..27d049eb3a 100644 --- a/resources/src/mediawiki.legacy/wikibits.js +++ b/resources/src/mediawiki.legacy/wikibits.js @@ -49,7 +49,7 @@ loadedScripts[ url ] = true; s = document.createElement( 'script' ); s.setAttribute( 'src', url ); - document.getElementsByTagName( 'head' )[ 0 ].appendChild( s ); + document.head.appendChild( s ); return s; } @@ -72,7 +72,7 @@ if ( media ) { l.media = media; } - document.getElementsByTagName( 'head' )[ 0 ].appendChild( l ); + document.head.appendChild( l ); return l; } diff --git a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ChangesListViewModel.js b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ChangesListViewModel.js index 15fe334261..96b44100ea 100644 --- a/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ChangesListViewModel.js +++ b/resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ChangesListViewModel.js @@ -4,17 +4,19 @@ * * @mixins OO.EventEmitter * + * @param {jQuery} $initialFieldset The initial server-generated legacy form content * @constructor */ - mw.rcfilters.dm.ChangesListViewModel = function MwRcfiltersDmChangesListViewModel() { + mw.rcfilters.dm.ChangesListViewModel = function MwRcfiltersDmChangesListViewModel( $initialFieldset ) { // Mixin constructor OO.EventEmitter.call( this ); this.valid = true; this.newChangesExist = false; - this.nextFrom = null; this.liveUpdate = false; this.unseenWatchedChanges = false; + + this.extractNextFrom( $initialFieldset ); }; /* Initialization */ @@ -74,7 +76,6 @@ * @param {jQuery|string} changesListContent * @param {jQuery} $fieldset * @param {string} noResultsDetails Type of no result error - * timeout. * @param {boolean} [isInitialDOM] Using the initial (already attached) DOM elements * @param {boolean} [separateOldAndNew] Whether a logical separation between old and new changes is needed * @fires update @@ -114,7 +115,9 @@ */ mw.rcfilters.dm.ChangesListViewModel.prototype.extractNextFrom = function ( $fieldset ) { var data = $fieldset.find( '.rclistfrom > a, .wlinfo' ).data( 'params' ); - this.nextFrom = data ? data.from : null; + if ( data && data.from ) { + this.nextFrom = data.from; + } }; /** diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js index 3e1191f392..7bb0a222c4 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js @@ -100,7 +100,8 @@ */ mw.rcfilters.UriProcessor.prototype._normalizeTargetInUri = function ( uri ) { var parts, - re = /^((?:\/.+\/)?.+:.+)\/(.+)$/; // matches [namespace:]Title/Subpage + // matches [/wiki/]SpecialNS:RCL/[Namespace:]Title/Subpage/Subsubpage/etc + re = /^((?:\/.+?\/)?.*?:.*?)\/(.*)$/; // target in title param if ( uri.query.title ) { @@ -112,7 +113,7 @@ } // target in path - parts = uri.path.match( re ); + parts = mw.Uri.decode( uri.path ).match( re ); if ( parts ) { uri.path = parts[ 1 ]; uri.query.target = parts[ 2 ]; diff --git a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js index 582d25fa34..100fa0b0ca 100644 --- a/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js +++ b/resources/src/mediawiki.rcfilters/mw.rcfilters.init.js @@ -11,11 +11,12 @@ var $topSection, mainWrapperWidget, conditionalViews = {}, + $initialFieldset = $( 'fieldset.cloptions' ), savedQueriesPreferenceName = mw.config.get( 'wgStructuredChangeFiltersSavedQueriesPreferenceName' ), daysPreferenceName = mw.config.get( 'wgStructuredChangeFiltersDaysPreferenceName' ), limitPreferenceName = mw.config.get( 'wgStructuredChangeFiltersLimitPreferenceName' ), filtersModel = new mw.rcfilters.dm.FiltersViewModel(), - changesListModel = new mw.rcfilters.dm.ChangesListViewModel(), + changesListModel = new mw.rcfilters.dm.ChangesListViewModel( $initialFieldset ), savedQueriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ), specialPage = mw.config.get( 'wgCanonicalSpecialPageName' ), controller = new mw.rcfilters.Controller( @@ -82,7 +83,7 @@ '.mw-changeslist-timeout', '.mw-changeslist-notargetpage' ].join( ', ' ) ), - $formContainer: $( 'fieldset.cloptions' ) + $formContainer: $initialFieldset } ); @@ -94,7 +95,7 @@ controller.initialize( mw.config.get( 'wgStructuredChangeFilters' ), // All namespaces without Media namespace - this.getNamespaces( [ 'Media' ] ), + rcfilters.getNamespaces( [ 'Media' ] ), mw.config.get( 'wgRCFiltersChangeTags' ), conditionalViews ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MenuSelectWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MenuSelectWidget.js index 1740c939bf..98acab050c 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MenuSelectWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MenuSelectWidget.js @@ -334,7 +334,7 @@ */ mw.rcfilters.ui.MenuSelectWidget.prototype.onKeyDown = function ( e ) { var nextItem, - currentItem = this.getHighlightedItem() || this.getSelectedItem(); + currentItem = this.findHighlightedItem() || this.getSelectedItem(); // Call parent mw.rcfilters.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e ); diff --git a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js index 6673c082d8..d5c5e26ec9 100644 --- a/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js @@ -71,6 +71,7 @@ * Respond to the model being updated */ mw.rcfilters.ui.RclTargetPageWidget.prototype.updateUiBasedOnModel = function () { - this.titleSearch.setValue( this.model.getValue() ); + var title = mw.Title.newFromText( this.model.getValue() ); + this.titleSearch.setValue( title ? title.toText() : this.model.getValue() ); }; }( mediaWiki ) ); diff --git a/resources/src/mediawiki/mediawiki.js b/resources/src/mediawiki/mediawiki.js index a661ae5521..6a218e3d25 100644 --- a/resources/src/mediawiki/mediawiki.js +++ b/resources/src/mediawiki/mediawiki.js @@ -879,8 +879,10 @@ // Cache marker = document.querySelector( 'meta[name="ResourceLoaderDynamicStyles"]' ); if ( !marker ) { - mw.log( 'Create dynamically' ); - marker = $( '' ).attr( 'name', 'ResourceLoaderDynamicStyles' ).appendTo( 'head' )[ 0 ]; + mw.log( 'Created ResourceLoaderDynamicStyles marker dynamically' ); + marker = document.createElement( 'meta' ); + marker.name = 'ResourceLoaderDynamicStyles'; + document.head.appendChild( marker ); } } return marker; @@ -902,7 +904,7 @@ if ( nextNode && nextNode.parentNode ) { nextNode.parentNode.insertBefore( s, nextNode ); } else { - document.getElementsByTagName( 'head' )[ 0 ].appendChild( s ); + document.head.appendChild( s ); } return s; @@ -2059,7 +2061,7 @@ l = document.createElement( 'link' ); l.rel = 'stylesheet'; l.href = modules; - $( 'head' ).append( l ); + document.head.appendChild( l ); return; } if ( type === 'text/javascript' || type === undefined ) { @@ -2753,7 +2755,7 @@ // If we have an exception object, log it to the warning channel to trigger // proper stacktraces in browsers that support it. if ( e && console.warn ) { - console.warn( String( e ), e ); + console.warn( e ); } } /* eslint-enable no-console */ diff --git a/resources/src/startup.js b/resources/src/startup.js index b0c15781ee..8e8463d251 100644 --- a/resources/src/startup.js +++ b/resources/src/startup.js @@ -162,5 +162,5 @@ window.isCompatible = function ( str ) { // Callback startUp(); }; - document.getElementsByTagName( 'head' )[ 0 ].appendChild( script ); + document.head.appendChild( script ); }() ); diff --git a/tests/parser/parserTests.txt b/tests/parser/parserTests.txt index 7af3a3655b..72ee550109 100644 --- a/tests/parser/parserTests.txt +++ b/tests/parser/parserTests.txt @@ -546,15 +546,19 @@ Extra newlines between heading and content are swallowed Heading with line break in nowiki !! options parsoid=wt2html +!! config +wgFragmentMode=[ 'html5', 'legacy' ] !! wikitext == A B C == -!! html -

A B +!! html/php +

A B C[edit]

!! html/parsoid -

A B +

A B C

!! end @@ -4851,8 +4855,8 @@ parsoid=wt2html,wt2wt

!! html/parsoid

-

Bar

-

Bar

+

Bar

+

Bar

!! end !! test @@ -6715,9 +6719,9 @@ Don't break on | in extension attribute in template !! html/parsoid -

[1]

+

[1]

-
  1. ↑ ha
+
  1. ↑ ha
!! end ## We don't support roundtripping of these attributes in Parsoid. @@ -7825,13 +7829,15 @@ Link with multiple pipes !! test Anchor containing a #. (T65430) +!! config +wgFragmentMode=[ 'html5', 'legacy' ] !! wikitext [[Main Page#And#Link]] !! html/php -

Main Page#And#Link +

Main Page#And#Link

!! html/parsoid -

Main Page#And#Link

+

Main Page#And#Link

!! end !! test @@ -7949,13 +7955,27 @@ Link containing % as a double hex sequence interpreted to hex sequence ## Example for such a section: == < == !! test Link containing "#<" and "#>" % as a hex sequences- these are valid section anchors +!! config +wgFragmentMode=[ 'html5', 'legacy' ] !! wikitext [[%23%3c]][[%23%3e]] !! html/php -

#<#> +

#<#>

!! html/parsoid -

#<#>

+

#<#>

+!! end + +## Example for such a section: == < == +!! test +Link containing "#<" and "#>" % as a hex sequences- these are valid section anchors (legacy) +!! config +wgFragmentMode=[ 'legacy' ] +!! wikitext +[[%23%3c]][[%23%3e]] +!! html/php +

#<#> +

!! end !! test @@ -8017,7 +8037,7 @@ Link containing double quotes and spaces

Cool "Gator"

!! html/parsoid -

Cool "Gator"

+

Cool "Gator"

!! end !! test @@ -8025,7 +8045,7 @@ File containing double quotes and spaces !! wikitext [[File:Cool "Gator".png]] !! html/parsoid -

+

!! end !! test @@ -8073,7 +8093,7 @@ Link with double quotes in title part (literal) and alternate part (interpreted)

Pentecoste

!! html/parsoid -

+

''Pentecoste''

Pentecoste

Pentecoste

@@ -8093,10 +8113,10 @@ Broken image links with HTML captions (T41700) abc

!! html/parsoid -

- - -

+

+ + +

!! end !! test @@ -8600,13 +8620,26 @@ Parsoid: Scoped parsing should handle mixed transclusions and plain text !! test Link with angle bracket after anchor +!! config +wgFragmentMode=[ 'html5', 'legacy' ] !! wikitext [[Foo#]] !! html/php -

Foo#<bar> +

Foo#<bar>

!! html/parsoid -

Foo#<bar>

+

Foo#<bar>

+!! end + +!! test +Link with angle bracket after anchor (legacy) +!! config +wgFragmentMode=[ 'legacy' ] +!! wikitext +[[Foo#]] +!! html/php +

Foo#<bar> +

!! end ### @@ -8623,7 +8656,7 @@ parsoid=wt2html,wt2wt,html2html

MeatBall:SoftSecurity

!! html/parsoid -

MeatBall:SoftSecurity

+

MeatBall:SoftSecurity

!! end !! test @@ -8636,7 +8669,7 @@ parsoid=wt2html,wt2wt,html2html

MeatBall:

!! html/parsoid -

MeatBall:

+

MeatBall:

!! end ## html2wt and html2html will fail because we will prefer the :en: interwiki prefix over wikipedia: @@ -8658,8 +8691,8 @@ parsoid=wt2html,wt2wt !! html/parsoid !! end @@ -8674,6 +8707,27 @@ Interwiki link with fragment (T4130) !! test Link scenarios with escaped fragments +!! config +wgFragmentMode=[ 'html5', 'legacy' ] +!! wikitext +[[#Is this great?]] +[[Foo#Is this great?]] +[[meatball:Foo#Is this great?]] +!! html/php +

#Is this great? +Foo#Is this great? +meatball:Foo#Is this great? +

+!! html/parsoid +

#Is this great? +Foo#Is this great? +meatball:Foo#Is this great?

+!! end + +!! test +Link scenarios with escaped fragments (legacy) +!! config +wgFragmentMode=[ 'legacy' ] !! wikitext [[#Is this great?]] [[Foo#Is this great?]] @@ -8683,10 +8737,6 @@ Link scenarios with escaped fragments Foo#Is this great? meatball:Foo#Is this great?

-!! html/parsoid -

#Is this great? -Foo#Is this great? -meatball:Foo#Is this great?

!! end # Ideally the wikipedia: prefix here should be proto-relative too @@ -8711,19 +8761,19 @@ Different interwiki prefixes mapping to the same URL [[ wikiPEdia :Foo]] !! html/parsoid -

en:Foo

+

en:Foo

-

Foo

+

Foo

-

wikipedia:Foo

+

wikipedia:Foo

-

Foo

+

Foo

-

wikipedia:en:Foo

+

wikipedia:en:Foo

-

wikipedia:en:Foo

+

wikipedia:en:Foo

-

wikiPEdia :Foo

+

wikiPEdia :Foo

!! end !! test @@ -8743,9 +8793,9 @@ Interwiki links that cannot be represented in wiki syntax is just fragment

!! html/parsoid -

meatball:ok -ok with fragment -ok ending with ? mark +

meatball:ok +ok with fragment +ok ending with ? mark has query is just fragment

!! end @@ -8758,7 +8808,7 @@ Interwiki links: trail

Bar

!! html/parsoid -

Bar

+

Bar

!! end !! test @@ -8812,7 +8862,7 @@ parsoid=wt2html,wt2wt,html2html

local:meatball:Hello

!! html/parsoid -

local:meatball:Hello

+

local:meatball:Hello

!! end !! test @@ -8910,8 +8960,8 @@ Blah blah blah

!! html/parsoid

Blah blah blah -es:Spanish - zh : Chinese

+es:Spanish + zh : Chinese

!! end !! test @@ -8928,7 +8978,7 @@ parsoid=wt2html [[:::es:Spanish]]

!! html/parsoid -

es:Spanish +

es:Spanish [[::es:Spanish]] [[:::es:Spanish]]

!! end @@ -9005,7 +9055,7 @@ parsoid=wt2html,wt2wt,html2html Blah blah blah [[zh:Chinese]] !! html/parsoid -

Blah blah blah zh:Chinese

+

Blah blah blah zh:Chinese

!! end ## PHP parser tests script needs an update @@ -9019,7 +9069,7 @@ parsoid=wt2html,wt2wt,html2html Blah blah blah [[zh:Chinese]] !! html/parsoid -

Blah blah blah zh:Chinese

+

Blah blah blah zh:Chinese

!! end !! test @@ -9106,7 +9156,7 @@ parsoid=wt2html,wt2wt,html2html

ko:

!! html/parsoid -

es:

+

es:

ko:

!! end @@ -9134,7 +9184,7 @@ Blah blah blah

!! html/parsoid

Blah blah blah -local:es:Spanish

+local:es:Spanish

!! end !! test @@ -9177,10 +9227,12 @@ Blah blah blah # This tests the Parsoid bail-out code. !! test 3. Other redirect variants +!! options +parsoid=wt2html !! wikitext #REDIRECT [[[[Bar]]]] !! html/parsoid -
  1. REDIRECT [[[[Bar]]]]
+
  1. REDIRECT [[[[Bar]]]]
!! end !! test @@ -11989,14 +12041,14 @@ some

here

!! html/parsoid -

hu

+

hu

some

  • stuff
  • here
-

here

+

here

!! end @@ -12520,6 +12572,8 @@ Preprocessor precedence 14: broken language converter in comment !! test Preprocessor precedence 15: broken brace markup in headings +!! config +wgFragmentMode=[ 'html5', 'legacy' ] !! options parsoid=wt2html !! wikitext @@ -12537,32 +12591,31 @@ __NOTOC__ __NOEDITSECTION__ ===6 foo-{bar 6=== 6 !! html/php+tidy -

1 foo[bar 1

+

1 foo[bar 1

1

-

2 foo[[bar 2

+

2 foo[[bar 2

2

-

3 foo{bar 3

+

3 foo{bar 3

3

-

4 foo{{bar 4

+

4 foo{{bar 4

4

-

5 foo{{{bar 5

+

5 foo{{{bar 5

5

-

6 foo-{bar 6

+

6 foo-{bar 6

6

!! html/parsoid - -

1 foo[bar 1

+ +

1 foo[bar 1

1

-

2 foo[[bar 2

+

2 foo[[bar 2

2

-

3 foo{bar 3

+

3 foo{bar 3

3

-

4 foo{{bar 4

+

4 foo{{bar 4

4

-

5 foo{{{bar 5

+

5 foo{{{bar 5

5

-

6 foo-{bar 6

+

6 foo-{bar 6

6

!! end @@ -14264,15 +14317,15 @@ parsoid=wt2html,wt2wt,html2html

Foobar.jpg

!! html/parsoid -

+

!! end !! test -Serialize simple image with figure-inline wrapper +Serialize simple image with span wrapper !! options parsoid=html2wt !! html/parsoid -

+

!! wikitext [[File:Foobar.jpg]] !! end @@ -14285,7 +14338,7 @@ Simple image (using File: namespace, now canonical)

Foobar.jpg

!! html/parsoid -

+

!! end !! test @@ -14402,7 +14455,7 @@ Linktrails should not work for images: [[File:Foobar.jpg]]s

Linktrails should not work for images: Foobar.jpgs

!! html/parsoid -

Linktrails should not work for images: s

+

Linktrails should not work for images: s

!! end !! test @@ -14448,7 +14501,7 @@ parsoid=wt2html,wt2wt,html2html

Foobar.jpg

!! html/parsoid -

+

!! end ## Parsoid does not provide editing support for images where templates produce multiple image attributes. @@ -14492,7 +14545,7 @@ thumbsize=220

456

!! html/parsoid -

123456

+

123456

123

456

123

456

!! end @@ -14516,7 +14569,7 @@ Image with multiple widths -- use last

caption

!! html/parsoid -

+

!! end !! test @@ -14533,7 +14586,7 @@ thumbsize=220

!! html/parsoid
caption
-

+

!! end !! test @@ -14566,7 +14619,7 @@ parsoid=wt2html,wt2wt,html2html Foobar.jpg

!! html/parsoid -

+

!! end !! test @@ -14577,7 +14630,7 @@ Image with link parameter, wiki target

Foobar.jpg

!! html/parsoid -

+

!! end # parsoid T51293 (part 1) @@ -14589,7 +14642,7 @@ Image with link parameter, URL target

Foobar.jpg

!! html/parsoid -

+

!! end # parsoid T51293 (part 2) @@ -14601,7 +14654,7 @@ Image with link parameter, protocol-less URL target

Foobar.jpg

!! html/parsoid -

+

!! end !! test @@ -14673,7 +14726,7 @@ Image with empty link parameter

Foobar.jpg

!! html/parsoid -

+

!! end !! test @@ -14684,7 +14737,7 @@ Image with link parameter (wiki target) and unnamed parameter

Title

!! html/parsoid -

+

!! end !! test @@ -14695,7 +14748,7 @@ Image with link parameter (URL target) and unnamed parameter

Title

!! html/parsoid -

+

!! end !! test @@ -14818,9 +14871,9 @@ Image with wiki markup in implicit alt

testing bold in alt

!! html/parsoid -

+

-

testing bold in alt

+

testing bold in alt

!! end !! test @@ -14913,9 +14966,9 @@ parsoid=wt2html,wt2wt,html2html

caption

!! html/parsoid -

-

-

+

+

+

!! end !! test @@ -14980,8 +15033,8 @@ parsoid=wt2html,wt2wt,html2html

Foobar.jpg

!! html/parsoid -

-

+

+

!! end !! test @@ -14997,8 +15050,8 @@ parsoid=wt2html,wt2wt,html2html

Foobar.jpg

!! html/parsoid -

-

+

+

!! end !! test @@ -15041,7 +15094,7 @@ parsoid=wt2html,wt2wt,html2html

Foobar.jpg

!! html/parsoid -

+

!! end !! test @@ -15057,8 +15110,8 @@ parsoid=wt2html,wt2wt,html2html

Foobar.svg

!! html/parsoid -

-

+

+

!! end !! test @@ -15116,7 +15169,7 @@ Frameless image caption with a free URL

http://example.com

!! html/parsoid -

+

!! end !! test @@ -15226,7 +15279,7 @@ T2648: Frameless image caption with a link

text with a link in it

!! html/parsoid -

+

!! end !! test @@ -15237,7 +15290,7 @@ T2648: Frameless image caption with a link (suffix)

text with a linkfoo in it

!! html/parsoid -

+

!! end !! test @@ -15248,7 +15301,7 @@ T2648: Frameless image caption with an interwiki link

text with a MeatBall:Link in it

!! html/parsoid -

+

!! end !! test @@ -15259,7 +15312,7 @@ T2648: Frameless image caption with a piped interwiki link

text with a link in it

!! html/parsoid -

+

!! end !! test @@ -15267,7 +15320,7 @@ T107474: Frameless image caption with !! wikitext [[File:Foobar.jpg|text with a [[MeatBall:Link|link]] in it]] !! html/parsoid -

+

!! end !! test @@ -15278,7 +15331,7 @@ Escape HTML special chars in image alt text

& < > "

!! html/parsoid -

+

!! end !! test @@ -15291,7 +15344,7 @@ language=zh

& < > "

!! html/parsoid -

+

!! end !! test @@ -15302,7 +15355,7 @@ Entities in file name and attributes

7% solution

!! html/parsoid -

+

!! end !! test @@ -15313,7 +15366,7 @@ T2499: Alt text should have Ӓ, not &1234;

♀

!! html/parsoid -

+

!! end !! test @@ -15337,7 +15390,7 @@ Image caption containing another image
This is a caption with another image inside it!
!! html/parsoid -
This is a caption with another inside it!
+
This is a caption with another inside it!
!! end !! test @@ -15349,7 +15402,7 @@ Image: caption containing a newline

This *is some text

!! html/parsoid -

+

!!end !!test @@ -15410,7 +15463,7 @@ parsoid=wt2html,wt2wt,html2html

a

!! html/parsoid -

+

!! end !! test @@ -15464,7 +15517,7 @@ parsoid=wt2html,wt2wt,html2html

caption

!! html/parsoid -

+

!! end # Note that 'right' is the default alignment, despite the misspelled 'righ' below @@ -15517,7 +15570,7 @@ wgEnableUploads=0

File:Foobaz.jpg

!! html/parsoid -

+

!! end # Parsoid-specific testing for images @@ -15532,7 +15585,7 @@ Parsoid-specific image handling - simple image with size and middle alignment !! wikitext [[File:Foobar.jpg|middle|50px]] !! html/parsoid -

+

!! end !! test @@ -15543,7 +15596,7 @@ parsoid=wt2wt,wt2html,html2html !! wikitext [[Image:Foobar.jpg|middle|50px]] !! html/parsoid -

+

!! end !! test @@ -15552,7 +15605,7 @@ Parsoid-specific image handling - simple image with size and middle alignment !! wikitext [[File:Foobar.jpg|50px|middle]] !! html/parsoid -

+

!! end !! test @@ -15563,7 +15616,7 @@ parsoid=wt2html,wt2wt,html2html !! wikitext [[Image:Foobar.jpg|50px|middle]] !! html/parsoid -

+

!! end !! test @@ -15571,7 +15624,7 @@ Parsoid-specific image handling - simple image with both sizes, a baseline align !! wikitext [[File:Foobar.jpg|500x10px|baseline|caption]] !! html/parsoid -

+

!! end !! test @@ -15579,7 +15632,7 @@ Parsoid-specific image handling - simple image with border and size spec !! wikitext [[File:Foobar.jpg|50px|border|caption]] !! html/parsoid -

+

!! end !! test @@ -15643,7 +15696,7 @@ Parsoid-specific image handling - frameless image with specific size, border, an !! wikitext [[File:Foobar.jpg|frameless|442x50px|border|caption]] !! html/parsoid -

+

!! end !! test @@ -15651,7 +15704,7 @@ Parsoid-specific image handling - simple image with a formatted caption !! wikitext [[File:Foobar.jpg|
ab
c
]] !! html/parsoid -

+

!! end !! test @@ -15721,7 +15774,7 @@ foo bar !! html/parsoid

foo - + bar

!! end @@ -15745,7 +15798,7 @@ T93580: 2. inside inline images !! html/parsoid -

+

  1. ↑ foo
!! end @@ -15757,7 +15810,7 @@ T93580: 3. Templated inside inline images !! html/parsoid -

+

  1. ↑ foo
!! end @@ -16585,8 +16638,11 @@ __FORCETOC__ !! end # perl -e 'print "="x$_," Level $_ heading","="x$_,"\n" for 1..10' +# Parsoid html2wt direction adds for level 7 and up. !! test Handling of sections up to level 6 and beyond +!! options +parsoid=wt2html !! wikitext = Level 1 Heading= == Level 2 Heading== @@ -16598,7 +16654,7 @@ Handling of sections up to level 6 and beyond ======== Level 8 Heading======== ========= Level 9 Heading========= ========== Level 10 Heading========== -!! html +!! html/php

Contents

  • 1 Level 1 Heading @@ -16640,6 +16696,17 @@ Handling of sections up to level 6 and beyond
    === Level 9 Heading===[edit]
    ==== Level 10 Heading====[edit]
    +!! html/parsoid +

    Level 1 Heading

    +

    Level 2 Heading

    +

    Level 3 Heading

    +

    Level 4 Heading

    +
    Level 5 Heading
    +
    Level 6 Heading
    +
    = Level 7 Heading=
    +
    == Level 8 Heading==
    +
    === Level 9 Heading===
    +
    ==== Level 10 Heading====
    !! end !! test @@ -16863,24 +16930,33 @@ http://example.com [[File:Foobar.jpg]]

    http://example.com Foobar.jpg

    !! html/parsoid -

    http://example.com

    +

    http://example.com

    !!end +# Parsoid doesn't wt2wt this cleanly because it adds s. !! test Short headings with trailing space should match behavior of Parser::doHeadings (T21910) +!! options +parsoid=wt2html,html2html !! wikitext === The line above must have a trailing space! === But just in case it doesn't... -!! html +!! html/php

    =[edit]

    The line above must have a trailing space!

    =[edit]

    But just in case it doesn't...

    +!! html/parsoid +

    =

    +

    The line above must have a trailing space!

    +

    =

    +

    But just in case it doesn't...

    !! end !! test @@ -16902,7 +16978,7 @@ section 4 == text " text == section 5 -!! html +!! html/php

    The tooltips shall not show entities to the user (ie. be double escaped)

    Contents

    @@ -16930,6 +17006,23 @@ section 5

    text " text[edit]

    section 5

    +!! html/parsoid +

    The tooltips shall not show entities to the user (ie. be double escaped)

    + +

    text > text

    +

    section 1

    + +

    text < text

    +

    section 2

    + +

    text & text

    +

    section 3

    + +

    text ' text

    +

    section 4

    + +

    text " text

    +

    section 5

    !! end !! test @@ -16961,7 +17054,7 @@ section 6 [[#Plus-Entity+between+Text]] [[#Underscore_between_Text]] [[#Underscore-Entity_between_Text]] -!! html +!! html/php

    Id should not contain + for spaces

    Contents

    @@ -16999,17 +17092,47 @@ section 6 #Underscore_between_Text #Underscore-Entity_between_Text

    +!! html/parsoid +

    Id should not contain + for spaces

    + +

    Space between Text

    +

    section 1

    + +

    Space-Entity between Text

    +

    section 2

    + +

    Plus+between+Text

    +

    section 3

    + +

    Plus-Entity+between+Text

    +

    section 4

    + +

    Underscore_between_Text

    +

    section 5

    + +

    Underscore-Entity_between_Text

    +

    section 6

    + +

    #Space between Text +#Space-Entity between Text +#Plus+between+Text +#Plus-Entity+between+Text +#Underscore_between_Text +#Underscore-Entity_between_Text

    !! end +# Parsoid html2wt disabled because it adds padding spaces around = !! test Headers with excess '=' characters (Are similar tests necessary beyond the 1st level?) +!! options +parsoid=wt2html,wt2wt,html2html !! wikitext =foo== ==foo= =''italic'' heading== ==''italic'' heading= -!! html +!! html/php

    Contents

    • 1 foo=
    • @@ -17024,6 +17147,11 @@ Headers with excess '=' characters

      italic heading=[edit]

      =italic heading[edit]

      +!! html/parsoid +

      foo=

      +

      =foo

      +

      italic heading=

      +

      =italic heading

      !! end !! test @@ -17039,7 +17167,7 @@ HTML headers vs TOC (T25393) == Header 2.1 == == Header 2.2 == __NOEDITSECTION__ -!! html +!! html/php

      Contents

      • 1 Header 1 @@ -17064,6 +17192,16 @@ __NOEDITSECTION__

        Header 2.1

        Header 2.2

        +!! html/parsoid +

        Header 1

        +

        Header 1.1

        +

        Header 1.2

        + +

        Header 2 +

        +

        Header 2.1

        +

        Header 2.2

        + !! end !! test @@ -17076,11 +17214,17 @@ parsoid=wt2html,wt2wt ==baz== -!! html -

        foo

        -

        bar

        -

        baz

        +!! html/php +

        foo[edit]

        +

        bar[edit]

        +

        baz[edit]

        +!! html/parsoid +

        foo

        +

        bar

        +

        baz

        !! end !! test @@ -17091,7 +17235,7 @@ http://example.com[[File:Foobar.jpg]]

        http://example.comFoobar.jpg

        !! html/parsoid -

        http://example.com

        +

        http://example.com

        !!end !! test @@ -17187,15 +17331,17 @@ parsoid=wt2html,html2html !! test div with multiple empty attribute values +!! config +wgFragmentMode=[ 'html5', 'legacy' ] !! options parsoid=wt2html,html2html !! wikitext
        HTML rocks
        !! html/php -
        HTML rocks
        +
        HTML rocks
        !! html/parsoid -
        HTML rocks
        +
        HTML rocks
        !! end !! test @@ -17521,7 +17667,7 @@ Image link to nonexistent file (T3850 - good)

        File:No such.jpg

        !! html/parsoid -

        +

        !! end !! test @@ -17773,9 +17919,11 @@ T4304: HTML attribute safety (unsafe breakout parameter 2; 2309) T4304: HTML attribute safety (link) !! wikitext
        -!! html +!! html/php
        +!! html/parsoid +
        !! end !! test @@ -17836,9 +17984,11 @@ T4304: HTML attribute safety (web link) T4304: HTML attribute safety (named web link) !! wikitext
        -!! html +!! html/php
        +!! html/parsoid +
        !! end !! test @@ -18447,13 +18597,26 @@ Table not started !! test Sanitizer: Escaping of spaces, multibyte characters, colons & other stuff in id="" +!! config +wgFragmentMode=[ 'html5', 'legacy' ] !! wikitext byte[[#æ: v|backlink]] !! html/php -

        bytebacklink +

        bytebacklink

        !! html/parsoid -

        bytebacklink

        +

        bytebacklink

        +!! end + +!! test +Sanitizer: Escaping of spaces, multibyte characters, colons & other stuff in id="" (legacy) +!! config +wgFragmentMode=[ 'legacy' ] +!! wikitext +byte[[#æ: v|backlink]] +!! html/php +

        bytebacklink +

        !! end # In HTML5, the restrictions are that id must contain at least one character, @@ -18516,6 +18679,37 @@ parsoid=wt2html,wt2wt

        2013

        !! end +!! test +Sanitizer: Avoid unnecessary percent encoded characters in interwiki links +!! wikitext +[[meatball:Soft"Security]] +!! html/php +

        meatball:Soft"Security +

        +!! html/parsoid +

        meatball:Soft"Security

        +!! end + +!! test +Sanitizer: angle brackets are invalid, even in interwiki links (T182338) +!! wikitext +[[meatball:FooBar]] +[[meatball:Foo<bar]] +[[meatball:Foo>bar]] +!! html/php +

        [[meatball:Foo<Bar]] +[[meatball:Foo>Bar]] +[[meatball:Foo<bar]] +[[meatball:Foo>bar]] +

        +!! html/parsoid +

        [[meatball:Foo<Bar]] +[[meatball:Foo>Bar]] +[[meatball:Foo<bar]] +[[meatball:Foo>bar]]

        +!! end + !! test Language converter: output gets cut off unexpectedly (T7757) !! options @@ -18877,12 +19071,15 @@ Fuzz testing: Parser13 !! end +# Note that Parsoid output differs from the PHP parser here: the PHP +# parser breaks the URL for the magic word, while in Parsoid the URL +# production takes precedence. !! test Fuzz testing: Parser14 !! wikitext == onmouseover= == http://__TOC__ -!! html +!! html/php

        onmouseover=[edit]

        http://

        Contents

          @@ -18891,7 +19088,7 @@ http://

          Contents

          -!! html+tidy +!! html/php+tidy

          onmouseover=[edit]

          http://

          @@ -18903,6 +19100,9 @@ http://

          Contents

        +!! html/parsoid +

        onmouseover=

        +

        http://__TOC__

        !! end !! test @@ -18926,7 +19126,7 @@ parsoid=wt2html,html2html !! html/parsoid -

        a

        +

        a

        !! end @@ -19101,15 +19301,45 @@ Fuzz testing: image with bogus manual thumbnail
        !! end +# Parsoid will emit the newline literally in wt2wt; see next test case. !! test Fuzz testing: encoded newline in generated HTML replacements (T8577) +!! options +parsoid=wt2html !! wikitext
        
         !! html/php
         
        
         
         !! html/parsoid
        -
        
        +
        
        +!! end
        +
        +!! test
        +Fuzz testing: encoded newline in generated HTML replacements, html2wt (T8577)
        +!! options
        +parsoid=html2wt
        +!! html/parsoid
        +
        
        +!! wikitext
        +
        
        +!! html/php
        +
        
        +
        +!! end
        +
        +!! test
        +Templates in extension attributes are not expanded
        +!! wikitext
        +
        
        +!! html/php
        +
        
        +
        +!! html/parsoid
        +
        
         !! end
         
         !! test
        @@ -20263,7 +20493,7 @@ File:File:Foobar.jpg
         
         !! html/parsoid
         
         !! end
         
        @@ -20326,12 +20556,12 @@ image4    |300px| centre
         
         !! html/parsoid
         
         !! end
         
        @@ -20389,11 +20619,11 @@ image:foobar.jpg|Blabla|alt=This is a foo-bar.|blabla.
         !! html/parsoid
         
         !! end
         
        @@ -20450,11 +20680,11 @@ image:foobar.jpg|Blabla|alt=This is a foo-bar.|blabla.
         !! html/parsoid
         
         !! end
         
        @@ -20494,9 +20724,9 @@ image:foobar.jpg|link=Main Page#section|caption
         
         !! html/parsoid
         
         !! end
         
        @@ -20526,7 +20756,7 @@ File:Foobar.jpg|{{echo|ho}}
         !! html/parsoid
         
         !! end
         
        @@ -20561,8 +20791,8 @@ File:Foobar.jpg|alt=galleryalt|{{Test|unamedParam|alt=param}}
         
         !! html/parsoid
         
         !! end
         
        @@ -20615,10 +20845,10 @@ some caption Main Page
         
         !! html/parsoid
         
         !! end
         
        @@ -20663,27 +20893,27 @@ foobar.jpg
         
         !! html/parsoid
         
         !! end
         
         !! test
        -Gallery override link with WikiLink (T36852)
        +Gallery override link with wikilink (T36852)
         !! options
         parsoid={
           "nativeGallery": true
         }
         !! wikitext
         
        -File:Foobar.jpg|alt=galleryalt|link=InterWikiLink
        +File:Foobar.jpg|alt=galleryalt|link=Wikilink
         
         !! html/php
         
         
         !! html/parsoid
        -