-Change notes from older releases. For current info see RELEASE-NOTES-1.31.
+Change notes from older releases. For current info see RELEASE-NOTES-1.32.
+
+= MediaWiki 1.31 =
+
+== MediaWiki 1.31.0 ==
+
+=== Changes since MediaWiki 1.31.0-rc.2 ===
+* (T195783) Initialize PSR-4 namespaces at same stage as normal autoloader.
+* (T196092) Hide MySQL binary/utf-8 charset option in the installer.
+* (T196185) Don't allow setting $wgDBmysql5 in the installer.
+* (T196125) php-memcached 3.0 (provided with PHP 7.0) is now supported.
+* (T182366) UploadBase::checkXMLEncodingMissmatch() now works on PHP 7.1+
+* (T118683) Fix exception from &$user deref on HHVM in the TitleMoveComplete hook.
+* (T196672) The mtime of extension.json files is now able to be zero
+* (T180403) Validate $length in padleft/padright parser functions.
+* (T143790) Make $wgEmailConfirmToEdit only affect edit actions.
+
+=== Changes since MediaWiki 1.31.0-rc.0 ===
+* (T33223) Drop archive.ar_text and ar_flags.
+* Add default edit rate limit of 90 edits/minute for all users.
+* (T187645) Use codepoint as tiebreaker when getting first-letters in
+ IcuCollation.
+* (T191947) Don't shell during the installer if shelling out is disabled.
+* (T194319) Improve duplicate config setting exception as part of extension
+ registration.
+* (T195211) Don't require trailing slash in PSR-4 autoloader directory.
+* (T186565) Fix PHP Notice from `ob_end_flush()` in `FileRepo::streamFile()`.
+* Do not incorrectly hide namespace input field in the installer.
+* (T186456) Refactor checks looking for PEAR maik libraries to be clearer.
+
+=== Important pre-upgrade notes for 1.31 ===
+* If you're using MySQL, SQLite, or MSSQL, are not using update.php to apply
+ schema changes, and cannot have downtime to run migrateArchiveText.php and
+ apply patch-drop-ar_text.sql manually, you'll have to apply a default value
+ to the ar_text and ar_flags columns of the archive table or make those
+ columns nullable before upgrading to MediaWiki 1.31.
+ maintenance/archives/patch-nullable-ar_text.sql shows how to do this for MySQL.
+
+=== Configuration changes in 1.31 ===
+* $wgEnableAPI and $wgEnableWriteAPI are now deprecated and will be removed in
+ a future version. The API is now considered to be stable, secure and
+ essential.
+* $wgUsejQueryThree was removed, as it is now the default. This was documented
+ as a temporary variable during the migration period, deprecated since 1.29.
+* $wgLogoHD has been updated to support svg images and uses $wgLogo where
+ possible for fallback images such as png.
+* (T44246) $wgFilterLogTypes will no longer ignore 'patrol' when user does not
+ have the right to mark things patrolled.
+* Wikis that contain imported revisions or CentralAuth global blocks should run
+ maintenance/cleanupUsersWithNoId.php.
+* The configuration settings $wgResourceLoaderMinifierStatementsOnOwnLine and
+ $wgResourceLoaderMinifierMaxLineLength, deprecated since 1.27, were removed.
+* (T180921) $wgReferrerPolicy now supports having fallbacks for browsers that
+ are not using the latest version of the Referrer Policy specification.
+* $wgFragmentMode is now set to [ 'legacy', 'html5' ] by default. This is a
+ first step of migration to human-readable section IDs that will later result
+ in 'html5' being the default mode.
+* CACHE_ACCEL now only supports APC(u) or WinCache. XCache support was removed
+ as upstream is inactive and has no plans to move to PHP 7.
+* The old CategorizedRecentChanges feature, including its related configuration
+ option $wgAllowCategorizedRecentChanges, has been removed.
+* (T188472) The 'comma' value for $wgArticleCountMethod is no longer supported
+ for performance reasons, and installations with this setting will now work as
+ if it was configured with 'any'.
+* (T185753) MediaWiki now defaults to using RemexHtml to tidy up user input,
+ rather than being off by default. If you wish to disable HTML tidying
+ entirely, set $wgTidyConfig to null; if you wish to use the old, deprecated
+ Tidy external binary, both set $wgTidyConfig to null and $wgUseTidy to true.
+* $wgLogAutopatrol now defaults to false instead of true.
+* $wgValidateAllHtml was removed and will be ignored.
+* $wgScriptExtension, deprecated and ignored since 1.25, was removed. See the
+ 1.25 release notes for more information.
+* $wgUseAjax is now marked as deprecated, just like the deprecated AJAX
+ framework that it enables. Some extensions mistakenly used this to check
+ whether any AJAX functionality at all should be enabled, further making this
+ problematic to retain.
+* $wgDBmysql5 is now deprecated, and will be removed in a future version. It
+ has been marked as experimental ever since it was introduced.
+
+=== New features in 1.31 ===
+* (T76554) User sub-pages named ….json are now protected in the same way that
+ ….js and ….css pages are, so that configuration options can safely be placed
+ there.
+* Wikimedia\Rdbms\IDatabase->select() and similar methods now support joins
+ with parentheses for grouping.
+* As a first pass in standardizing dialog boxes across the MediaWiki product,
+ Html class now provides helper methods for messageBox, successBox, errorBox
+ and warningBox generation.
+* (T9240) Imports will now record unknown (and, optionally, known) usernames in
+ a format like "iw>Example".
+* (T20209) Linker (used on history pages, log pages, and so on) will display
+ usernames formed like "iw>Example" as interwiki links, as if by wikitext like
+ [[iw:User:Example|iw>Example]].
+* (T111605) The 'ImportHandleUnknownUser' hook allows extensions to auto-create
+ users during an import.
+* Added a hook, ParserOutputPostCacheTransform, to allow extensions to affect
+ the ParserOutput::getText() post-cache transformations.
+* Added a hook, UploadForm:getInitialPageText, to allow extensions to alter the
+ initial page text for file uploads.
+* (T181651) The info page for File pages now displays the file's base-16 SHA1
+ hash value in the table of basic information.
+* Style tags with a 'data-mw-deduplicate' attribute will be deduplicated as a
+ ParserOutput::getText() post-cache transformation. This may be disabled by
+ passing 'deduplicateStyles' => false to that method.
+* The identity of the logged-in or IP "actor" for logged actions is being moved
+ into a new actor table, with the rows in tables such as revision and logging
+ referring to the actor ID instead of storing the user ID and name/IP in
+ every row.
+ * This is currently gated by $wgActorTableSchemaMigrationStage. Most wikis
+ can set this to MIGRATION_NEW and run maintenance/migrateActors.php as
+ soon as any necessary extensions are updated.
+ * Most code accessing rows for logged actions from the database should use
+ the relevant getQueryInfo() methods to get the information needed to build
+ the SQL query. The ActorMigration class may also be used to get feature
+ -flagged information needed to access actor-related fields during the
+ migration period.
+* Added Wikimedia\Rdbms\IDatabase::cancelAtomic(), to roll back an atomic
+ section without having to roll back the whole transaction.
+* Wikimedia\Rdbms\IDatabase::doAtomicSection(), non-native ::insertSelect(),
+ and non-MySQL ::replace() and ::upsert() no longer roll back the whole
+ transaction on failure.
+* (T189785) Added a monthly heartbeat ping to the pingback feature.
+* The CLI installer (maintenance/install.php) learned to detect and include
+ extensions. Pass --with-extensions to enable that feature.
+* (T184791) rc_patrolled now has three states: "0" for unpatrolled,
+ "1" for manually patrolled and "2" for autopatrolled actions.
+* Extensions can now set their type to "editor" if they provide an editor or
+ enhance the editing experience.
+* Extensions can use a PSR-4 autoloader by setting an "AutoloadNamespaces"
+ property in extension.json. See the documentation at
+ <https://mediawiki.org/wiki/Manual:Extension.json/Schema#AutoloadNamespaces>
+ for more details and an example.
+* (T19099) Tabs which link to pages that don't exist (like those to uncreated
+ discussion pages) now have a tooltip to indicate state, not just colour.
+
+=== External library changes in 1.31 ===
+* pear/mail, pear/mail_mime and pear/mail_mime-decode have been moved from
+ suggested to required. These packages now must be installed via composer
+ and not via PEAR itself.
+
+==== Upgraded external libraries ====
+* Updated jquery.chosen from v0.9.14 to v1.8.2.
+* Updated composer/spdx-licenses from 1.1.4 to 1.3.0 (development dependency).
+* Updated nikic/php-parser from 2.1.0 to 3.1.3 (development dependency).
+* Updated wikimedia/ip-set from 1.1.0 to 1.2.0.
+* Updated wikimedia/relpath from 2.0.0 to 2.1.1.
+* Updated wikimedia/running-stat from 1.1.0 to 1.2.0.
+* Updated wikimedia/wrappedstring from 2.2.0 to 2.3.0.
+* Updated mediawiki/at-ease from 1.1.0 to 1.2.0.
+* Updated wikimedia/php-session-serializer from 1.0.4 to 1.0.6.
+* Updated wikimedia/remex-html from 1.0.2 to 1.0.3.
+* Updated wikimedia/html-formatter from 1.0.1 to 1.0.2.
+
+==== New external libraries ====
+* Added wikimedia/object-factory 1.0.0
+
+==== Removed and replaced external libraries ====
+* (T17845) The deprecated 'jquery.badge' module was removed.
+* The deprecated 'jquery.autoEllipsis' module was removed. Use the CSS
+ text-overflow property instead.
+* The deprecated 'jquery.placeholder' module was removed.
+* The deprecated 'jquery.appear' module was removed. Use the
+ 'mediawiki.viewport' module instead.
+* mediawiki/at-ease was replaced with wikimedia/at-ease.
+
+=== Bug fixes in 1.31 ===
+* (T90902) Non-breaking space in header ID breaks anchor.
+* (T189375) CSSMin now allows quoted urls in `url()` syntax to start with a
+ space.
+* (T2087, T10897, T87753, T174639) Whitespace created by category and language
+ links is now stripped rather than leaving blank lines in odd places.
+* (T3780) Uploads with UTF-8 names now work on PHP7.1+ on Windows servers.
+* (T182366) UploadBase::checkXMLEncodingMissmatch() now works on PHP 7.1+
+
+=== Action API changes in 1.31 ===
+* (T185058) The 'name' value to tgprop for action=query&list=tags has been
+ removed. It has never made a difference in the output, the name was always
+ returned regardless.
+* The 'watch' and 'unwatch' parameters for action=move have been removed. They
+ were deprecated and also accidentally nonfunctional since 1.17 in 2010. Use
+ 'watchlist' instead.
+
+=== Action API internal changes in 1.31 ===
+* ApiBase::getProfileDBTime, deprecated since 1.25, was removed.
+* ApiBase::getModuleProfileName, deprecated since 1.25, was removed.
+* ApiBase::getProfileTime, deprecated since 1.25, was removed.
+
+=== Languages updated in 1.31 ===
+MediaWiki supports over 350 languages. Many localisations are updated
+regularly. Below only new and removed languages are listed, as well as
+changes to languages because of Phabricator reports.
+
+* (T180052) Mirandese (mwl) now supports gendered NS_USER/NS_USER_TALK.
+* (T182305) New language support: Nyungar (nys).
+* (T186359) New language support: Siberian Tatar [cебертатар] (sty).
+* (T186635) New language support: Guianan Creole (gcr).
+* (T186647) New language support: Kumyk [къумукъ] (kum).
+* (T187750) New language support: Spanish formal address (es-formal).
+* (T187824) New language support: Hungarian formal address (hu-formal).
+* (T189127) New language support: Gorontalo (gor).
+
+=== Breaking changes in 1.31 ===
+* MessageBlobStore::insertMessageBlob(), deprecated in 1.27, was removed.
+* The OutputPage class constructor now requires a context parameter.
+ Instantiating without context was deprecated in 1.18.
+* The mw.page JavaScript singleton, deprecated in 1.30, was removed.
+* Article::getLastPurgeTimestamp(), WikiPage::getLastPurgeTimestamp(), and the
+ related WikiPage::PURGE_* constants, deprecated in 1.29, were removed.
+* The Article::selectFields(), ::onArticleCreate(), ::onArticleDelete(), and
+ ::onArticleEdit() methods, deprecated in 1.24, were removed.
+* Installer::locateExecutable() and ::locateExecutableInDefaultPaths() were
+ removed. Use ExecutableFinder::findInDefaultPaths() instead.
+* The deprecated MW_DIFF_VERSION constant was removed.
+ DifferenceEngine::MW_DIFF_VERSION should be used instead.
+* Due to significant refactoring, method ContribsPager::getUserCond() that had
+ no access restriction has been removed.
+* The Block class will no longer accept usable-but-missing usernames for
+ 'byText' or ->setBlocker(). Callers should either ensure the blocker exists
+ locally or use a new interwiki-format username like "iw>Example".
+* The following methods and constants from the WatchedItem class, which were
+ deprecated in 1.27, have been removed:
+ * WatchedItem::getTitle()
+ * WatchedItem::fromUserTitle()
+ * WatchedItem::addWatch()
+ * WatchedItem::removeWatch()
+ * WatchedItem::isWatched()
+ * WatchedItem::duplicateEntries()
+ * WatchedItem::IGNORE_USER_RIGHTS
+ * WatchedItem::CHECK_USER_RIGHTS
+ * WatchedItem::DEPRECATED_USAGE_TIMESTAMP
+* The $statementsOnOwnLine parameter of JavaScriptMinifier::minify was removed.
+ $wgResourceLoaderMinifierStatementsOnOwnLine, the corresponding configuration
+ variable, has been deprecated since 1.27 and was removed as well.
+* The $maxLineLength parameter of JavaScriptMinifier::minify was removed.
+ $wgResourceLoaderMinifierMaxLineLength, the corresponding configuration
+ variable, has been deprecated since 1.27 and was removed as well.
+* The HtmlFormatter class, deprecated in 1.27, was removed. The namespaced
+ HtmlFormatter\HtmlFormatter class should be used instead.
+* The driver 'mysql' for MySQL, deprecated in MediaWiki 1.30, has been removed.
+ The driver has been deprecated since PHP 5.5 and was removed in PHP 7.0. The
+ default driver for MySQL has been 'mysqli' since MediaWiki 1.22.
+* The following properties of PreparedEdit were deprecated in 1.21 and have
+ been removed:
+ * PreparedEdit->newText
+ * PreparedEdit->oldText
+ * PreparedEdit->pst
+* ParserOutput objects which are generated using a non-default value for
+ ParserOptions::setWrapOutputClass() can no longer be added to the parser
+ cache.
+* The following deprecated methods from the OutputPage class have been removed:
+ * OutputPage::addExtensionStyle(); deprecated in 1.27
+ * OutputPage::getExtStyle(); deprecated in 1.27
+ * OutputPage::setETag(); deprecated in 1.28 (obsolete no-op)
+ * OutputPage::setSquidMaxage(); deprecated in 1.27
+ * OutputPage::readOnlyPage(); deprecated in 1.25
+ * OutputPage::rateLimited(); deprecated in 1.25
+ * Additionally, the protected OutputPage::$mExtStyles array, only accessed
+ through the above and with no known uses, was removed.
+* The no-op method Skin::showIPinHeader(), deprecated in 1.27, was removed.
+* The following variables and methods in EditPage, deprecated in MediaWiki 1.30,
+ were removed:
+ * $isCssJsSubpage — use ::isUserConfigPage()
+ * $isCssSubpage — use ::isUserCssConfigPage()
+ * $isJsSubpage — use ::isUserJsConfigPage()
+ * $isWrongCaseCssJsPage – use ::isWrongCaseUserConfigPage()
+ * ::getSummaryInput() – use ::getSummaryInputWidget()
+ * ::getSummaryInputOOUI() – use ::getSummaryInputWidget()
+ * ::getCheckboxes() – use ::getCheckboxesWidget() or
+ ::getCheckboxesDefinition()
+ * ::getCheckboxesOOUI() – use ::getCheckboxesWidget() or
+ ::getCheckboxesDefinition()
+* ResourceLoaderModule::getPosition(), deprecated in 1.29, has been removed.
+* In User, the cookie-related methods which were wrappers for the functions on
+ the response object, and were deprecated in 1.27, have been removed:
+ * ::setCookie()
+ * ::clearCookie()
+ * ::setExtendedLoginCookie()
+ Note that User::setCookies() remains, and is not deprecated.
+* Also in User, some auth-related methods which were deprecated in 1.27 have
+ been removed:
+ * ::getEditTokenTimestamp() – use MediaWiki\Session\Token::getTimestamp()
+ * ::getPasswordFactory() – create a PasswordFactory directly
+ * ::passwordChangeInputAttribs()
+* The global functions wfProfileIn and wfProfileOut, deprecated in 1.25, have
+ been removed.
+* SpecialPageFactory::getList(), deprecated in 1.24, has been removed. You can
+ use ::getNames() instead.
+* OpenSearch::getOpenSearchTemplate(), deprecated in 1.25, has been removed. You
+ can use ApiOpenSearch::getOpenSearchTemplate() instead.
+* The global function wfBaseConvert, deprecated in 1.27, has been removed. Use
+ Wikimedia\base_convert() directly.
+* Calling Database::begin() explicitly during an implicit transaction or when
+ DBO_TRX is set results in an exception. Calling Database::commit() explicitly
+ for an implicit transaction also results in an exception. Previously these
+ were logged as errors. The startAtomic() and endAtomic() methods, or
+ AtomicSectionUpdate should be used instead.
+* The global function wfOutputHandler() was removed, use the its replacement
+ MediaWiki\OutputHandler::handle() instead. The global function was only
+ sometimes defined. Its replacement is always available via the autoloader.
+* ChangeTags::listExtensionActivatedTags and ::listExtensionDefinedTags,
+ deprecated in 1.28, have been removed. Use ::listSoftwareActivatedTags() and
+ ::listSoftwareDefinedTags() instead.
+* Title::getTitleInvalidRegex(), deprecated in 1.25, has been removed. You can
+ use MediaWikiTitleCodec::getTitleInvalidRegex() instead.
+* HTMLForm & VFormHTMLForm::isVForm(), deprecated in 1.25, have been removed.
+* The ProfileSection class, deprecated in 1.25 and unused, has been removed.
+* The ResourceLoaderGetLessVars hook, deprecated in 1.30, has been removed. Use
+ ResourceLoaderModule::getLessVars() to expose local variables instead of
+ global ones.
+* As part of work to modernise user-generated content clean-up, a config option
+ and some methods related to HTML validity were removed without deprecation.
+ The public methods MWTidy::checkErrors() and the path through which it was
+ called, TidyDriverBase::validate(), are removed, as are the testing methods
+ MediaWikiTestCase::assertValidHtmlSnippet() and ::assertValidHtmlDocument().
+ The $wgValidateAllHtml configuration option is removed and will be ignored.
+* Execution of external programs using MediaWiki\Shell\Command now applies
+ the RESTRICT_DEFAULT Firejail restriction by default.
+* The ResourceLoaderModule::getHashMtime() and ::getDefinitionMtime() methods,
+ deprecated in 1.26, were removed.
+* The deprecated 'mediawiki.widgets.CategorySelector' module alias was removed.
+ Use the 'mediawiki.widgets.CategoryMultiselectWidget' module directly.
+
+=== Deprecations in 1.31 ===
+* The Revision class was deprecated in favor of RevisionStore, BlobStore, and
+ RevisionRecord and its subclasses.
+* The global function wfBCP47 is deprecated in favour of LanguageCode::bcp47.
+* The global function wfCountDown is now deprecated in favor of
+ Maintenance::countDown.
+* Several methods for returning lists of fields to select from the database
+ have been deprecated in favor of similar methods that also return the tables
+ to select from and the join conditions for those tables.
+ * Block::selectFields() → Block::getQueryInfo()
+ * RecentChange::selectFields() → RecentChange::getQueryInfo()
+ * ArchivedFile::selectFields() → ArchivedFile::getQueryInfo()
+ * LocalFile::selectFields() → LocalFile::getQueryInfo()
+ * LocalFile::getCacheFields() with a prefix no longer works
+ * LocalFile::getLazyCacheFields() with a prefix no longer works
+ * OldLocalFile::selectFields() → OldLocalFile::getQueryInfo()
+ * RecentChange::selectFields() → RecentChange::getQueryInfo()
+ * Revision::userJoinCond() → Revision::getQueryInfo( [ 'user' ] )
+ * Revision::selectUserFields() → Revision::getQueryInfo( [ 'user' ] )
+ * Revision::pageJoinCond() → Revision::getQueryInfo( [ 'page' ] )
+ * Revision::selectPageFields() → Revision::getQueryInfo( [ 'page' ] )
+ * Revision::selectTextFields() → Revision::getQueryInfo( [ 'text' ] )
+ * Revision::selectFields() → Revision::getQueryInfo()
+ * Revision::selectArchiveFields() → Revision::getArchiveQueryInfo()
+ * User::selectFields() → User::getQueryInfo()
+ * WikiPage::selectFields() → WikiPage::getQueryInfo()
+* Revision::setUserIdAndName() was deprecated.
+* Access to TitleValue class properties was deprecated, the relevant getters
+ should be used instead.
+* DifferenceEngine::getDiffBodyCacheKey() is deprecated. Subclasses should
+ override DifferenceEngine::getDiffBodyCacheKeyParams() instead.
+* Use of Maintenance::error( $err, $die ) to exit script was deprecated. Use
+ Maintenance::fatalError() instead.
+* Passing a ParserOptions object to OutputPage::parserOptions() is deprecated.
+* The RevisionInsertComplete hook is now deprecated; use instead the hook
+ RevisionRecordInserted. RevisionInsertComplete is still called, but the second
+ and third parameter will always be null. Hard deprecation is scheduled for 1.32.
+* The following methods that get and set ParserOutput state are deprecated.
+ Callers should use the new stateless $options parameter to
+ ParserOutput::getText() instead.
+ * ParserOptions::getEditSection()
+ * ParserOptions::setEditSection()
+ * ParserOutput::getEditSectionTokens()
+ * ParserOutput::setEditSectionTokens()
+ * ParserOutput::getTOCEnabled()
+ * ParserOutput::setTOCEnabled()
+ * OutputPage::enableSectionEditLinks()
+ * OutputPage::sectionEditLinksEnabled()
+ * The public ParserOutput state fields $mTOCEnabled and $mEditSectionTokens
+ are also deprecated.
+* License::getLicenses has been deprecated; use License::getLines instead.
+* QuickTemplate::setRef() was deprecated in favour of QuickTemplate::set().
+ Setting template variables by reference allowed violating the principle of
+ data being immutable once added to the skin template. In practice, this method
+ was not being used for that. Rather, setRef() existed as memory optimisation
+ for PHP 4.
+* QuickTemplate::setTranslator() and MediaWikiI18N::set() were deprecated in
+ favour of Skin::msg() parameters.
+* MediaWikiI18N::translate() was deprecated in favour of Skin::msg() or
+ wfMessage().
+* Passing false to ParserOptions::setWrapOutputClass() is deprecated. Use the
+ 'unwrap' transform to ParserOutput::getText() instead.
+* \ObjectFactory (no namespace) is deprecated, the namespaced class
+ \Wikimedia\ObjectFactory from the wikimedia/object-factory library should be
+ used instead.
+* CommentStore::newKey is deprecated. Instead, get an instance from
+ MediaWikiServices.
+* The following CommentStore methods have had their signatures changed to
+ introduce a $key parameter, usage of the methods on instances retrieved from
+ CommentStore::newKey will remain unchanged but deprecated:
+ * CommentStore::getFields
+ * CommentStore::getJoin
+ * CommentStore::getComment
+ * CommentStore::getCommentLegacy
+ * CommentStore::insert
+ * CommentStore::insertWithTemplate
+* The following methods in Title have been renamed, and the old ones are
+ deprecated:
+ * Title::getSkinFromCssJsSubpage – use ::getSkinFromConfigSubpage
+ * Title::isCssOrJsPage – use ::isSiteConfigPage
+ * Title::isCssJsSubpage – use ::isUserConfigPage
+ * Title::isCssSubpage – use ::isUserCssConfigPage
+ * Title::isJsSubpage – use ::isUserJsConfigPage
+* The following methods related to caching of half-parsed HTML were deprecated:
+ * Parser::serializeHalfParsedText()
+ * Parser::unserializeHalfParsedText()
+ * Parser::isValidHalfParsedText()
+ * StripState::getSubState()
+ * StripState::merge()
+* The DeferredStringifier class is deprecated, use Message::listParam() instead.
+* The type string for the parameter $lang of DateFormatter::getInstance is
+ deprecated.
+* Wikimedia\Rdbms\SavepointPostgres is deprecated.
+* The DO_MAINTENANCE constant is deprecated. RUN_MAINTENANCE_IF_MAIN should be
+ used instead.
+* The function wfShellWikiCmd() has been deprecated, use
+ MediaWiki\Shell::makeScriptCommand().
+* In the future, the hooks 'PreferencesFormPreSave' and 'PreferencesGetLegend'
+ will be allowed to provide any HTMLForm object rather than PreferencesForm.
+
+=== Other changes in 1.31 ===
+* Browser support for Internet Explorer 10 was lowered from Grade A to Grade C.
+* Browser support for Opera 12 and older was dropped entirely. Opera 15+
+ continues at Grade A.
+* Multi-content-revision capability was introduced into the storage layer. See
+ <https://mediawiki.org/wiki/Requests_for_comment/Multi-Content_Revisions>.
+* The "free" CSS class is now only applied to unbracketed URLs in wikitext.
+ Links written using square brackets will get the class "text" not "free".
+* RFC 157418: Whitespace is trimmed from wikitext headings, wikitext list items,
+ wikitext table captions, wikitext table headings, wikitext table cells. HTML
+ headings, HTML list items, HTML table captions, HTML table headings, HTML
+ table cells will not have this trimming behavior.
+
+== Compatibility ==
+MediaWiki 1.31 requires PHP 7.0.0 or later. Although HHVM 3.18.5 or later is
+supported, it is generally advised to use PHP 7.0.0 or later for long term
+support.
+
+MySQL/MariaDB is the recommended DBMS. PostgreSQL or SQLite can also be used,
+but support for them is somewhat less mature. There is experimental support for
+Oracle and Microsoft SQL Server.
+
+The supported versions are:
+
+* MySQL 5.5.8 or later
+* PostgreSQL 9.2 or later
+* SQLite 3.3.7 or later
+* Oracle 9.0.1 or later
+* Microsoft SQL Server 2005 (9.00.1399)
+
+== Upgrading ==
+1.31 has several database changes since 1.30, and will not work without schema
+updates. Note that due to changes to some very large tables like the revision
+table, the schema update may take quite long (minutes on a medium sized site,
+many hours on a large site).
+
+Don't forget to always back up your database before upgrading!
+
+See the file UPGRADE for more detailed upgrade instructions, including
+important information when upgrading from versions prior to 1.11.
+
+For notes on 1.30.x and older releases, see HISTORY.
+
+== Online documentation ==
+Documentation for both end-users and site administrators is available on
+MediaWiki.org, and is covered under the GNU Free Documentation License (except
+for pages that explicitly state that their contents are in the public domain):
+
+ https://www.mediawiki.org/wiki/Special:MyLanguage/Documentation
+
+== Mailing list ==
+A mailing list is available for MediaWiki user support and discussion:
+
+ https://lists.wikimedia.org/mailman/listinfo/mediawiki-l
+
+A low-traffic announcements-only list is also available:
+
+ https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce
+
+It's highly recommended that you sign up for one of these lists if you're
+going to run a public MediaWiki, so you can be notified of security fixes.
+
+== IRC help ==
+There's usually someone online in #mediawiki on irc.freenode.net.
+
= MediaWiki 1.30 =
+++ /dev/null
-== MediaWiki 1.31 ==
-
-THIS IS NOT A RELEASE YET
-
-MediaWiki 1.31 is an alpha-quality branch and is not recommended for use in
-production.
-
-=== Changes since MediaWiki 1.31.0-rc.2 ===
-* (T195783) Initialize PSR-4 namespaces at same stage as normal autoloader.
-* (T196092) Hide MySQL binary/utf-8 charset option in the installer.
-* (T196185) Don't allow setting $wgDBmysql5 in the installer.
-* (T196125) php-memcached 3.0 (provided with PHP 7.0) is now supported.
-* (T182366) UploadBase::checkXMLEncodingMissmatch() now works on PHP 7.1+
-* (T118683) Fix exception from &$user deref on HHVM in the TitleMoveComplete hook.
-* (T196672) The mtime of extension.json files is now able to be zero
-* (T180403) Validate $length in padleft/padright parser functions.
-* (T143790) Make $wgEmailConfirmToEdit only affect edit actions.
-
-=== Changes since MediaWiki 1.31.0-rc.0 ===
-* (T33223) Drop archive.ar_text and ar_flags.
-* Add default edit rate limit of 90 edits/minute for all users.
-* (T187645) Use codepoint as tiebreaker when getting first-letters in
- IcuCollation.
-* (T191947) Don't shell during the installer if shelling out is disabled.
-* (T194319) Improve duplicate config setting exception as part of extension
- registration.
-* (T195211) Don't require trailing slash in PSR-4 autoloader directory.
-* (T186565) Fix PHP Notice from `ob_end_flush()` in `FileRepo::streamFile()`.
-* Do not incorrectly hide namespace input field in the installer.
-* (T186456) Refactor checks looking for PEAR maik libraries to be clearer.
-
-=== Important pre-upgrade notes for 1.31 ===
-* If you're using MySQL, SQLite, or MSSQL, are not using update.php to apply
- schema changes, and cannot have downtime to run migrateArchiveText.php and
- apply patch-drop-ar_text.sql manually, you'll have to apply a default value
- to the ar_text and ar_flags columns of the archive table or make those
- columns nullable before upgrading to MediaWiki 1.31.
- maintenance/archives/patch-nullable-ar_text.sql shows how to do this for MySQL.
-
-=== Configuration changes in 1.31 ===
-* $wgEnableAPI and $wgEnableWriteAPI are now deprecated and will be removed in
- a future version. The API is now considered to be stable, secure and
- essential.
-* $wgUsejQueryThree was removed, as it is now the default. This was documented
- as a temporary variable during the migration period, deprecated since 1.29.
-* $wgLogoHD has been updated to support svg images and uses $wgLogo where
- possible for fallback images such as png.
-* (T44246) $wgFilterLogTypes will no longer ignore 'patrol' when user does not
- have the right to mark things patrolled.
-* Wikis that contain imported revisions or CentralAuth global blocks should run
- maintenance/cleanupUsersWithNoId.php.
-* The configuration settings $wgResourceLoaderMinifierStatementsOnOwnLine and
- $wgResourceLoaderMinifierMaxLineLength, deprecated since 1.27, were removed.
-* (T180921) $wgReferrerPolicy now supports having fallbacks for browsers that
- are not using the latest version of the Referrer Policy specification.
-* $wgFragmentMode is now set to [ 'legacy', 'html5' ] by default. This is a
- first step of migration to human-readable section IDs that will later result
- in 'html5' being the default mode.
-* CACHE_ACCEL now only supports APC(u) or WinCache. XCache support was removed
- as upstream is inactive and has no plans to move to PHP 7.
-* The old CategorizedRecentChanges feature, including its related configuration
- option $wgAllowCategorizedRecentChanges, has been removed.
-* (T188472) The 'comma' value for $wgArticleCountMethod is no longer supported
- for performance reasons, and installations with this setting will now work as
- if it was configured with 'any'.
-* (T185753) MediaWiki now defaults to using RemexHtml to tidy up user input,
- rather than being off by default. If you wish to disable HTML tidying
- entirely, set $wgTidyConfig to null; if you wish to use the old, deprecated
- Tidy external binary, both set $wgTidyConfig to null and $wgUseTidy to true.
-* $wgLogAutopatrol now defaults to false instead of true.
-* $wgValidateAllHtml was removed and will be ignored.
-* $wgScriptExtension, deprecated and ignored since 1.25, was removed. See the
- 1.25 release notes for more information.
-* $wgUseAjax is now marked as deprecated, just like the deprecated AJAX
- framework that it enables. Some extensions mistakenly used this to check
- whether any AJAX functionality at all should be enabled, further making this
- problematic to retain.
-* $wgDBmysql5 is now deprecated, and will be removed in a future version. It
- has been marked as experimental ever since it was introduced.
-
-=== New features in 1.31 ===
-* (T76554) User sub-pages named ….json are now protected in the same way that
- ….js and ….css pages are, so that configuration options can safely be placed
- there.
-* Wikimedia\Rdbms\IDatabase->select() and similar methods now support joins
- with parentheses for grouping.
-* As a first pass in standardizing dialog boxes across the MediaWiki product,
- Html class now provides helper methods for messageBox, successBox, errorBox
- and warningBox generation.
-* (T9240) Imports will now record unknown (and, optionally, known) usernames in
- a format like "iw>Example".
-* (T20209) Linker (used on history pages, log pages, and so on) will display
- usernames formed like "iw>Example" as interwiki links, as if by wikitext like
- [[iw:User:Example|iw>Example]].
-* (T111605) The 'ImportHandleUnknownUser' hook allows extensions to auto-create
- users during an import.
-* Added a hook, ParserOutputPostCacheTransform, to allow extensions to affect
- the ParserOutput::getText() post-cache transformations.
-* Added a hook, UploadForm:getInitialPageText, to allow extensions to alter the
- initial page text for file uploads.
-* (T181651) The info page for File pages now displays the file's base-16 SHA1
- hash value in the table of basic information.
-* Style tags with a 'data-mw-deduplicate' attribute will be deduplicated as a
- ParserOutput::getText() post-cache transformation. This may be disabled by
- passing 'deduplicateStyles' => false to that method.
-* The identity of the logged-in or IP "actor" for logged actions is being moved
- into a new actor table, with the rows in tables such as revision and logging
- referring to the actor ID instead of storing the user ID and name/IP in
- every row.
- * This is currently gated by $wgActorTableSchemaMigrationStage. Most wikis
- can set this to MIGRATION_NEW and run maintenance/migrateActors.php as
- soon as any necessary extensions are updated.
- * Most code accessing rows for logged actions from the database should use
- the relevant getQueryInfo() methods to get the information needed to build
- the SQL query. The ActorMigration class may also be used to get feature
- -flagged information needed to access actor-related fields during the
- migration period.
-* Added Wikimedia\Rdbms\IDatabase::cancelAtomic(), to roll back an atomic
- section without having to roll back the whole transaction.
-* Wikimedia\Rdbms\IDatabase::doAtomicSection(), non-native ::insertSelect(),
- and non-MySQL ::replace() and ::upsert() no longer roll back the whole
- transaction on failure.
-* (T189785) Added a monthly heartbeat ping to the pingback feature.
-* The CLI installer (maintenance/install.php) learned to detect and include
- extensions. Pass --with-extensions to enable that feature.
-* (T184791) rc_patrolled now has three states: "0" for unpatrolled,
- "1" for manually patrolled and "2" for autopatrolled actions.
-* Extensions can now set their type to "editor" if they provide an editor or
- enhance the editing experience.
-* Extensions can use a PSR-4 autoloader by setting an "AutoloadNamespaces"
- property in extension.json. See the documentation at
- <https://mediawiki.org/wiki/Manual:Extension.json/Schema#AutoloadNamespaces>
- for more details and an example.
-* (T19099) Tabs which link to pages that don't exist (like those to uncreated
- discussion pages) now have a tooltip to indicate state, not just colour.
-
-=== External library changes in 1.31 ===
-* pear/mail, pear/mail_mime and pear/mail_mime-decode have been moved from
- suggested to required. These packages now must be installed via composer
- and not via PEAR itself.
-
-==== Upgraded external libraries ====
-* Updated jquery.chosen from v0.9.14 to v1.8.2.
-* Updated composer/spdx-licenses from 1.1.4 to 1.3.0 (development dependency).
-* Updated nikic/php-parser from 2.1.0 to 3.1.3 (development dependency).
-* Updated wikimedia/ip-set from 1.1.0 to 1.2.0.
-* Updated wikimedia/relpath from 2.0.0 to 2.1.1.
-* Updated wikimedia/running-stat from 1.1.0 to 1.2.0.
-* Updated wikimedia/wrappedstring from 2.2.0 to 2.3.0.
-* Updated mediawiki/at-ease from 1.1.0 to 1.2.0.
-* Updated wikimedia/php-session-serializer from 1.0.4 to 1.0.6.
-* Updated wikimedia/remex-html from 1.0.2 to 1.0.3.
-* Updated wikimedia/html-formatter from 1.0.1 to 1.0.2.
-
-==== New external libraries ====
-* Added wikimedia/object-factory 1.0.0
-
-==== Removed and replaced external libraries ====
-* (T17845) The deprecated 'jquery.badge' module was removed.
-* The deprecated 'jquery.autoEllipsis' module was removed. Use the CSS
- text-overflow property instead.
-* The deprecated 'jquery.placeholder' module was removed.
-* The deprecated 'jquery.appear' module was removed. Use the
- 'mediawiki.viewport' module instead.
-* mediawiki/at-ease was replaced with wikimedia/at-ease.
-
-=== Bug fixes in 1.31 ===
-* (T90902) Non-breaking space in header ID breaks anchor.
-* (T189375) CSSMin now allows quoted urls in `url()` syntax to start with a
- space.
-* (T2087, T10897, T87753, T174639) Whitespace created by category and language
- links is now stripped rather than leaving blank lines in odd places.
-* (T3780) Uploads with UTF-8 names now work on PHP7.1+ on Windows servers.
-* (T182366) UploadBase::checkXMLEncodingMissmatch() now works on PHP 7.1+
-
-=== Action API changes in 1.31 ===
-* (T185058) The 'name' value to tgprop for action=query&list=tags has been
- removed. It has never made a difference in the output, the name was always
- returned regardless.
-* The 'watch' and 'unwatch' parameters for action=move have been removed. They
- were deprecated and also accidentally nonfunctional since 1.17 in 2010. Use
- 'watchlist' instead.
-
-=== Action API internal changes in 1.31 ===
-* ApiBase::getProfileDBTime, deprecated since 1.25, was removed.
-* ApiBase::getModuleProfileName, deprecated since 1.25, was removed.
-* ApiBase::getProfileTime, deprecated since 1.25, was removed.
-
-=== Languages updated in 1.31 ===
-MediaWiki supports over 350 languages. Many localisations are updated
-regularly. Below only new and removed languages are listed, as well as
-changes to languages because of Phabricator reports.
-
-* (T180052) Mirandese (mwl) now supports gendered NS_USER/NS_USER_TALK.
-* (T182305) New language support: Nyungar (nys).
-* (T186359) New language support: Siberian Tatar [cебертатар] (sty).
-* (T186635) New language support: Guianan Creole (gcr).
-* (T186647) New language support: Kumyk [къумукъ] (kum).
-* (T187750) New language support: Spanish formal address (es-formal).
-* (T187824) New language support: Hungarian formal address (hu-formal).
-* (T189127) New language support: Gorontalo (gor).
-
-=== Breaking changes in 1.31 ===
-* MessageBlobStore::insertMessageBlob(), deprecated in 1.27, was removed.
-* The OutputPage class constructor now requires a context parameter.
- Instantiating without context was deprecated in 1.18.
-* The mw.page JavaScript singleton, deprecated in 1.30, was removed.
-* Article::getLastPurgeTimestamp(), WikiPage::getLastPurgeTimestamp(), and the
- related WikiPage::PURGE_* constants, deprecated in 1.29, were removed.
-* The Article::selectFields(), ::onArticleCreate(), ::onArticleDelete(), and
- ::onArticleEdit() methods, deprecated in 1.24, were removed.
-* Installer::locateExecutable() and ::locateExecutableInDefaultPaths() were
- removed. Use ExecutableFinder::findInDefaultPaths() instead.
-* The deprecated MW_DIFF_VERSION constant was removed.
- DifferenceEngine::MW_DIFF_VERSION should be used instead.
-* Due to significant refactoring, method ContribsPager::getUserCond() that had
- no access restriction has been removed.
-* The Block class will no longer accept usable-but-missing usernames for
- 'byText' or ->setBlocker(). Callers should either ensure the blocker exists
- locally or use a new interwiki-format username like "iw>Example".
-* The following methods and constants from the WatchedItem class, which were
- deprecated in 1.27, have been removed:
- * WatchedItem::getTitle()
- * WatchedItem::fromUserTitle()
- * WatchedItem::addWatch()
- * WatchedItem::removeWatch()
- * WatchedItem::isWatched()
- * WatchedItem::duplicateEntries()
- * WatchedItem::IGNORE_USER_RIGHTS
- * WatchedItem::CHECK_USER_RIGHTS
- * WatchedItem::DEPRECATED_USAGE_TIMESTAMP
-* The $statementsOnOwnLine parameter of JavaScriptMinifier::minify was removed.
- $wgResourceLoaderMinifierStatementsOnOwnLine, the corresponding configuration
- variable, has been deprecated since 1.27 and was removed as well.
-* The $maxLineLength parameter of JavaScriptMinifier::minify was removed.
- $wgResourceLoaderMinifierMaxLineLength, the corresponding configuration
- variable, has been deprecated since 1.27 and was removed as well.
-* The HtmlFormatter class, deprecated in 1.27, was removed. The namespaced
- HtmlFormatter\HtmlFormatter class should be used instead.
-* The driver 'mysql' for MySQL, deprecated in MediaWiki 1.30, has been removed.
- The driver has been deprecated since PHP 5.5 and was removed in PHP 7.0. The
- default driver for MySQL has been 'mysqli' since MediaWiki 1.22.
-* The following properties of PreparedEdit were deprecated in 1.21 and have
- been removed:
- * PreparedEdit->newText
- * PreparedEdit->oldText
- * PreparedEdit->pst
-* ParserOutput objects which are generated using a non-default value for
- ParserOptions::setWrapOutputClass() can no longer be added to the parser
- cache.
-* The following deprecated methods from the OutputPage class have been removed:
- * OutputPage::addExtensionStyle(); deprecated in 1.27
- * OutputPage::getExtStyle(); deprecated in 1.27
- * OutputPage::setETag(); deprecated in 1.28 (obsolete no-op)
- * OutputPage::setSquidMaxage(); deprecated in 1.27
- * OutputPage::readOnlyPage(); deprecated in 1.25
- * OutputPage::rateLimited(); deprecated in 1.25
- * Additionally, the protected OutputPage::$mExtStyles array, only accessed
- through the above and with no known uses, was removed.
-* The no-op method Skin::showIPinHeader(), deprecated in 1.27, was removed.
-* The following variables and methods in EditPage, deprecated in MediaWiki 1.30,
- were removed:
- * $isCssJsSubpage — use ::isUserConfigPage()
- * $isCssSubpage — use ::isUserCssConfigPage()
- * $isJsSubpage — use ::isUserJsConfigPage()
- * $isWrongCaseCssJsPage – use ::isWrongCaseUserConfigPage()
- * ::getSummaryInput() – use ::getSummaryInputWidget()
- * ::getSummaryInputOOUI() – use ::getSummaryInputWidget()
- * ::getCheckboxes() – use ::getCheckboxesWidget() or
- ::getCheckboxesDefinition()
- * ::getCheckboxesOOUI() – use ::getCheckboxesWidget() or
- ::getCheckboxesDefinition()
-* ResourceLoaderModule::getPosition(), deprecated in 1.29, has been removed.
-* In User, the cookie-related methods which were wrappers for the functions on
- the response object, and were deprecated in 1.27, have been removed:
- * ::setCookie()
- * ::clearCookie()
- * ::setExtendedLoginCookie()
- Note that User::setCookies() remains, and is not deprecated.
-* Also in User, some auth-related methods which were deprecated in 1.27 have
- been removed:
- * ::getEditTokenTimestamp() – use MediaWiki\Session\Token::getTimestamp()
- * ::getPasswordFactory() – create a PasswordFactory directly
- * ::passwordChangeInputAttribs()
-* The global functions wfProfileIn and wfProfileOut, deprecated in 1.25, have
- been removed.
-* SpecialPageFactory::getList(), deprecated in 1.24, has been removed. You can
- use ::getNames() instead.
-* OpenSearch::getOpenSearchTemplate(), deprecated in 1.25, has been removed. You
- can use ApiOpenSearch::getOpenSearchTemplate() instead.
-* The global function wfBaseConvert, deprecated in 1.27, has been removed. Use
- Wikimedia\base_convert() directly.
-* Calling Database::begin() explicitly during an implicit transaction or when
- DBO_TRX is set results in an exception. Calling Database::commit() explicitly
- for an implicit transaction also results in an exception. Previously these
- were logged as errors. The startAtomic() and endAtomic() methods, or
- AtomicSectionUpdate should be used instead.
-* The global function wfOutputHandler() was removed, use the its replacement
- MediaWiki\OutputHandler::handle() instead. The global function was only
- sometimes defined. Its replacement is always available via the autoloader.
-* ChangeTags::listExtensionActivatedTags and ::listExtensionDefinedTags,
- deprecated in 1.28, have been removed. Use ::listSoftwareActivatedTags() and
- ::listSoftwareDefinedTags() instead.
-* Title::getTitleInvalidRegex(), deprecated in 1.25, has been removed. You can
- use MediaWikiTitleCodec::getTitleInvalidRegex() instead.
-* HTMLForm & VFormHTMLForm::isVForm(), deprecated in 1.25, have been removed.
-* The ProfileSection class, deprecated in 1.25 and unused, has been removed.
-* The ResourceLoaderGetLessVars hook, deprecated in 1.30, has been removed. Use
- ResourceLoaderModule::getLessVars() to expose local variables instead of
- global ones.
-* As part of work to modernise user-generated content clean-up, a config option
- and some methods related to HTML validity were removed without deprecation.
- The public methods MWTidy::checkErrors() and the path through which it was
- called, TidyDriverBase::validate(), are removed, as are the testing methods
- MediaWikiTestCase::assertValidHtmlSnippet() and ::assertValidHtmlDocument().
- The $wgValidateAllHtml configuration option is removed and will be ignored.
-* Execution of external programs using MediaWiki\Shell\Command now applies
- the RESTRICT_DEFAULT Firejail restriction by default.
-* The ResourceLoaderModule::getHashMtime() and ::getDefinitionMtime() methods,
- deprecated in 1.26, were removed.
-* The deprecated 'mediawiki.widgets.CategorySelector' module alias was removed.
- Use the 'mediawiki.widgets.CategoryMultiselectWidget' module directly.
-
-=== Deprecations in 1.31 ===
-* The Revision class was deprecated in favor of RevisionStore, BlobStore, and
- RevisionRecord and its subclasses.
-* The global function wfBCP47 is deprecated in favour of LanguageCode::bcp47.
-* The global function wfCountDown is now deprecated in favor of
- Maintenance::countDown.
-* Several methods for returning lists of fields to select from the database
- have been deprecated in favor of similar methods that also return the tables
- to select from and the join conditions for those tables.
- * Block::selectFields() → Block::getQueryInfo()
- * RecentChange::selectFields() → RecentChange::getQueryInfo()
- * ArchivedFile::selectFields() → ArchivedFile::getQueryInfo()
- * LocalFile::selectFields() → LocalFile::getQueryInfo()
- * LocalFile::getCacheFields() with a prefix no longer works
- * LocalFile::getLazyCacheFields() with a prefix no longer works
- * OldLocalFile::selectFields() → OldLocalFile::getQueryInfo()
- * RecentChange::selectFields() → RecentChange::getQueryInfo()
- * Revision::userJoinCond() → Revision::getQueryInfo( [ 'user' ] )
- * Revision::selectUserFields() → Revision::getQueryInfo( [ 'user' ] )
- * Revision::pageJoinCond() → Revision::getQueryInfo( [ 'page' ] )
- * Revision::selectPageFields() → Revision::getQueryInfo( [ 'page' ] )
- * Revision::selectTextFields() → Revision::getQueryInfo( [ 'text' ] )
- * Revision::selectFields() → Revision::getQueryInfo()
- * Revision::selectArchiveFields() → Revision::getArchiveQueryInfo()
- * User::selectFields() → User::getQueryInfo()
- * WikiPage::selectFields() → WikiPage::getQueryInfo()
-* Revision::setUserIdAndName() was deprecated.
-* Access to TitleValue class properties was deprecated, the relevant getters
- should be used instead.
-* DifferenceEngine::getDiffBodyCacheKey() is deprecated. Subclasses should
- override DifferenceEngine::getDiffBodyCacheKeyParams() instead.
-* Use of Maintenance::error( $err, $die ) to exit script was deprecated. Use
- Maintenance::fatalError() instead.
-* Passing a ParserOptions object to OutputPage::parserOptions() is deprecated.
-* The RevisionInsertComplete hook is now deprecated; use instead the hook
- RevisionRecordInserted. RevisionInsertComplete is still called, but the second
- and third parameter will always be null. Hard deprecation is scheduled for 1.32.
-* The following methods that get and set ParserOutput state are deprecated.
- Callers should use the new stateless $options parameter to
- ParserOutput::getText() instead.
- * ParserOptions::getEditSection()
- * ParserOptions::setEditSection()
- * ParserOutput::getEditSectionTokens()
- * ParserOutput::setEditSectionTokens()
- * ParserOutput::getTOCEnabled()
- * ParserOutput::setTOCEnabled()
- * OutputPage::enableSectionEditLinks()
- * OutputPage::sectionEditLinksEnabled()
- * The public ParserOutput state fields $mTOCEnabled and $mEditSectionTokens
- are also deprecated.
-* License::getLicenses has been deprecated; use License::getLines instead.
-* QuickTemplate::setRef() was deprecated in favour of QuickTemplate::set().
- Setting template variables by reference allowed violating the principle of
- data being immutable once added to the skin template. In practice, this method
- was not being used for that. Rather, setRef() existed as memory optimisation
- for PHP 4.
-* QuickTemplate::setTranslator() and MediaWikiI18N::set() were deprecated in
- favour of Skin::msg() parameters.
-* MediaWikiI18N::translate() was deprecated in favour of Skin::msg() or
- wfMessage().
-* Passing false to ParserOptions::setWrapOutputClass() is deprecated. Use the
- 'unwrap' transform to ParserOutput::getText() instead.
-* \ObjectFactory (no namespace) is deprecated, the namespaced class
- \Wikimedia\ObjectFactory from the wikimedia/object-factory library should be
- used instead.
-* CommentStore::newKey is deprecated. Instead, get an instance from
- MediaWikiServices.
-* The following CommentStore methods have had their signatures changed to
- introduce a $key parameter, usage of the methods on instances retrieved from
- CommentStore::newKey will remain unchanged but deprecated:
- * CommentStore::getFields
- * CommentStore::getJoin
- * CommentStore::getComment
- * CommentStore::getCommentLegacy
- * CommentStore::insert
- * CommentStore::insertWithTemplate
-* The following methods in Title have been renamed, and the old ones are
- deprecated:
- * Title::getSkinFromCssJsSubpage – use ::getSkinFromConfigSubpage
- * Title::isCssOrJsPage – use ::isSiteConfigPage
- * Title::isCssJsSubpage – use ::isUserConfigPage
- * Title::isCssSubpage – use ::isUserCssConfigPage
- * Title::isJsSubpage – use ::isUserJsConfigPage
-* The following methods related to caching of half-parsed HTML were deprecated:
- * Parser::serializeHalfParsedText()
- * Parser::unserializeHalfParsedText()
- * Parser::isValidHalfParsedText()
- * StripState::getSubState()
- * StripState::merge()
-* The DeferredStringifier class is deprecated, use Message::listParam() instead.
-* The type string for the parameter $lang of DateFormatter::getInstance is
- deprecated.
-* Wikimedia\Rdbms\SavepointPostgres is deprecated.
-* The DO_MAINTENANCE constant is deprecated. RUN_MAINTENANCE_IF_MAIN should be
- used instead.
-* The function wfShellWikiCmd() has been deprecated, use
- MediaWiki\Shell::makeScriptCommand().
-* In the future, the hooks 'PreferencesFormPreSave' and 'PreferencesGetLegend'
- will be allowed to provide any HTMLForm object rather than PreferencesForm.
-
-=== Other changes in 1.31 ===
-* Browser support for Internet Explorer 10 was lowered from Grade A to Grade C.
-* Browser support for Opera 12 and older was dropped entirely. Opera 15+
- continues at Grade A.
-* Multi-content-revision capability was introduced into the storage layer. See
- <https://mediawiki.org/wiki/Requests_for_comment/Multi-Content_Revisions>.
-* The "free" CSS class is now only applied to unbracketed URLs in wikitext.
- Links written using square brackets will get the class "text" not "free".
-* RFC 157418: Whitespace is trimmed from wikitext headings, wikitext list items,
- wikitext table captions, wikitext table headings, wikitext table cells. HTML
- headings, HTML list items, HTML table captions, HTML table headings, HTML
- table cells will not have this trimming behavior.
-
-== Compatibility ==
-MediaWiki 1.31 requires PHP 7.0.0 or later. Although HHVM 3.18.5 or later is
-supported, it is generally advised to use PHP 7.0.0 or later for long term
-support.
-
-MySQL/MariaDB is the recommended DBMS. PostgreSQL or SQLite can also be used,
-but support for them is somewhat less mature. There is experimental support for
-Oracle and Microsoft SQL Server.
-
-The supported versions are:
-
-* MySQL 5.5.8 or later
-* PostgreSQL 9.2 or later
-* SQLite 3.3.7 or later
-* Oracle 9.0.1 or later
-* Microsoft SQL Server 2005 (9.00.1399)
-
-== Upgrading ==
-1.31 has several database changes since 1.30, and will not work without schema
-updates. Note that due to changes to some very large tables like the revision
-table, the schema update may take quite long (minutes on a medium sized site,
-many hours on a large site).
-
-Don't forget to always back up your database before upgrading!
-
-See the file UPGRADE for more detailed upgrade instructions, including
-important information when upgrading from versions prior to 1.11.
-
-For notes on 1.30.x and older releases, see HISTORY.
-
-== Online documentation ==
-Documentation for both end-users and site administrators is available on
-MediaWiki.org, and is covered under the GNU Free Documentation License (except
-for pages that explicitly state that their contents are in the public domain):
-
- https://www.mediawiki.org/wiki/Special:MyLanguage/Documentation
-
-== Mailing list ==
-A mailing list is available for MediaWiki user support and discussion:
-
- https://lists.wikimedia.org/mailman/listinfo/mediawiki-l
-
-A low-traffic announcements-only list is also available:
-
- https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce
-
-It's highly recommended that you sign up for one of these lists if you're
-going to run a public MediaWiki, so you can be notified of security fixes.
-
-== IRC help ==
-There's usually someone online in #mediawiki on irc.freenode.net.
reauthenticating.
* FormSpecialPage::execute() will now call checkLoginSecurityLevel() if
getLoginSecurityLevel() returns non-false.
+* The 'ImageBeforeProduceHTML' hook is now passed three new parameters, $parser,
+ &$query and &$widthOption, allowing extensions even finer control over the resulting
+ HTML code.
=== External library changes in 1.32 ===
* …
or refer to T195256 for details on how to make the same change.
* Hook 'EditPageBeforeEditChecks' was removed;
use 'EditPageGetCheckboxesDefinition' instead.
+* Linker::getLinkColour() and DummyLinker::getLinkColour(), deprecated since
+ 1.28, were removed. LinkRenderer::getLinkClasses() should be used instead.
=== Deprecations in 1.32 ===
* Use of a StartProfiler.php file is deprecated in favour of placing
* Class CryptRand, everything in MWCryptRand except generateHex() and function
MediaWikiServices::getCryptRand() are deprecated, use random_bytes() to
generate cryptographically secure random byte sequences.
+* Parser::getConverterLanguage() is deprecated. Use ::getTargetLanguage()
+ instead.
+* Language::markNoConversion() is deprecated. It confused readers because
+ it had unexpected behavior (only marking text if it looked like a URL)
+ and was only used in a single place in the code. Use
+ LanguageConverter::markNoConversion() instead.
+* (T176526) EditPage::getContextTitle() falling back to $wgTitle when the
+ context title is unset is now deprecated; anything creating an EditPage
+ instance should set the context title via ::setContextTitle().
=== Other changes in 1.32 ===
* …
'PageProps' => __DIR__ . '/includes/PageProps.php',
'PageQueryPage' => __DIR__ . '/includes/specialpage/PageQueryPage.php',
'Pager' => __DIR__ . '/includes/pager/Pager.php',
+ 'PaginatingSearchEngine' => __DIR__ . '/includes/search/PaginatingSearchEngine.php',
'ParameterizedPassword' => __DIR__ . '/includes/password/ParameterizedPassword.php',
'Parser' => __DIR__ . '/includes/parser/Parser.php',
'ParserCache' => __DIR__ . '/includes/parser/ParserCache.php',
"nikic/php-parser": "3.1.3",
"nmred/kafka-php": "0.1.5",
"phpunit/phpunit": "4.8.36 || ^6.5",
- "psy/psysh": "0.8.11",
+ "psy/psysh": "0.9.6",
"wikimedia/avro": "1.8.0",
"wikimedia/testing-access-wrapper": "~1.0",
"wmde/hamcrest-html-matchers": "^0.1.0"
Note that hook parameters are passed in an array; this is a necessary
inconvenience to make it possible to pass reference values (that can be changed)
into the hook code. Also note that earlier versions of wfRunHooks took a
-variable number of arguments; the [] calling protocol came about after
+variable number of arguments; the array calling protocol came about after
MediaWiki 1.4rc1.
==Events and parameters==
includes/Linker.php for Linker::makeImageLink
&$time: Timestamp of file in 'YYYYMMDDHHIISS' string form, or false for current
&$res: Final HTML output, used if you return false
+$parser: Parser instance
+&$query: Query params for desc URL
+&$widthOption: Used by the parser to remember the user preference thumbnailsize
'ImageOpenShowImageInlineBefore': Call potential extension just before showing
the image on an image page.
--- /dev/null
+This document provides an overview of the usage of PageUpdater and DerivedPageDataUpdater.
+
+== PageUpdater ==
+PageUpdater is the canonical way to create page revisions, that is, to perform edits.
+
+PageUpdater is a stateful, handle-like object that allows new revisions to be created
+on a given wiki page using the saveRevision() method. PageUpdater provides setters for
+defining the new revision's content as well as meta-data such as change tags. saveRevision()
+stores the new revision's primary content and metadata, and triggers the necessary
+updates to derived secondary data and cached artifacts e.g. in the ParserCache and the
+CDN layer, using a DerivedPageDataUpdater.
+
+PageUpdater instances follow the below life cycle, defined by a number of
+methods:
+
+ +----------------------------+
+ | |
+ | new |
+ | |
+ +------|--------------|------+
+ | |
+ grabParentRevision()-| |
+ or hasEditConflict()-| |
+ | |
+ +--------v-------+ |
+ | | |
+ | parent known | |
+ | | |
+ Enables---------------+--------|-------+ |
+ safe operations based on | |-saveRevision()
+ the parent revision, e.g. | |
+ section replacement or | |
+ edit conflict resolution. | |
+ | |
+ saveRevision()-| |
+ | |
+ +------v--------------v------+
+ | |
+ | creation committed |
+ | |
+ Enables-----------------+----------------------------+
+ wasSuccess()
+ isUnchanged()
+ isNew()
+ getState()
+ getNewRevision()
+ etc.
+
+The stateful nature of PageUpdater allows it to be used to safely perform
+transformations that depend on the new revision's parent revision, such as replacing
+sections or applying 3-way conflict resolution, while protecting against race
+conditions using a compare-and-swap (CAS) mechanism: after calling code used the
+grabParentRevision() method to access the edit's logical parent, PageUpdater
+remembers that revision, and ensure that that revision is still the page's current
+revision when performing the atomic database update for the revision's primary
+meta-data when saveRevision() is called. If another revision was created concurrently,
+saveRevision() will fail, indicating the problem with the "edit-conflict" code in the status
+object.
+
+Typical usage for programmatic revision creation (with $page being a WikiPage as of 1.32, to be
+replaced by a repository service later):
+
+ $updater = $page->newPageUpdater( $user );
+ $updater->setContent( 'main', $content );
+ $updater->setRcPatrolStatus( RecentChange::PRC_PATROLLED );
+ $newRev = $updater->saveRevision( $comment );
+
+Usage with content depending on the parent revision
+
+ $updater = $page->newPageUpdater( $user );
+ $parent = $updater->grabParentRevision();
+ $content = $parent->getContent( 'main' )->replaceSection( $section, $sectionContent );
+ $updater->setContent( 'main', $content );
+ $newRev = $updater->saveRevision( $comment, EDIT_UPDATE );
+
+In both cases, all secondary updates will be triggered automatically.
+
+== DerivedPageDataUpdater ==
+DerivedPageDataUpdater is a stateful, handle-like object that caches derived data representing
+a revision, and can trigger updates of cached copies of that data, e.g. in the links tables,
+page_props, the ParserCache, and the CDN layer.
+
+DerivedPageDataUpdater is used by PageUpdater when creating new revisions, but can also
+be used independently when performing meta data updates during undeletion, import, or
+when puring a page. It's a stepping stone on the way to a more complete refactoring of WikiPage.
+
+NOTE: Avoid direct usage of DerivedPageDataUpdater. In the future, we want to define interfaces
+for the different use cases of DerivedPageDataUpdater, particularly providing access to post-PST
+content and ParserOutput to callbacks during revision creation, which currently use
+WikiPage::prepareContentForEdit, and allowing updates to be triggered on purge, import, and
+undeletion, which currently use WikiPage::doEditUpdates() and Content::getSecondaryDataUpdates().
+
+The primary reason for DerivedPageDataUpdater to be stateful is internal caching of state
+that avoids the re-generation of ParserOutput and re-application of pre-save-
+transformations (PST).
+
+DerivedPageDataUpdater instances follow the below life cycle, defined by a number of
+methods:
+
+ +---------------------------------------------------------------------+
+ | |
+ | new |
+ | |
+ +---------------|------------------|------------------|---------------+
+ | | |
+ grabCurrentRevision()-| | |
+ | | |
+ +-----------v----------+ | |
+ | | |-prepareContent() |
+ | knows current | | |
+ | | | |
+ Enables------------------+-----|-----|----------+ | |
+ pageExisted() | | | |
+ wasRedirect() | |-prepareContent() | |-prepareUpdate()
+ | | | |
+ | | +-------------v------------+ |
+ | | | | |
+ | +----> has content | |
+ | | | |
+ Enables------------------------|----------+--------------------------+ |
+ isChange() | | |
+ isCreation() |-prepareUpdate() | |
+ getSlots() | prepareUpdate()-| |
+ getTouchedSlotRoles() | | |
+ getCanonicalParserOutput() | +-----------v------------v-----------------+
+ | | |
+ +------------------> has revision |
+ | |
+ Enables-------------------------------------------+------------------------|-----------------+
+ updateParserCache() |
+ runSecondaryDataUpdates() |-doUpdates()
+ |
+ +-----------v---------+
+ | |
+ | updates done |
+ | |
+ +---------------------+
+
+
+- grabCurrentRevision() returns the logical parent revision of the target revision. It is
+guaranteed to always return the same revision for a given DerivedPageDataUpdater instance.
+If called before prepareUpdate(), this fixates the logical parent to be the page's current
+revision. If called for the first time after prepareUpdate(), it returns the revision
+passed as the 'oldrevision' option to prepareUpdate(), or, if that wasn't given, the
+parent of $revision parameter passed to prepareUpdate().
+
+- prepareContent() is called before the new revision is created, to apply pre-save-
+transformation (PST) and allow subsequent access to the canonical ParserOutput of the
+revision. getSlots() and getCanonicalParserOutput() as well as getSecondaryDataUpdates()
+may be used after prepareContent() was called. Calling prepareContent() with the same
+parameters again has no effect. Calling it again with mismatching paramters, or calling
+it after prepareUpdate() was called, triggers a LogicException.
+
+- prepareUpdate() is called after the new revision has been created. This may happen
+right after the revision was created, on the same instance on which prepareContent() was
+called, or later (possibly much later), on a fresh instance in a different process,
+due to deferred or asynchronous updates, or during import, undeletion, purging, etc.
+prepareUpdate() is required before a call to doUpdates(), and it also enables calls to
+getSlots() and getCanonicalParserOutput() as well as getSecondaryDataUpdates().
+Calling prepareUpdate() with the same parameters again has no effect.
+Calling it again with mismatching parameters, or calling it with parameters mismatching
+the ones prepareContent() was called with, triggers a LogicException.
+
+- getSecondaryDataUpdtes() returns DataUpdates that represent derived data for the revision.
+These may be used to update such data, e.g. in ApiPurge, RefreshLinksJob, and the refreshLinks
+script.
+
+- doUpdates() triggers the updates defined by getSecondaryDataUpdtes(), and also causes
+updates to cached artifacts in the ParserCache, the CDN layer, etc. This is primarily
+used by PageUpdater, but also by PageArchive during undeletion, and when importing
+revisions from XML. doUpdates() can only be called after prepareUpdate() was used to
+initialize the DerivedPageDataUpdater instance for a specific revision. Calling it before
+prepareUpdate() is called raises a LogicException.
+
+A DerivedPageDataUpdater instance is intended to be re-used during different stages
+of complex update operations that often involve callbacks to extension code via
+MediaWiki's hook mechanism, or deferred or even asynchronous execution of Jobs and
+DeferredUpdates. Since these mechanisms typically do not provide a way to pass a
+DerivedPageDataUpdater directly, WikiPage::getDerivedPageDataUpdater() has to be used to
+obtain a DerivedPageDataUpdater for the update currently in progress - re-using the
+same DerivedPageDataUpdater if possible avoids re-generation of ParserOutput objects
+and other expensively derived artifacts.
+
+This mechanism for re-using a DerivedPageDataUpdater instance without passing it directly
+requires a way to ensure that a given DerivedPageDataUpdater instance can actually be used
+in the calling code's context. For this purpose, WikiPage::getDerivedPageDataUpdater()
+calls the isReusableFor() method on DerivedPageDataUpdater, which ensures that the given
+instance is applicable to the given parameters. In other words, isReusableFor() predicts
+whether calling prepareContent() or prepareUpdate() with a given set of parameters will
+trigger a LogicException. In that case, WikiPage::getDerivedPageDataUpdater() creates a
+fresh DerivedPageDataUpdater instance.
* $wgPasswordConfig['bcrypt-peppered'] = [
* 'class' => EncryptedPassword::class,
* 'underlying' => 'bcrypt',
- * 'secrets' => [],
- * 'cipher' => MCRYPT_RIJNDAEL_256,
- * 'mode' => MCRYPT_MODE_CBC,
- * 'cost' => 5,
+ * 'secrets' => [
+ * hash( 'sha256', 'secret', true ),
+ * ],
+ * 'cipher' => 'aes-256-cbc',
* ];
* @endcode
*
*/
$wgCommentTableSchemaMigrationStage = MIGRATION_OLD;
+/**
+ * RevisionStore table schema migration stage (content, slots, content_models & slot_roles tables)
+ *
+ * @see Task: https://phabricator.wikimedia.org/T174028
+ * @see Commit: https://gerrit.wikimedia.org/r/#/c/378724/
+ *
+ * @since 1.32
+ * @var int One of the MIGRATION_* constants
+ */
+$wgMultiContentRevisionSchemaMigrationStage = MIGRATION_OLD;
+
/**
* Actor table schema migration stage.
* @since 1.31
*/
class DummyLinker {
- /**
- * @deprecated since 1.28, use LinkRenderer::getLinkClasses() instead
- */
- public function getLinkColour( $t, $threshold ) {
- wfDeprecated( __METHOD__, '1.28' );
- return Linker::getLinkColour( $t, $threshold );
- }
-
public function link(
$target,
$html = null,
/**
* Get the context title object.
- * If not set, $wgTitle will be returned. This behavior might change in
- * the future to return $this->mTitle instead.
+ *
+ * If not set, $wgTitle will be returned, but this is deprecated. This will
+ * throw an exception.
*
* @return Title
*/
public function getContextTitle() {
if ( is_null( $this->mContextTitle ) ) {
- wfDebugLog(
- 'GlobalTitleFail',
- __METHOD__ . ' called by ' . wfGetAllCallers( 5 ) . ' with no title set.'
- );
+ wfDeprecated( __METHOD__ . ' called with no title set', '1.32' );
global $wgTitle;
return $wgTitle;
} else {
* @return Status The resulting status object.
*/
public function attemptSave( &$resultDetails = false ) {
- # Allow bots to exempt some edits from bot flagging
+ // TODO: MCR: treat $this->minoredit like $this->bot and check isAllowed( 'minoredit' )!
+ // Also, add $this->autopatrol like $this->bot and check isAllowed( 'autopatrol' )!
+ // This is needed since PageUpdater no longer checks these rights!
+
+ // Allow bots to exempt some edits from bot flagging
$bot = $this->context->getUser()->isAllowed( 'bot' ) && $this->bot;
$status = $this->internalAttemptSave( $resultDetails, $bot );
const TOOL_LINKS_NOBLOCK = 1;
const TOOL_LINKS_EMAIL = 2;
- /**
- * Return the CSS colour of a known link
- *
- * @deprecated since 1.28, use LinkRenderer::getLinkClasses() instead
- *
- * @since 1.16.3
- * @param LinkTarget $t
- * @param int $threshold User defined threshold
- * @return string CSS class
- */
- public static function getLinkColour( LinkTarget $t, $threshold ) {
- wfDeprecated( __METHOD__, '1.28' );
- $services = MediaWikiServices::getInstance();
- $linkRenderer = $services->getLinkRenderer();
- if ( $threshold !== $linkRenderer->getStubThreshold() ) {
- // Need to create a new instance with the right stub threshold...
- $linkRenderer = $services->getLinkRendererFactory()->create();
- $linkRenderer->setStubThreshold( $threshold );
- }
-
- return $linkRenderer->getLinkClasses( $t );
- }
-
/**
* This function returns an HTML link to the given target. It serves a few
* purposes:
$res = null;
$dummy = new DummyLinker;
if ( !Hooks::run( 'ImageBeforeProduceHTML', [ &$dummy, &$title,
- &$file, &$frameParams, &$handlerParams, &$time, &$res ] ) ) {
+ &$file, &$frameParams, &$handlerParams, &$time, &$res,
+ $parser, &$query, &$widthOption
+ ] ) ) {
return $res;
}
MWExceptionHandler::rollbackMasterChangesAndLog( $e );
}
+ // Disable WebResponse setters for post-send processing (T191537).
+ WebResponse::disableForPostSend();
+
$blocksHttpClient = true;
// Defer everything else if possible...
$callback = function () use ( $mode, &$blocksHttpClient ) {
);
# Add the results to the link cache
- $lb->addResultToCache( LinkCache::singleton(), $res );
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
+ $lb->addResultToCache( $linkCache, $res );
return $res;
}
// Avoid PHP 7.1 warning of passing $this by reference
$revision = $this;
- // TODO: hard-deprecate in 1.32 (or even 1.31?)
- Hooks::run( 'RevisionInsertComplete', [ &$revision, null, null ] );
return $rec->getId();
}
$blobStore,
$services->getMainWANObjectCache(),
$services->getCommentStore(),
+ $services->getContentModelStore(),
+ $services->getSlotRoleStore(),
+ $services->getMainConfig()->get( 'MultiContentRevisionSchemaMigrationStage' ),
$services->getActorMigration()
);
--- /dev/null
+<?php
+/**
+ * A handle for managing updates for derived page data on edit, import, purge, etc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Storage;
+
+use ApiStashEdit;
+use CategoryMembershipChangeJob;
+use Content;
+use ContentHandler;
+use DataUpdate;
+use DeferredUpdates;
+use Hooks;
+use IDBAccessObject;
+use InvalidArgumentException;
+use JobQueueGroup;
+use Language;
+use LinksUpdate;
+use LogicException;
+use MediaWiki\Edit\PreparedEdit;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\User\UserIdentity;
+use MessageCache;
+use ParserCache;
+use ParserOptions;
+use ParserOutput;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use RecentChangesUpdateJob;
+use ResourceLoaderWikiModule;
+use Revision;
+use SearchUpdate;
+use SiteStatsUpdate;
+use Title;
+use User;
+use Wikimedia\Assert\Assert;
+use WikiPage;
+
+/**
+ * A handle for managing updates for derived page data on edit, import, purge, etc.
+ *
+ * @note Avoid direct usage of DerivedPageDataUpdater.
+ *
+ * @todo Define interfaces for the different use cases of DerivedPageDataUpdater, particularly
+ * providing access to post-PST content and ParserOutput to callbacks during revision creation,
+ * which currently use WikiPage::prepareContentForEdit, and allowing updates to be triggered on
+ * purge, import, and undeletion, which currently use WikiPage::doEditUpdates() and
+ * Content::getSecondaryDataUpdates().
+ *
+ * DerivedPageDataUpdater instances are designed to be cached inside a WikiPage instance,
+ * and re-used by callback code over the course of an update operation. It's a stepping stone
+ * one the way to a more complete refactoring of WikiPage.
+ *
+ * When using a DerivedPageDataUpdater, the following life cycle must be observed:
+ * grabCurrentRevision (optional), prepareContent (optional), prepareUpdate (required
+ * for doUpdates). getCanonicalParserOutput, getSlots, and getSecondaryDataUpdates
+ * require prepareContent or prepareUpdate to have been called first, to initialize the
+ * DerivedPageDataUpdater.
+ *
+ * @see docs/pageupdater.txt for more information.
+ *
+ * MCR migration note: this replaces the relevant methods in WikiPage, and covers the use cases
+ * of PreparedEdit.
+ *
+ * @internal
+ *
+ * @since 1.32
+ * @ingroup Page
+ */
+class DerivedPageDataUpdater implements IDBAccessObject {
+
+ /**
+ * @var UserIdentity|null
+ */
+ private $user = null;
+
+ /**
+ * @var WikiPage
+ */
+ private $wikiPage;
+
+ /**
+ * @var ParserCache
+ */
+ private $parserCache;
+
+ /**
+ * @var RevisionStore
+ */
+ private $revisionStore;
+
+ /**
+ * @var Language
+ */
+ private $contentLanguage;
+
+ /**
+ * @var LoggerInterface
+ */
+ private $saveParseLogger;
+
+ /**
+ * @var JobQueueGroup
+ */
+ private $jobQueueGroup;
+
+ /**
+ * @var MessageCache
+ */
+ private $messageCache;
+
+ /**
+ * @var string see $wgArticleCountMethod
+ */
+ private $articleCountMethod;
+
+ /**
+ * @var boolean see $wgRCWatchCategoryMembership
+ */
+ private $rcWatchCategoryMembership = false;
+
+ /**
+ * See $options on prepareUpdate.
+ */
+ private $options = [
+ 'changed' => true,
+ 'created' => false,
+ 'moved' => false,
+ 'restored' => false,
+ 'oldcountable' => null,
+ 'oldredirect' => null,
+ ];
+
+ /**
+ * The state of the relevant row in page table before the edit.
+ * This is determined by the first call to grabCurrentRevision, prepareContent,
+ * or prepareUpdate.
+ * If pageState was not initialized when prepareUpdate() is called, prepareUpdate() will
+ * attempt to emulate the state of the page table before the edit.
+ *
+ * @var array
+ */
+ private $pageState = null;
+
+ /**
+ * @var RevisionSlotsUpdate|null
+ */
+ private $slotsUpdate = null;
+
+ /**
+ * @var MutableRevisionSlots|null
+ */
+ private $pstContentSlots = null;
+
+ /**
+ * @var object[] anonymous objects with two fields, using slot roles as keys:
+ * - hasHtml: whether the output contains HTML
+ * - ParserOutput: the slot's parser output
+ */
+ private $slotsOutput = [];
+
+ /**
+ * @var ParserOutput|null
+ */
+ private $canonicalParserOutput = null;
+
+ /**
+ * @var ParserOptions|null
+ */
+ private $canonicalParserOptions = null;
+
+ /**
+ * @var RevisionRecord
+ */
+ private $revision = null;
+
+ /**
+ * A stage identifier for managing the life cycle of this instance.
+ * Possible stages are 'new', 'knows-current', 'has-content', 'has-revision', and 'done'.
+ *
+ * @see docs/pageupdater.txt for documentation of the life cycle.
+ *
+ * @var string
+ */
+ private $stage = 'new';
+
+ /**
+ * Transition table for managing the life cycle of DerivedPageDateUpdater instances.
+ *
+ * XXX: Overkill. This is a linear order, we could just count. Names are nice though,
+ * and constants are also overkill...
+ *
+ * @see docs/pageupdater.txt for documentation of the life cycle.
+ *
+ * @var array[]
+ */
+ private static $transitions = [
+ 'new' => [
+ 'new' => true,
+ 'knows-current' => true,
+ 'has-content' => true,
+ 'has-revision' => true,
+ ],
+ 'knows-current' => [
+ 'knows-current' => true,
+ 'has-content' => true,
+ 'has-revision' => true,
+ ],
+ 'has-content' => [
+ 'has-content' => true,
+ 'has-revision' => true,
+ ],
+ 'has-revision' => [
+ 'has-revision' => true,
+ 'done' => true,
+ ],
+ ];
+
+ /**
+ * @param WikiPage $wikiPage ,
+ * @param RevisionStore $revisionStore
+ * @param ParserCache $parserCache
+ * @param JobQueueGroup $jobQueueGroup
+ * @param MessageCache $messageCache
+ * @param Language $contentLanguage
+ * @param LoggerInterface $saveParseLogger
+ */
+ public function __construct(
+ WikiPage $wikiPage,
+ RevisionStore $revisionStore,
+ ParserCache $parserCache,
+ JobQueueGroup $jobQueueGroup,
+ MessageCache $messageCache,
+ Language $contentLanguage,
+ LoggerInterface $saveParseLogger = null
+ ) {
+ $this->wikiPage = $wikiPage;
+
+ $this->parserCache = $parserCache;
+ $this->revisionStore = $revisionStore;
+ $this->jobQueueGroup = $jobQueueGroup;
+ $this->messageCache = $messageCache;
+ $this->contentLanguage = $contentLanguage;
+
+ // XXX: replace all wfDebug calls with a Logger. Do we nede more than one logger here?
+ $this->saveParseLogger = $saveParseLogger ?: new NullLogger();
+ }
+
+ /**
+ * Transition function for managing the life cycle of this instances.
+ *
+ * @see docs/pageupdater.txt for documentation of the life cycle.
+ *
+ * @param string $newStage the new stage
+ * @return string the previous stage
+ *
+ * @throws LogicException If a transition to the given stage is not possible in the current
+ * stage.
+ */
+ private function doTransition( $newStage ) {
+ $this->assertTransition( $newStage );
+
+ $oldStage = $this->stage;
+ $this->stage = $newStage;
+
+ return $oldStage;
+ }
+
+ /**
+ * Asserts that a transition to the given stage is possible, without performing it.
+ *
+ * @see docs/pageupdater.txt for documentation of the life cycle.
+ *
+ * @param string $newStage the new stage
+ *
+ * @throws LogicException If this instance is not in the expected stage
+ */
+ private function assertTransition( $newStage ) {
+ if ( empty( self::$transitions[$this->stage][$newStage] ) ) {
+ throw new LogicException( "Cannot transition from {$this->stage} to $newStage" );
+ }
+ }
+
+ /**
+ * @return bool|string
+ */
+ private function getWikiId() {
+ // TODO: get from RevisionStore
+ return false;
+ }
+
+ /**
+ * Checks whether this DerivedPageDataUpdater can be re-used for running updates targeting
+ * the the given revision.
+ *
+ * @param UserIdentity|null $user The user creating the revision in question
+ * @param RevisionRecord|null $revision New revision (after save, if already saved)
+ * @param RevisionSlotsUpdate|null $slotsUpdate New content (before PST)
+ * @param null|int $parentId Parent revision of the edit (use 0 for page creation)
+ *
+ * @return bool
+ */
+ public function isReusableFor(
+ UserIdentity $user = null,
+ RevisionRecord $revision = null,
+ RevisionSlotsUpdate $slotsUpdate = null,
+ $parentId = null
+ ) {
+ if ( $revision
+ && $parentId
+ && $revision->getParentId() !== $parentId
+ ) {
+ throw new InvalidArgumentException( '$parentId should match the parent of $revision' );
+ }
+
+ if ( $revision
+ && $user
+ && $revision->getUser( RevisionRecord::RAW )->getName() !== $user->getName()
+ ) {
+ throw new InvalidArgumentException( '$user should match the author of $revision' );
+ }
+
+ if ( $user && $this->user && $user->getName() !== $this->user->getName() ) {
+ return false;
+ }
+
+ if ( $revision && $this->revision && $this->revision->getId() !== $revision->getId() ) {
+ return false;
+ }
+
+ if ( $revision && !$user ) {
+ $user = $revision->getUser( RevisionRecord::RAW );
+ }
+
+ if ( $this->pageState
+ && $revision
+ && $revision->getParentId() !== null
+ && $this->pageState['oldId'] !== $revision->getParentId()
+ ) {
+ return false;
+ }
+
+ if ( $this->pageState
+ && $parentId !== null
+ && $this->pageState['oldId'] !== $parentId
+ ) {
+ return false;
+ }
+
+ if ( $this->revision
+ && $user
+ && $this->revision->getUser( RevisionRecord::RAW )->getName() !== $user->getName()
+ ) {
+ return false;
+ }
+
+ if ( $revision
+ && $this->user
+ && $revision->getUser( RevisionRecord::RAW )->getName() !== $this->user->getName()
+ ) {
+ return false;
+ }
+
+ // NOTE: this check is the primary reason for having the $this->slotsUpdate field!
+ if ( $this->slotsUpdate
+ && $slotsUpdate
+ && !$this->slotsUpdate->hasSameUpdates( $slotsUpdate )
+ ) {
+ return false;
+ }
+
+ if ( $this->pstContentSlots
+ && $revision
+ && !$this->pstContentSlots->hasSameContent( $revision->getSlots() )
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * @param string $articleCountMethod "any" or "link".
+ * @see $wgArticleCountMethod
+ */
+ public function setArticleCountMethod( $articleCountMethod ) {
+ $this->articleCountMethod = $articleCountMethod;
+ }
+
+ /**
+ * @param bool $rcWatchCategoryMembership
+ * @see $wgRCWatchCategoryMembership
+ */
+ public function setRcWatchCategoryMembership( $rcWatchCategoryMembership ) {
+ $this->rcWatchCategoryMembership = $rcWatchCategoryMembership;
+ }
+
+ /**
+ * @return Title
+ */
+ private function getTitle() {
+ // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
+ return $this->wikiPage->getTitle();
+ }
+
+ /**
+ * @return WikiPage
+ */
+ private function getWikiPage() {
+ // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
+ return $this->wikiPage;
+ }
+
+ /**
+ * Determines whether the page being edited already existed.
+ * Only defined after calling grabCurrentRevision() or prepareContent() or prepareUpdate()!
+ *
+ * @return bool
+ * @throws LogicException if called before grabCurrentRevision
+ */
+ public function pageExisted() {
+ $this->assertHasPageState( __METHOD__ );
+
+ return $this->pageState['oldId'] > 0;
+ }
+
+ /**
+ * Returns the revision that was current before the edit. This would be null if the edit
+ * created the page, or the revision's parent for a regular edit, or the revision itself
+ * for a null-edit.
+ * Only defined after calling grabCurrentRevision() or prepareContent() or prepareUpdate()!
+ *
+ * @return RevisionRecord|null the revision that was current before the edit, or null if
+ * the edit created the page.
+ */
+ private function getOldRevision() {
+ $this->assertHasPageState( __METHOD__ );
+
+ // If 'oldRevision' is not set, load it!
+ // Useful if $this->oldPageState is initialized by prepareUpdate.
+ if ( !array_key_exists( 'oldRevision', $this->pageState ) ) {
+ /** @var int $oldId */
+ $oldId = $this->pageState['oldId'];
+ $flags = $this->useMaster() ? RevisionStore::READ_LATEST : 0;
+ $this->pageState['oldRevision'] = $oldId
+ ? $this->revisionStore->getRevisionById( $oldId, $flags )
+ : null;
+ }
+
+ return $this->pageState['oldRevision'];
+ }
+
+ /**
+ * Returns the revision that was the page's current revision when grabCurrentRevision()
+ * was first called.
+ *
+ * During an edit, that revision will act as the logical parent of the new revision.
+ *
+ * Some updates are performed based on the difference between the database state at the
+ * moment this method is first called, and the state after the edit.
+ *
+ * @see docs/pageupdater.txt for more information on when thie method can and should be called.
+ *
+ * @note After prepareUpdate() was called, grabCurrentRevision() will throw an exception
+ * to avoid confusion, since the page's current revision is then the new revision after
+ * the edit, which was presumably passed to prepareUpdate() as the $revision parameter.
+ * Use getOldRevision() instead to access the revision that used to be current before the
+ * edit.
+ *
+ * @return RevisionRecord|null the page's current revision, or null if the page does not
+ * yet exist.
+ */
+ public function grabCurrentRevision() {
+ if ( $this->pageState ) {
+ return $this->pageState['oldRevision'];
+ }
+
+ $this->assertTransition( 'knows-current' );
+
+ // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
+ $wikiPage = $this->getWikiPage();
+
+ // Do not call WikiPage::clear(), since the caller may already have caused page data
+ // to be loaded with SELECT FOR UPDATE. Just assert it's loaded now.
+ $wikiPage->loadPageData( self::READ_LATEST );
+ $rev = $wikiPage->getRevision();
+ $current = $rev ? $rev->getRevisionRecord() : null;
+
+ $this->pageState = [
+ 'oldRevision' => $current,
+ 'oldId' => $rev ? $rev->getId() : 0,
+ 'oldIsRedirect' => $wikiPage->isRedirect(), // NOTE: uses page table
+ 'oldCountable' => $wikiPage->isCountable(), // NOTE: uses pagelinks table
+ ];
+
+ $this->doTransition( 'knows-current' );
+
+ return $this->pageState['oldRevision'];
+ }
+
+ /**
+ * Whether prepareUpdate() or prepareContent() have been called on this instance.
+ *
+ * @return bool
+ */
+ public function isContentPrepared() {
+ return $this->pstContentSlots !== null;
+ }
+
+ /**
+ * Whether prepareUpdate() has been called on this instance.
+ *
+ * @return bool
+ */
+ public function isUpdatePrepared() {
+ return $this->revision !== null;
+ }
+
+ /**
+ * @return int
+ */
+ private function getPageId() {
+ // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
+ return $this->wikiPage->getId();
+ }
+
+ /**
+ * @return string
+ */
+ private function getTimestampNow() {
+ // TODO: allow an override to be injected for testing
+ return wfTimestampNow();
+ }
+
+ /**
+ * Whether the content of the target revision is publicly visible.
+ *
+ * @return bool
+ */
+ public function isContentPublic() {
+ if ( $this->revision ) {
+ // XXX: if that revision is the current revision, this can be skipped
+ return !$this->revision->isDeleted( RevisionRecord::DELETED_TEXT );
+ } else {
+ // If the content has not been saved yet, it cannot have been suppressed yet.
+ return true;
+ }
+ }
+
+ /**
+ * Returns the slot, modified or inherited, after PST, with no audience checks applied.
+ *
+ * @param string $role slot role name
+ *
+ * @throws PageUpdateException If the slot is neither set for update nor inherited from the
+ * parent revision.
+ * @return SlotRecord
+ */
+ public function getRawSlot( $role ) {
+ return $this->getSlots()->getSlot( $role );
+ }
+
+ /**
+ * Returns the content of the given slot, with no audience checks.
+ *
+ * @throws PageUpdateException If the slot is neither set for update nor inherited from the
+ * parent revision.
+ * @param string $role slot role name
+ * @return Content
+ */
+ public function getRawContent( $role ) {
+ return $this->getRawSlot( $role )->getContent();
+ }
+
+ /**
+ * Returns the content model of the given slot
+ *
+ * @param string $role slot role name
+ * @return string
+ */
+ private function getContentModel( $role ) {
+ return $this->getRawSlot( $role )->getModel();
+ }
+
+ /**
+ * @param string $role slot role name
+ * @return ContentHandler
+ */
+ private function getContentHandler( $role ) {
+ // TODO: inject something like a ContentHandlerRegistry
+ return ContentHandler::getForModelID( $this->getContentModel( $role ) );
+ }
+
+ private function useMaster() {
+ // TODO: can we just set a flag to true in prepareContent()?
+ return $this->wikiPage->wasLoadedFrom( self::READ_LATEST );
+ }
+
+ /**
+ * @return bool
+ */
+ public function isCountable() {
+ // NOTE: Keep in sync with WikiPage::isCountable.
+
+ if ( !$this->getTitle()->isContentPage() ) {
+ return false;
+ }
+
+ if ( !$this->isContentPublic() ) {
+ // This should be irrelevant: countability only applies to the current revision,
+ // and the current revision is never suppressed.
+ return false;
+ }
+
+ if ( $this->isRedirect() ) {
+ return false;
+ }
+
+ $hasLinks = null;
+
+ if ( $this->articleCountMethod === 'link' ) {
+ $hasLinks = (bool)count( $this->getCanonicalParserOutput()->getLinks() );
+ }
+
+ // TODO: MCR: ask all slots if they have links [SlotHandler/PageTypeHandler]
+ $mainContent = $this->getRawContent( 'main' );
+ return $mainContent->isCountable( $hasLinks );
+ }
+
+ /**
+ * @return bool
+ */
+ public function isRedirect() {
+ // NOTE: main slot determines redirect status
+ $mainContent = $this->getRawContent( 'main' );
+
+ return $mainContent->isRedirect();
+ }
+
+ /**
+ * @param RevisionRecord $rev
+ *
+ * @return bool
+ */
+ private function revisionIsRedirect( RevisionRecord $rev ) {
+ // NOTE: main slot determines redirect status
+ $mainContent = $rev->getContent( 'main', RevisionRecord::RAW );
+
+ return $mainContent->isRedirect();
+ }
+
+ /**
+ * Prepare updates based on an update which has not yet been saved.
+ *
+ * This may be used to create derived data that is needed when creating a new revision;
+ * particularly, this makes available the slots of the new revision via the getSlots()
+ * method, after applying PST and slot inheritance.
+ *
+ * The derived data prepared for revision creation may then later be re-used by doUpdates(),
+ * without the need to re-calculate.
+ *
+ * @see docs/pageupdater.txt for more information on when thie method can and should be called.
+ *
+ * @note: Calling this method more than once with the same $slotsUpdate
+ * has no effect. Calling this method multiple times with different content will cause
+ * an exception.
+ *
+ * @note: Calling this method after prepareUpdate() has been called will cause an exception.
+ *
+ * @param User $user The user to act as context for pre-save transformation (PST).
+ * Type hint should be reduced to UserIdentity at some point.
+ * @param RevisionSlotsUpdate $slotsUpdate The new content of the slots to be updated
+ * by this edit, before PST.
+ * @param bool $useStash Whether to use stashed ParserOutput
+ */
+ public function prepareContent(
+ User $user,
+ RevisionSlotsUpdate $slotsUpdate,
+ $useStash = true
+ ) {
+ if ( $this->slotsUpdate ) {
+ if ( !$this->user ) {
+ throw new LogicException(
+ 'Unexpected state: $this->slotsUpdate was initialized, '
+ . 'but $this->user was not.'
+ );
+ }
+
+ if ( $this->user->getName() !== $user->getName() ) {
+ throw new LogicException( 'Can\'t call prepareContent() again for different user! '
+ . 'Expected ' . $this->user->getName() . ', got ' . $user->getName()
+ );
+ }
+
+ if ( !$this->slotsUpdate->hasSameUpdates( $slotsUpdate ) ) {
+ throw new LogicException(
+ 'Can\'t call prepareContent() again with different slot content!'
+ );
+ }
+
+ return; // prepareContent() already done, nothing to do
+ }
+
+ $this->assertTransition( 'has-content' );
+
+ $wikiPage = $this->getWikiPage(); // TODO: use only for legacy hooks!
+ $title = $this->getTitle();
+
+ $parentRevision = $this->grabCurrentRevision();
+
+ $this->slotsOutput = [];
+ $this->canonicalParserOutput = null;
+ $this->canonicalParserOptions = null;
+
+ // The edit may have already been prepared via api.php?action=stashedit
+ $stashedEdit = false;
+
+ // TODO: MCR: allow output for all slots to be stashed.
+ if ( $useStash && $slotsUpdate->isModifiedSlot( 'main' ) ) {
+ $mainContent = $slotsUpdate->getModifiedSlot( 'main' )->getContent();
+ $legacyUser = User::newFromIdentity( $user );
+ $stashedEdit = ApiStashEdit::checkCache( $title, $mainContent, $legacyUser );
+ }
+
+ if ( $stashedEdit ) {
+ /** @var ParserOutput $output */
+ $output = $stashedEdit->output;
+
+ // TODO: this should happen when stashing the ParserOutput, not now!
+ $output->setCacheTime( $stashedEdit->timestamp );
+
+ // TODO: MCR: allow output for all slots to be stashed.
+ $this->canonicalParserOutput = $output;
+ }
+
+ $userPopts = ParserOptions::newFromUserAndLang( $user, $this->contentLanguage );
+ Hooks::run( 'ArticlePrepareTextForEdit', [ $wikiPage, $userPopts ] );
+
+ $this->user = $user;
+ $this->slotsUpdate = $slotsUpdate;
+
+ if ( $parentRevision ) {
+ // start out by inheriting all parent slots
+ $this->pstContentSlots = MutableRevisionSlots::newFromParentRevisionSlots(
+ $parentRevision->getSlots()->getSlots()
+ );
+ } else {
+ $this->pstContentSlots = new MutableRevisionSlots();
+ }
+
+ foreach ( $slotsUpdate->getModifiedRoles() as $role ) {
+ $slot = $slotsUpdate->getModifiedSlot( $role );
+
+ if ( $slot->isInherited() ) {
+ // No PST for inherited slots! Note that "modified" slots may still be inherited
+ // from an earlier version, e.g. for rollbacks.
+ $pstSlot = $slot;
+ } elseif ( $role === 'main' && $stashedEdit ) {
+ // TODO: MCR: allow PST content for all slots to be stashed.
+ $pstSlot = SlotRecord::newUnsaved( $role, $stashedEdit->pstContent );
+ } else {
+ $content = $slot->getContent();
+ $pstContent = $content->preSaveTransform( $title, $this->user, $userPopts );
+ $pstSlot = SlotRecord::newUnsaved( $role, $pstContent );
+ }
+
+ $this->pstContentSlots->setSlot( $pstSlot );
+ }
+
+ foreach ( $slotsUpdate->getRemovedRoles() as $role ) {
+ $this->pstContentSlots->removeSlot( $role );
+ }
+
+ $this->options['created'] = ( $parentRevision === null );
+ $this->options['changed'] = ( $parentRevision === null
+ || !$this->pstContentSlots->hasSameContent( $parentRevision->getSlots() ) );
+
+ $this->doTransition( 'has-content' );
+ }
+
+ private function assertHasPageState( $method ) {
+ if ( !$this->pageState ) {
+ throw new LogicException(
+ 'Must call grabCurrentRevision() or prepareContent() '
+ . 'or prepareUpdate() before calling ' . $method
+ );
+ }
+ }
+
+ private function assertPrepared( $method ) {
+ if ( !$this->pstContentSlots ) {
+ throw new LogicException(
+ 'Must call prepareContent() or prepareUpdate() before calling ' . $method
+ );
+ }
+ }
+
+ /**
+ * Whether the edit creates the page.
+ *
+ * @return bool
+ */
+ public function isCreation() {
+ $this->assertPrepared( __METHOD__ );
+ return $this->options['created'];
+ }
+
+ /**
+ * Whether the edit created, or should create, a new revision (that is, it's not a null-edit).
+ *
+ * @warning: at present, "null-revisions" that do not change content but do have a revision
+ * record would return false after prepareContent(), but true after prepareUpdate()!
+ * This should probably be fixed.
+ *
+ * @return bool
+ */
+ public function isChange() {
+ $this->assertPrepared( __METHOD__ );
+ return $this->options['changed'];
+ }
+
+ /**
+ * Whether the page was a redirect before the edit.
+ *
+ * @return bool
+ */
+ public function wasRedirect() {
+ $this->assertHasPageState( __METHOD__ );
+
+ if ( $this->pageState['oldIsRedirect'] === null ) {
+ /** @var RevisionRecord $rev */
+ $rev = $this->pageState['oldRevision'];
+ if ( $rev ) {
+ $this->pageState['oldIsRedirect'] = $this->revisionIsRedirect( $rev );
+ } else {
+ $this->pageState['oldIsRedirect'] = false;
+ }
+ }
+
+ return $this->pageState['oldIsRedirect'];
+ }
+
+ /**
+ * Returns the slots of the target revision, after PST.
+ *
+ * @return RevisionSlots
+ */
+ public function getSlots() {
+ $this->assertPrepared( __METHOD__ );
+ return $this->pstContentSlots;
+ }
+
+ /**
+ * Returns the RevisionSlotsUpdate for this updater.
+ *
+ * @return RevisionSlotsUpdate
+ */
+ private function getRevisionSlotsUpdate() {
+ $this->assertPrepared( __METHOD__ );
+
+ if ( !$this->slotsUpdate ) {
+ if ( !$this->revision ) {
+ // This should not be possible: if assertPrepared() returns true,
+ // at least one of $this->slotsUpdate or $this->revision should be set.
+ throw new LogicException( 'No revision nor a slots update is known!' );
+ }
+
+ $old = $this->getOldRevision();
+ $this->slotsUpdate = RevisionSlotsUpdate::newFromRevisionSlots(
+ $this->revision->getSlots(),
+ $old ? $old->getSlots() : null
+ );
+ }
+ return $this->slotsUpdate;
+ }
+
+ /**
+ * Returns the role names of the slots touched by the new revision,
+ * including removed roles.
+ *
+ * @return string[]
+ */
+ public function getTouchedSlotRoles() {
+ return $this->getRevisionSlotsUpdate()->getTouchedRoles();
+ }
+
+ /**
+ * Returns the role names of the slots modified by the new revision,
+ * not including removed roles.
+ *
+ * @return string[]
+ */
+ public function getModifiedSlotRoles() {
+ return $this->getRevisionSlotsUpdate()->getModifiedRoles();
+ }
+
+ /**
+ * Returns the role names of the slots removed by the new revision.
+ *
+ * @return string[]
+ */
+ public function getRemovedSlotRoles() {
+ return $this->getRevisionSlotsUpdate()->getRemovedRoles();
+ }
+
+ /**
+ * Prepare derived data updates targeting the given Revision.
+ *
+ * Calling this method requires the given revision to be present in the database.
+ * This may be right after a new revision has been created, or when re-generating
+ * derived data e.g. in ApiPurge, RefreshLinksJob, and the refreshLinks
+ * script.
+ *
+ * @see docs/pageupdater.txt for more information on when thie method can and should be called.
+ *
+ * @note: Calling this method more than once with the same revision has no effect.
+ * $options are only used for the first call. Calling this method multiple times with
+ * different revisions will cause an exception.
+ *
+ * @note: If grabCurrentRevision() (or prepareContent()) has been called before
+ * calling this method, $revision->getParentRevision() has to refer to the revision that
+ * was the current revision at the time grabCurrentRevision() was called.
+ *
+ * @param RevisionRecord $revision
+ * @param array $options Array of options, following indexes are used:
+ * - changed: bool, whether the revision changed the content (default true)
+ * - created: bool, whether the revision created the page (default false)
+ * - moved: bool, whether the page was moved (default false)
+ * - restored: bool, whether the page was undeleted (default false)
+ * - oldrevision: Revision object for the pre-update revision (default null)
+ * - parseroutput: The canonical ParserOutput of $revision (default null)
+ * - triggeringuser: The user triggering the update (UserIdentity, default null)
+ * - oldredirect: bool, null, or string 'no-change' (default null):
+ * - bool: whether the page was counted as a redirect before that
+ * revision, only used in changed is true and created is false
+ * - null or 'no-change': don't update the redirect status.
+ * - oldcountable: bool, null, or string 'no-change' (default null):
+ * - bool: whether the page was counted as an article before that
+ * revision, only used in changed is true and created is false
+ * - null: if created is false, don't update the article count; if created
+ * is true, do update the article count
+ * - 'no-change': don't update the article count, ever
+ *
+ */
+ public function prepareUpdate( RevisionRecord $revision, array $options = [] ) {
+ Assert::parameter(
+ !isset( $options['oldrevision'] )
+ || $options['oldrevision'] instanceof Revision
+ || $options['oldrevision'] instanceof RevisionRecord,
+ '$options["oldrevision"]',
+ 'must be a RevisionRecord (or Revision)'
+ );
+ Assert::parameter(
+ !isset( $options['parseroutput'] )
+ || $options['parseroutput'] instanceof ParserOutput,
+ '$options["parseroutput"]',
+ 'must be a ParserOutput'
+ );
+ Assert::parameter(
+ !isset( $options['triggeringuser'] )
+ || $options['triggeringuser'] instanceof UserIdentity,
+ '$options["triggeringuser"]',
+ 'must be a UserIdentity'
+ );
+
+ if ( !$revision->getId() ) {
+ throw new InvalidArgumentException(
+ 'Revision must have an ID set for it to be used with prepareUpdate()!'
+ );
+ }
+
+ if ( $this->revision ) {
+ if ( $this->revision->getId() === $revision->getId() ) {
+ return; // nothing to do!
+ } else {
+ throw new LogicException(
+ 'Trying to re-use DerivedPageDataUpdater with revision '
+ .$revision->getId()
+ . ', but it\'s already bound to revision '
+ . $this->revision->getId()
+ );
+ }
+ }
+
+ if ( $this->pstContentSlots
+ && !$this->pstContentSlots->hasSameContent( $revision->getSlots() )
+ ) {
+ throw new LogicException(
+ 'The Revision provided has mismatching content!'
+ );
+ }
+
+ // Override fields defined in $this->options with values from $options.
+ $this->options = array_intersect_key( $options, $this->options ) + $this->options;
+
+ if ( isset( $this->pageState['oldId'] ) ) {
+ $oldId = $this->pageState['oldId'];
+ } elseif ( isset( $this->options['oldrevision'] ) ) {
+ /** @var Revision|RevisionRecord $oldRev */
+ $oldRev = $this->options['oldrevision'];
+ $oldId = $oldRev->getId();
+ } else {
+ $oldId = $revision->getParentId();
+ }
+
+ if ( $oldId !== null ) {
+ // XXX: what if $options['changed'] disagrees?
+ // MovePage creates a dummy revision with changed = false!
+ // We may want to explicitly distinguish between "no new revision" (null-edit)
+ // and "new revision without new content" (dummy revision).
+
+ if ( $oldId === $revision->getParentId() ) {
+ // NOTE: this may still be a NullRevision!
+ // New revision!
+ $this->options['changed'] = true;
+ } elseif ( $oldId === $revision->getId() ) {
+ // Null-edit!
+ $this->options['changed'] = false;
+ } else {
+ // This indicates that calling code has given us the wrong Revision object
+ throw new LogicException(
+ 'The Revision mismatches old revision ID: '
+ . 'Old ID is ' . $oldId
+ . ', parent ID is ' . $revision->getParentId()
+ . ', revision ID is ' . $revision->getId()
+ );
+ }
+ }
+
+ // If prepareContent() was used to generate the PST content (which is indicated by
+ // $this->slotsUpdate being set), and this is not a null-edit, then the given
+ // revision must have the acting user as the revision author. Otherwise, user
+ // signatures generated by PST would mismatch the user in the revision record.
+ if ( $this->user !== null && $this->options['changed'] && $this->slotsUpdate ) {
+ $user = $revision->getUser();
+ if ( !$this->user->equals( $user ) ) {
+ throw new LogicException(
+ 'The Revision provided has a mismatching actor: expected '
+ .$this->user->getName()
+ . ', got '
+ . $user->getName()
+ );
+ }
+ }
+
+ // If $this->pageState was not yet initialized by grabCurrentRevision or prepareContent,
+ // emulate the state of the page table before the edit, as good as we can.
+ if ( !$this->pageState ) {
+ $this->pageState = [
+ 'oldIsRedirect' => isset( $this->options['oldredirect'] )
+ && is_bool( $this->options['oldredirect'] )
+ ? $this->options['oldredirect']
+ : null,
+ 'oldCountable' => isset( $this->options['oldcountable'] )
+ && is_bool( $this->options['oldcountable'] )
+ ? $this->options['oldcountable']
+ : null,
+ ];
+
+ if ( $this->options['changed'] ) {
+ // The edit created a new revision
+ $this->pageState['oldId'] = $revision->getParentId();
+
+ if ( isset( $this->options['oldrevision'] ) ) {
+ $rev = $this->options['oldrevision'];
+ $this->pageState['oldRevision'] = $rev instanceof Revision
+ ? $rev->getRevisionRecord()
+ : $rev;
+ }
+ } else {
+ // This is a null-edit, so the old revision IS the new revision!
+ $this->pageState['oldId'] = $revision->getId();
+ $this->pageState['oldRevision'] = $revision;
+ }
+ }
+
+ // "created" is forced here
+ $this->options['created'] = ( $this->pageState['oldId'] === 0 );
+
+ $this->revision = $revision;
+ $this->pstContentSlots = $revision->getSlots();
+
+ $this->doTransition( 'has-revision' );
+
+ // NOTE: in case we have a User object, don't override with a UserIdentity.
+ // We already checked that $revision->getUser() mathces $this->user;
+ if ( !$this->user ) {
+ $this->user = $revision->getUser( RevisionRecord::RAW );
+ }
+
+ // Prune any output that depends on the revision ID.
+ if ( $this->canonicalParserOutput ) {
+ if ( $this->outputVariesOnRevisionMetaData( $this->canonicalParserOutput, __METHOD__ ) ) {
+ $this->canonicalParserOutput = null;
+ }
+ } else {
+ $this->saveParseLogger->debug( __METHOD__ . ": No prepared canonical output...\n" );
+ }
+
+ if ( $this->slotsOutput ) {
+ foreach ( $this->slotsOutput as $role => $prep ) {
+ if ( $this->outputVariesOnRevisionMetaData( $prep->output, __METHOD__ ) ) {
+ unset( $this->slotsOutput[$role] );
+ }
+ }
+ } else {
+ $this->saveParseLogger->debug( __METHOD__ . ": No prepared output...\n" );
+ }
+
+ // reset ParserOptions, so the actual revision ID is used in future ParserOutput generation
+ $this->canonicalParserOptions = null;
+
+ // Avoid re-generating the canonical ParserOutput if it's known.
+ // We just trust that the caller is passing the correct ParserOutput!
+ if ( isset( $options['parseroutput'] ) ) {
+ $this->canonicalParserOutput = $options['parseroutput'];
+ }
+
+ // TODO: optionally get ParserOutput from the ParserCache here.
+ // Move the logic used by RefreshLinksJob here!
+ }
+
+ /**
+ * @param ParserOutput $out
+ * @param string $method
+ * @return bool
+ */
+ private function outputVariesOnRevisionMetaData( ParserOutput $out, $method = __METHOD__ ) {
+ if ( $out->getFlag( 'vary-revision' ) ) {
+ // XXX: Just keep the output if the speculative revision ID was correct, like below?
+ $this->saveParseLogger->info(
+ "$method: Prepared output has vary-revision...\n"
+ );
+ return true;
+ } elseif ( $out->getFlag( 'vary-revision-id' )
+ && $out->getSpeculativeRevIdUsed() !== $this->revision->getId()
+ ) {
+ $this->saveParseLogger->info(
+ "$method: Prepared output has vary-revision-id with wrong ID...\n"
+ );
+ return true;
+ } elseif ( $out->getFlag( 'vary-user' )
+ && !$this->options['changed']
+ ) {
+ // When Alice makes a null-edit on top of Bob's edit,
+ // {{REVISIONUSER}} must resolve to "Bob", not "Alice", see T135261.
+ // TODO: to avoid this, we should check for null-edits in makeCanonicalparserOptions,
+ // and set setCurrentRevisionCallback to return the existing revision when appropriate.
+ // See also the comment there [dk 2018-05]
+ $this->saveParseLogger->info(
+ "$method: Prepared output has vary-user and is null-edit...\n"
+ );
+ return true;
+ } else {
+ wfDebug( "$method: Keeping prepared output...\n" );
+ return false;
+ }
+ }
+
+ /**
+ * @deprecated This only exists for B/C, use the getters on DerivedPageDataUpdater directly!
+ * @return PreparedEdit
+ */
+ public function getPreparedEdit() {
+ $this->assertPrepared( __METHOD__ );
+
+ $slotsUpdate = $this->getRevisionSlotsUpdate();
+ $preparedEdit = new PreparedEdit();
+
+ $preparedEdit->popts = $this->getCanonicalParserOptions();
+ $preparedEdit->output = $this->getCanonicalParserOutput();
+ $preparedEdit->pstContent = $this->pstContentSlots->getContent( 'main' );
+ $preparedEdit->newContent =
+ $slotsUpdate->isModifiedSlot( 'main' )
+ ? $slotsUpdate->getModifiedSlot( 'main' )->getContent()
+ : $this->pstContentSlots->getContent( 'main' ); // XXX: can we just remove this?
+ $preparedEdit->oldContent = null; // unused. // XXX: could get this from the parent revision
+ $preparedEdit->revid = $this->revision ? $this->revision->getId() : null;
+ $preparedEdit->timestamp = $preparedEdit->output->getCacheTime();
+ $preparedEdit->format = $preparedEdit->pstContent->getDefaultFormat();
+
+ return $preparedEdit;
+ }
+
+ /**
+ * @return bool
+ */
+ private function isContentAccessible() {
+ // XXX: when we move this to a RevisionHtmlProvider, the audience may be configurable!
+ return $this->isContentPublic();
+ }
+
+ /**
+ * @param string $role
+ * @param bool $generateHtml
+ * @return ParserOutput
+ */
+ public function getSlotParserOutput( $role, $generateHtml = true ) {
+ // TODO: factor this out into a RevisionHtmlProvider that can also be used for viewing.
+
+ $this->assertPrepared( __METHOD__ );
+
+ if ( isset( $this->slotsOutput[$role] ) ) {
+ $entry = $this->slotsOutput[$role];
+
+ if ( $entry->hasHtml || !$generateHtml ) {
+ return $entry->output;
+ }
+ }
+
+ if ( !$this->isContentAccessible() ) {
+ // empty output
+ $output = new ParserOutput();
+ } else {
+ $content = $this->getRawContent( $role );
+
+ $output = $content->getParserOutput(
+ $this->getTitle(),
+ $this->revision ? $this->revision->getId() : null,
+ $this->getCanonicalParserOptions(),
+ $generateHtml
+ );
+ }
+
+ $this->slotsOutput[$role] = (object)[
+ 'output' => $output,
+ 'hasHtml' => $generateHtml,
+ ];
+
+ $output->setCacheTime( $this->getTimestampNow() );
+
+ return $output;
+ }
+
+ /**
+ * @return ParserOutput
+ */
+ public function getCanonicalParserOutput() {
+ if ( $this->canonicalParserOutput ) {
+ return $this->canonicalParserOutput;
+ }
+
+ // TODO: MCR: logic for combining the output of multiple slot goes here!
+ // TODO: factor this out into a RevisionHtmlProvider that can also be used for viewing.
+ $this->canonicalParserOutput = $this->getSlotParserOutput( 'main' );
+
+ return $this->canonicalParserOutput;
+ }
+
+ /**
+ * @return ParserOptions
+ */
+ public function getCanonicalParserOptions() {
+ if ( $this->canonicalParserOptions ) {
+ return $this->canonicalParserOptions;
+ }
+
+ // TODO: ParserOptions should *not* be controlled by the ContentHandler!
+ // See T190712 for how to fix this for Wikibase.
+ $this->canonicalParserOptions = $this->wikiPage->makeParserOptions( 'canonical' );
+
+ //TODO: if $this->revision is not set but we already know that we pending update is a
+ // null-edit, we should probably use the page's current revision here.
+ // That would avoid the need for the !$this->options['changed'] branch in
+ // outputVariesOnRevisionMetaData [dk 2018-05]
+
+ if ( $this->revision ) {
+ // Make sure we use the appropriate revision ID when generating output
+ $title = $this->getTitle();
+ $oldCallback = $this->canonicalParserOptions->getCurrentRevisionCallback();
+ $this->canonicalParserOptions->setCurrentRevisionCallback(
+ function ( Title $parserTitle, $parser = false ) use ( $title, &$oldCallback ) {
+ if ( $parserTitle->equals( $title ) ) {
+ $legacyRevision = new Revision( $this->revision );
+ return $legacyRevision;
+ } else {
+ return call_user_func( $oldCallback, $parserTitle, $parser );
+ }
+ }
+ );
+ } else {
+ // NOTE: we only get here without READ_LATEST if called directly by application logic
+ $dbIndex = $this->useMaster()
+ ? DB_MASTER // use the best possible guess
+ : DB_REPLICA; // T154554
+
+ $this->canonicalParserOptions->setSpeculativeRevIdCallback(
+ function () use ( $dbIndex ) {
+ // TODO: inject LoadBalancer!
+ $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
+ // Use a fresh connection in order to see the latest data, by avoiding
+ // stale data from REPEATABLE-READ snapshots.
+ // HACK: But don't use a fresh connection in unit tests, since it would not have
+ // the fake tables. This should be handled by the LoadBalancer!
+ $flags = defined( 'MW_PHPUNIT_TEST' ) ? 0 : $lb::CONN_TRX_AUTO;
+ $db = $lb->getConnectionRef( $dbIndex, [], $this->getWikiId(), $flags );
+
+ return 1 + (int)$db->selectField(
+ 'revision',
+ 'MAX(rev_id)',
+ [],
+ __METHOD__
+ );
+ }
+ );
+ }
+
+ return $this->canonicalParserOptions;
+ }
+
+ /**
+ * @param bool $recursive
+ *
+ * @return DataUpdate[]
+ */
+ public function getSecondaryDataUpdates( $recursive = false ) {
+ // TODO: MCR: getSecondaryDataUpdates() needs a complete overhaul to avoid DataUpdates
+ // from different slots overwriting each other in the database. Plan:
+ // * replace direct calls to Content::getSecondaryDataUpdates() with calls to this method
+ // * Construct LinksUpdate here, on the combined ParserOutput, instead of in AbstractContent
+ // for each slot.
+ // * Pass $slot into getSecondaryDataUpdates() - probably be introducing a new duplicate
+ // version of this function in ContentHandler.
+ // * The new method gets the PreparedEdit, but no $recursive flag (that's for LinksUpdate)
+ // * Hack: call both the old and the new getSecondaryDataUpdates method here; Pass
+ // the per-slot ParserOutput to the old method, for B/C.
+ // * Hack: If there is more than one slot, filter LinksUpdate from the DataUpdates
+ // returned by getSecondaryDataUpdates, and use a LinksUpdated for the combined output
+ // instead.
+ // * Call the SecondaryDataUpdates hook here (or kill it - its signature doesn't make sense)
+
+ $content = $this->getSlots()->getContent( 'main' );
+
+ // NOTE: $output is the combined output, to be shown in the default view.
+ $output = $this->getCanonicalParserOutput();
+
+ $updates = $content->getSecondaryDataUpdates(
+ $this->getTitle(), null, $recursive, $output
+ );
+
+ return $updates;
+ }
+
+ /**
+ * Do standard updates after page edit, purge, or import.
+ * Update links tables, site stats, search index, title cache, message cache, etc.
+ * Purges pages that depend on this page when appropriate.
+ * With a 10% chance, triggers pruning the recent changes table.
+ *
+ * @note prepareUpdate() must be called before calling this method!
+ *
+ * MCR migration note: this replaces WikiPage::doEditUpdates.
+ */
+ public function doUpdates() {
+ $this->assertTransition( 'done' );
+
+ // TODO: move logic into a PageEventEmitter service
+
+ $wikiPage = $this->getWikiPage(); // TODO: use only for legacy hooks!
+
+ // NOTE: this may trigger the first parsing of the new content after an edit (when not
+ // using pre-generated stashed output).
+ // XXX: we may want to use the PoolCounter here. This would perhaps allow the initial parse
+ // to be perform post-send. The client could already follow a HTTP redirect to the
+ // page view, but would then have to wait for a response until rendering is complete.
+ $output = $this->getCanonicalParserOutput();
+
+ // Save it to the parser cache.
+ // Make sure the cache time matches page_touched to avoid double parsing.
+ $this->parserCache->save(
+ $output, $wikiPage, $this->getCanonicalParserOptions(),
+ $this->revision->getTimestamp(), $this->revision->getId()
+ );
+
+ $legacyUser = User::newFromIdentity( $this->user );
+ $legacyRevision = new Revision( $this->revision );
+
+ // Update the links tables and other secondary data
+ $recursive = $this->options['changed']; // T52785
+ $updates = $this->getSecondaryDataUpdates( $recursive );
+
+ foreach ( $updates as $update ) {
+ // TODO: make an $option field for the cause
+ $update->setCause( 'edit-page', $this->user->getName() );
+ if ( $update instanceof LinksUpdate ) {
+ $update->setRevision( $legacyRevision );
+
+ if ( !empty( $this->options['triggeringuser'] ) ) {
+ /** @var UserIdentity|User $triggeringUser */
+ $triggeringUser = $this->options['triggeringuser'];
+ if ( !$triggeringUser instanceof User ) {
+ $triggeringUser = User::newFromIdentity( $triggeringUser );
+ }
+
+ $update->setTriggeringUser( $triggeringUser );
+ }
+ }
+ DeferredUpdates::addUpdate( $update );
+ }
+
+ // TODO: MCR: check if *any* changed slot supports categories!
+ if ( $this->rcWatchCategoryMembership
+ && $this->getContentHandler( 'main' )->supportsCategories() === true
+ && ( $this->options['changed'] || $this->options['created'] )
+ && !$this->options['restored']
+ ) {
+ // Note: jobs are pushed after deferred updates, so the job should be able to see
+ // the recent change entry (also done via deferred updates) and carry over any
+ // bot/deletion/IP flags, ect.
+ $this->jobQueueGroup->lazyPush(
+ new CategoryMembershipChangeJob(
+ $this->getTitle(),
+ [
+ 'pageId' => $this->getPageId(),
+ 'revTimestamp' => $this->revision->getTimestamp(),
+ ]
+ )
+ );
+ }
+
+ // TODO: replace legacy hook! Use a listener on PageEventEmitter instead!
+ $editInfo = $this->getPreparedEdit();
+ Hooks::run( 'ArticleEditUpdates', [ &$wikiPage, &$editInfo, $this->options['changed'] ] );
+
+ // TODO: replace legacy hook! Use a listener on PageEventEmitter instead!
+ if ( Hooks::run( 'ArticleEditUpdatesDeleteFromRecentchanges', [ &$wikiPage ] ) ) {
+ // Flush old entries from the `recentchanges` table
+ if ( mt_rand( 0, 9 ) == 0 ) {
+ $this->jobQueueGroup->lazyPush( RecentChangesUpdateJob::newPurgeJob() );
+ }
+ }
+
+ $id = $this->getPageId();
+ $title = $this->getTitle();
+ $dbKey = $title->getPrefixedDBkey();
+ $shortTitle = $title->getDBkey();
+
+ if ( !$title->exists() ) {
+ wfDebug( __METHOD__ . ": Page doesn't exist any more, bailing out\n" );
+
+ $this->doTransition( 'done' );
+ return;
+ }
+
+ if ( $this->options['oldcountable'] === 'no-change' ||
+ ( !$this->options['changed'] && !$this->options['moved'] )
+ ) {
+ $good = 0;
+ } elseif ( $this->options['created'] ) {
+ $good = (int)$this->isCountable();
+ } elseif ( $this->options['oldcountable'] !== null ) {
+ $good = (int)$this->isCountable()
+ - (int)$this->options['oldcountable'];
+ } else {
+ $good = 0;
+ }
+ $edits = $this->options['changed'] ? 1 : 0;
+ $pages = $this->options['created'] ? 1 : 0;
+
+ DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
+ [ 'edits' => $edits, 'articles' => $good, 'pages' => $pages ]
+ ) );
+
+ // TODO: make search infrastructure aware of slots!
+ $mainSlot = $this->revision->getSlot( 'main' );
+ if ( !$mainSlot->isInherited() && $this->isContentPublic() ) {
+ DeferredUpdates::addUpdate( new SearchUpdate( $id, $dbKey, $mainSlot->getContent() ) );
+ }
+
+ // If this is another user's talk page, update newtalk.
+ // Don't do this if $options['changed'] = false (null-edits) nor if
+ // it's a minor edit and the user making the edit doesn't generate notifications for those.
+ if ( $this->options['changed']
+ && $title->getNamespace() == NS_USER_TALK
+ && $shortTitle != $legacyUser->getTitleKey()
+ && !( $this->revision->isMinor() && $legacyUser->isAllowed( 'nominornewtalk' ) )
+ ) {
+ $recipient = User::newFromName( $shortTitle, false );
+ if ( !$recipient ) {
+ wfDebug( __METHOD__ . ": invalid username\n" );
+ } else {
+ // Allow extensions to prevent user notification
+ // when a new message is added to their talk page
+ // TODO: replace legacy hook! Use a listener on PageEventEmitter instead!
+ if ( Hooks::run( 'ArticleEditUpdateNewTalk', [ &$wikiPage, $recipient ] ) ) {
+ if ( User::isIP( $shortTitle ) ) {
+ // An anonymous user
+ $recipient->setNewtalk( true, $legacyRevision );
+ } elseif ( $recipient->isLoggedIn() ) {
+ $recipient->setNewtalk( true, $legacyRevision );
+ } else {
+ wfDebug( __METHOD__ . ": don't need to notify a nonexistent user\n" );
+ }
+ }
+ }
+ }
+
+ if ( $title->getNamespace() == NS_MEDIAWIKI
+ && $this->getRevisionSlotsUpdate()->isModifiedSlot( 'main' )
+ ) {
+ $mainContent = $this->isContentPublic() ? $this->getRawContent( 'main' ) : null;
+
+ $this->messageCache->updateMessageOverride( $title, $mainContent );
+ }
+
+ // TODO: move onArticleCreate and onArticle into a PageEventEmitter service
+ if ( $this->options['created'] ) {
+ WikiPage::onArticleCreate( $title );
+ } elseif ( $this->options['changed'] ) { // T52785
+ WikiPage::onArticleEdit( $title, $legacyRevision, $this->getTouchedSlotRoles() );
+ }
+
+ $oldRevision = $this->getOldRevision();
+ $oldLegacyRevision = $oldRevision ? new Revision( $oldRevision ) : null;
+
+ // TODO: In the wiring, register a listener for this on the new PageEventEmitter
+ ResourceLoaderWikiModule::invalidateModuleCache(
+ $title, $oldLegacyRevision, $legacyRevision, $this->getWikiId()
+ );
+
+ $this->doTransition( 'done' );
+ }
+
+}
* the new revision will act as a null-revision.
*
* @param RevisionRecord $parent
- * @param CommentStoreComment $comment
- * @param UserIdentity $user
- * @param string $timestamp
*
* @return MutableRevisionRecord
*/
- public static function newFromParentRevision(
- RevisionRecord $parent,
- CommentStoreComment $comment,
- UserIdentity $user,
- $timestamp
- ) {
+ public static function newFromParentRevision( RevisionRecord $parent ) {
// TODO: ideally, we wouldn't need a Title here
$title = Title::newFromLinkTarget( $parent->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 );
* @param SlotRecord $parentSlot
*/
public function inheritSlot( SlotRecord $parentSlot ) {
- $slot = SlotRecord::newInherited( $parentSlot );
- $this->setSlot( $slot );
+ $this->mSlots->inheritSlot( $parentSlot );
+ $this->resetAggregateValues();
}
/**
$this->resetAggregateValues();
}
+ /**
+ * Applies the given update to the slots of this revision.
+ *
+ * @param RevisionSlotsUpdate $update
+ */
+ public function applyUpdate( RevisionSlotsUpdate $update ) {
+ $update->apply( $this->mSlots );
+ }
+
/**
* @param CommentStoreComment $comment
*/
* 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 ) {
}
/**
- * Sets the content for the slot with the given role.
+ * Sets the given slot to an inherited version of $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 inheritSlot( SlotRecord $slot ) {
+ $this->setSlot( SlotRecord::newInherited( $slot ) );
+ }
+
+ /**
+ * Sets the content for the slot with the given role.
+ * If a slot with the same role is already present, it is replaced.
*
* @param string $role
* @param Content $content
/**
* 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 ) {
private $nameField;
/** @var null|callable */
private $normalizationCallback = null;
+ /** @var null|callable */
+ private $insertCallback = null;
/**
* @param LoadBalancer $dbLoadBalancer A load balancer for acquiring database connections
* @param callable $normalizationCallback Normalization to be applied to names before being
* saved or queried. This should be a callback that accepts and returns a single string.
* @param bool|string $wikiId The ID of the target wiki database. Use false for the local wiki.
+ * @param callable $insertCallback Callback to change insert fields accordingly.
+ * This parameter was introduced in 1.32
*/
public function __construct(
LoadBalancer $dbLoadBalancer,
$idField,
$nameField,
callable $normalizationCallback = null,
- $wikiId = false
+ $wikiId = false,
+ callable $insertCallback = null
) {
$this->loadBalancer = $dbLoadBalancer;
$this->cache = $cache;
$this->normalizationCallback = $normalizationCallback;
$this->wikiId = $wikiId;
$this->cacheTTL = IExpiringStore::TTL_MONTH;
+ $this->insertCallback = $insertCallback;
}
/**
$dbw = $this->getDBConnection( DB_MASTER );
+ $insertFields = [ $this->nameField => $name ];
+ if ( $this->insertCallback !== null ) {
+ $insertFields = call_user_func( $this->insertCallback, $insertFields );
+ }
+
$dbw->insert(
$this->table,
- [ $this->nameField => $name ],
+ $insertFields,
__METHOD__,
[ 'IGNORE' ]
);
--- /dev/null
+<?php
+/**
+ * Exception representing a failure to update a page entry.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Storage;
+
+use RuntimeException;
+
+/**
+ * Exception representing a failure to update a page entry.
+ *
+ * @since 1.32
+ */
+class PageUpdateException extends RuntimeException {
+
+}
--- /dev/null
+<?php
+/**
+ * Controller-like object for creating and updating pages by creating new revisions.
+ *
+ * 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
+ *
+ * @author Daniel Kinzler
+ */
+
+namespace MediaWiki\Storage;
+
+use AtomicSectionUpdate;
+use ChangeTags;
+use CommentStoreComment;
+use Content;
+use ContentHandler;
+use DeferredUpdates;
+use Hooks;
+use InvalidArgumentException;
+use LogicException;
+use ManualLogEntry;
+use MediaWiki\Linker\LinkTarget;
+use MWException;
+use RecentChange;
+use Revision;
+use RuntimeException;
+use Status;
+use Title;
+use User;
+use Wikimedia\Assert\Assert;
+use Wikimedia\Rdbms\DBConnRef;
+use Wikimedia\Rdbms\DBUnexpectedError;
+use Wikimedia\Rdbms\LoadBalancer;
+use WikiPage;
+
+/**
+ * Controller-like object for creating and updating pages by creating new revisions.
+ *
+ * PageUpdater instances provide compare-and-swap (CAS) protection against concurrent updates
+ * between the time grabParentRevision() is called and saveRevision() inserts a new revision.
+ * This allows application logic to safely perform edit conflict resolution using the parent
+ * revision's content.
+ *
+ * @see docs/pageupdater.txt for more information.
+ *
+ * MCR migration note: this replaces the relevant methods in WikiPage.
+ *
+ * @since 1.32
+ * @ingroup Page
+ */
+class PageUpdater {
+
+ /**
+ * @var User
+ */
+ private $user;
+
+ /**
+ * @var WikiPage
+ */
+ private $wikiPage;
+
+ /**
+ * @var DerivedPageDataUpdater
+ */
+ private $derivedDataUpdater;
+
+ /**
+ * @var LoadBalancer
+ */
+ private $loadBalancer;
+
+ /**
+ * @var RevisionStore
+ */
+ private $revisionStore;
+
+ /**
+ * @var boolean see $wgUseAutomaticEditSummaries
+ * @see $wgUseAutomaticEditSummaries
+ */
+ private $useAutomaticEditSummaries = true;
+
+ /**
+ * @var int the RC patrol status the new revision should be marked with.
+ */
+ private $rcPatrolStatus = RecentChange::PRC_UNPATROLLED;
+
+ /**
+ * @var bool whether to create a log entry for new page creations.
+ */
+ private $usePageCreationLog = true;
+
+ /**
+ * @var boolean see $wgAjaxEditStash
+ */
+ private $ajaxEditStash = true;
+
+ /**
+ * The ID of the logical base revision the content of the new revision is based on.
+ * Not to be confused with the immediate parent revision (the current revision before the
+ * new revision is created).
+ * The base revision is the last revision known to the client, while the parent revision
+ * is determined on the server by grabParentRevision().
+ *
+ * @var bool|int
+ */
+ private $baseRevId = false;
+
+ /**
+ * @var array
+ */
+ private $tags = [];
+
+ /**
+ * @var int
+ */
+ private $undidRevId = 0;
+
+ /**
+ * @var RevisionSlotsUpdate
+ */
+ private $slotsUpdate;
+
+ /**
+ * @var Status|null
+ */
+ private $status = null;
+
+ /**
+ * @param User $user
+ * @param WikiPage $wikiPage
+ * @param DerivedPageDataUpdater $derivedDataUpdater
+ * @param LoadBalancer $loadBalancer
+ * @param RevisionStore $revisionStore
+ */
+ public function __construct(
+ User $user,
+ WikiPage $wikiPage,
+ DerivedPageDataUpdater $derivedDataUpdater,
+ LoadBalancer $loadBalancer,
+ RevisionStore $revisionStore
+ ) {
+ $this->user = $user;
+ $this->wikiPage = $wikiPage;
+ $this->derivedDataUpdater = $derivedDataUpdater;
+
+ $this->loadBalancer = $loadBalancer;
+ $this->revisionStore = $revisionStore;
+
+ $this->slotsUpdate = new RevisionSlotsUpdate();
+ }
+
+ /**
+ * Can be used to enable or disable automatic summaries that are applied to certain kinds of
+ * changes, like completely blanking a page.
+ *
+ * @param bool $useAutomaticEditSummaries
+ * @see $wgUseAutomaticEditSummaries
+ */
+ public function setUseAutomaticEditSummaries( $useAutomaticEditSummaries ) {
+ $this->useAutomaticEditSummaries = $useAutomaticEditSummaries;
+ }
+
+ /**
+ * Sets the "patrolled" status of the edit.
+ * Callers should check the "patrol" and "autopatrol" permissions as appropriate.
+ *
+ * @see $wgUseRCPatrol
+ * @see $wgUseNPPatrol
+ *
+ * @param int $status RC patrol status, e.g. RecentChange::PRC_AUTOPATROLLED.
+ */
+ public function setRcPatrolStatus( $status ) {
+ $this->rcPatrolStatus = $status;
+ }
+
+ /**
+ * Whether to create a log entry for new page creations.
+ *
+ * @see $wgPageCreationLog
+ *
+ * @param bool $use
+ */
+ public function setUsePageCreationLog( $use ) {
+ $this->usePageCreationLog = $use;
+ }
+
+ /**
+ * @param bool $ajaxEditStash
+ * @see $wgAjaxEditStash
+ */
+ public function setAjaxEditStash( $ajaxEditStash ) {
+ $this->ajaxEditStash = $ajaxEditStash;
+ }
+
+ private function getWikiId() {
+ return false; // TODO: get from RevisionStore!
+ }
+
+ /**
+ * @param int $mode DB_MASTER or DB_REPLICA
+ *
+ * @return DBConnRef
+ */
+ private function getDBConnectionRef( $mode ) {
+ return $this->loadBalancer->getConnectionRef( $mode, [], $this->getWikiId() );
+ }
+
+ /**
+ * @return LinkTarget
+ */
+ private function getLinkTarget() {
+ // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
+ return $this->wikiPage->getTitle();
+ }
+
+ /**
+ * @return Title
+ */
+ private function getTitle() {
+ // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
+ return $this->wikiPage->getTitle();
+ }
+
+ /**
+ * @return WikiPage
+ */
+ private function getWikiPage() {
+ // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
+ return $this->wikiPage;
+ }
+
+ /**
+ * Checks whether this update conflicts with another update performed since the specified base
+ * revision. A user level "edit conflict" is detected when the base revision known to the client
+ * and specified via setBaseRevisionId() is not the ID of the current revision before the
+ * update. If setBaseRevisionId() was not called, this method always returns false.
+ *
+ * Note that an update expected to be based on a non-existing page will have base revision ID 0,
+ * and is considered to have a conflict if a current revision exists (that is, the page was
+ * created since the base revision was determined by the client).
+ *
+ * This method returning true indicates to calling code that edit conflict resolution should
+ * be applied before saving any data. It does not prevent the update from being performed, and
+ * it should not be confused with a "late" conflict indicated by the "edit-conflict" status.
+ * A "late" conflict is a CAS failure caused by an update being performed concurrently, between
+ * the time grabParentRevision() was called and the time saveRevision() trying to insert the
+ * new revision.
+ *
+ * @note A user level edit conflict is not the same as the "edit-conflict" status triggered by
+ * a CAS failure. Calling this method establishes the CAS token, it does not check against it:
+ * This method calls grabParentRevision(), and thus causes the expected parent revision
+ * for the update to be fixed to the page's current revision at this point in time.
+ * It acts as a compare-and-swap (CAS) token in that it is guaranteed that saveRevision()
+ * will fail with the "edit-conflict" status if the current revision of the page changes after
+ * hasEditConflict() was called and before saveRevision() could insert a new revision.
+ *
+ * @see grabParentRevision()
+ *
+ * @return bool
+ */
+ public function hasEditConflict() {
+ $baseId = $this->getBaseRevisionId();
+ if ( $baseId === false ) {
+ return false;
+ }
+
+ $parent = $this->grabParentRevision();
+ $parentId = $parent ? $parent->getId() : 0;
+
+ return $parentId !== $baseId;
+ }
+
+ /**
+ * Returns the revision that was the page's current revision when grabParentRevision()
+ * was first called. This revision is the expected parent revision of the update, and will be
+ * recorded as the new revision's parent revision (unless no new revision is created because
+ * the content was not changed).
+ *
+ * This method MUST not be called after saveRevision() was called!
+ *
+ * The current revision determined by the first call to this methods effectively acts a
+ * compare-and-swap (CAS) token which is checked by saveRevision(), which fails if any
+ * concurrent updates created a new revision.
+ *
+ * Application code should call this method before applying transformations to the new
+ * content that depend on the parent revision, e.g. adding/replacing sections, or resolving
+ * conflicts via a 3-way merge. This protects against race conditions triggered by concurrent
+ * updates.
+ *
+ * @see DerivedPageDataUpdater::grabCurrentRevision()
+ *
+ * @note The expected parent revision is not to be confused with the logical base revision.
+ * The base revision is specified by the client, the parent revision is determined from the
+ * database. If base revision and parent revision are not the same, the updates is considered
+ * to require edit conflict resolution.
+ *
+ * @throws LogicException if called after saveRevision().
+ * @return RevisionRecord|null the parent revision, or null of the page does not yet exist.
+ */
+ public function grabParentRevision() {
+ return $this->derivedDataUpdater->grabCurrentRevision();
+ }
+
+ /**
+ * @return string
+ */
+ private function getTimestampNow() {
+ // TODO: allow an override to be injected for testing
+ return wfTimestampNow();
+ }
+
+ /**
+ * Check flags and add EDIT_NEW or EDIT_UPDATE to them as needed.
+ * This also performs sanity checks against the base revision specified via setBaseRevisionId().
+ *
+ * @param int $flags
+ * @return int Updated $flags
+ */
+ private function checkFlags( $flags ) {
+ if ( !( $flags & EDIT_NEW ) && !( $flags & EDIT_UPDATE ) ) {
+ if ( $this->baseRevId === false ) {
+ $flags |= ( $this->derivedDataUpdater->pageExisted() ) ? EDIT_UPDATE : EDIT_NEW;
+ } else {
+ $flags |= ( $this->baseRevId > 0 ) ? EDIT_UPDATE : EDIT_NEW;
+ }
+ }
+
+ return $flags;
+ }
+
+ /**
+ * Set the new content for the given slot role
+ *
+ * @param string $role A slot role name (such as "main")
+ * @param Content $content
+ */
+ public function setContent( $role, Content $content ) {
+ // TODO: MCR: check the role and the content's model against the list of supported
+ // roles, see T194046.
+
+ $this->slotsUpdate->modifyContent( $role, $content );
+ }
+
+ /**
+ * Explicitly inherit a slot from some earlier revision.
+ *
+ * The primary use case for this is rollbacks, when slots are to be inherited from
+ * the rollback target, overriding the content from the parent revision (which is the
+ * revision being rolled back).
+ *
+ * This should typically not be used to inherit slots from the parent revision, which
+ * happens implicitly. Using this method causes the given slot to be treated as "modified"
+ * during revision creation, even if it has the same content as in the parent revision.
+ *
+ * @param SlotRecord $originalSlot A slot already existing in the database, to be inherited
+ * by the new revision.
+ */
+ public function inheritSlot( SlotRecord $originalSlot ) {
+ // NOTE: this slot is inherited from some other revision, but it's
+ // a "modified" slot for the RevisionSlotsUpdate and DerivedPageDataUpdater,
+ // since it's not implicitly inherited from the parent revision.
+ $inheritedSlot = SlotRecord::newInherited( $originalSlot );
+ $this->slotsUpdate->modifySlot( $inheritedSlot );
+ }
+
+ /**
+ * Removes the slot with the given role.
+ *
+ * This discontinues the "stream" of slots with this role on the page,
+ * preventing the new revision, and any subsequent revisions, from
+ * inheriting the slot with this role.
+ *
+ * @param string $role A slot role name (but not "main")
+ */
+ public function removeSlot( $role ) {
+ if ( $role === 'main' ) {
+ throw new InvalidArgumentException( 'Cannot remove the main slot!' );
+ }
+
+ $this->slotsUpdate->removeSlot( $role );
+ }
+
+ /**
+ * Returns the ID of the logical base revision of the update. Not to be confused with the
+ * immediate parent revision. The base revision is set via setBaseRevisionId(),
+ * the parent revision is determined by grabParentRevision().
+ *
+ * Application may use this information to detect user level edit conflicts. Edit conflicts
+ * can be resolved by performing a 3-way merge, using the revision returned by this method as
+ * the common base of the conflicting revisions, namely the new revision being saved,
+ * and the revision returned by grabParentRevision().
+ *
+ * @return bool|int The ID of the base revision, 0 if the base is a non-existing page, false
+ * if no base revision was specified.
+ */
+ public function getBaseRevisionId() {
+ return $this->baseRevId;
+ }
+
+ /**
+ * Sets the ID of the revision the content of this update is based on, if any.
+ * The base revision ID is not to be confused with the new revision's parent revision:
+ * the parent revision is the page's current revision immediately before the new revision
+ * is created; the base revision indicates what revision the client based the content of
+ * the new revision on. If base revision and parent revision are not the same, the update is
+ * considered to require edit conflict resolution.
+ *
+ * @param int|bool $baseRevId The ID of the base revision, or 0 if the update is expected to be
+ * performed on a non-existing page. false can be used to indicate that the caller
+ * doesn't care about the base revision.
+ */
+ public function setBaseRevisionId( $baseRevId ) {
+ Assert::parameterType( 'integer|boolean', $baseRevId, '$baseRevId' );
+ $this->baseRevId = $baseRevId;
+ }
+
+ /**
+ * Returns the revision ID set by setUndidRevisionId(), indicating what revision is being
+ * undone by this edit.
+ *
+ * @return int
+ */
+ public function getUndidRevisionId() {
+ return $this->undidRevId;
+ }
+
+ /**
+ * Sets the ID of revision that was undone by the present update.
+ * This is used with the "undo" action, and is expected to hold the oldest revision ID
+ * in case more then one revision is being undone.
+ *
+ * @param int $undidRevId
+ */
+ public function setUndidRevisionId( $undidRevId ) {
+ Assert::parameterType( 'integer', $undidRevId, '$undidRevId' );
+ $this->undidRevId = $undidRevId;
+ }
+
+ /**
+ * Sets a tag to apply to this update.
+ * Callers are responsible for permission checks,
+ * using ChangeTags::canAddTagsAccompanyingChange.
+ * @param string $tag
+ */
+ public function addTag( $tag ) {
+ Assert::parameterType( 'string', $tag, '$tag' );
+ $this->tags[] = trim( $tag );
+ }
+
+ /**
+ * Sets tags to apply to this update.
+ * Callers are responsible for permission checks,
+ * using ChangeTags::canAddTagsAccompanyingChange.
+ * @param string[] $tags
+ */
+ public function addTags( array $tags ) {
+ Assert::parameterElementType( 'string', $tags, '$tags' );
+ foreach ( $tags as $tag ) {
+ $this->addTag( $tag );
+ }
+ }
+
+ /**
+ * Returns the list of tags set using the addTag() method.
+ *
+ * @return string[]
+ */
+ public function getExplicitTags() {
+ return $this->tags;
+ }
+
+ /**
+ * @param int $flags Bit mask: a bit mask of EDIT_XXX flags.
+ * @return string[]
+ */
+ private function computeEffectiveTags( $flags ) {
+ $tags = $this->tags;
+
+ foreach ( $this->slotsUpdate->getModifiedRoles() as $role ) {
+ $old_content = $this->getParentContent( $role );
+
+ $handler = $this->getContentHandler( $role );
+ $content = $this->slotsUpdate->getModifiedSlot( $role )->getContent();
+
+ // TODO: MCR: Do this for all slots. Also add tags for removing roles!
+ $tag = $handler->getChangeTag( $old_content, $content, $flags );
+ // If there is no applicable tag, null is returned, so we need to check
+ if ( $tag ) {
+ $tags[] = $tag;
+ }
+ }
+
+ // Check for undo tag
+ if ( $this->undidRevId !== 0 && in_array( 'mw-undo', ChangeTags::getSoftwareTags() ) ) {
+ $tags[] = 'mw-undo';
+ }
+
+ return array_unique( $tags );
+ }
+
+ /**
+ * Returns the content of the given slot of the parent revision, with no audience checks applied.
+ * If there is no parent revision or the slot is not defined, this returns null.
+ *
+ * @param string $role slot role name
+ * @return Content|null
+ */
+ private function getParentContent( $role ) {
+ $parent = $this->grabParentRevision();
+
+ if ( $parent && $parent->hasSlot( $role ) ) {
+ return $parent->getContent( $role, RevisionRecord::RAW );
+ }
+
+ return null;
+ }
+
+ /**
+ * @param string $role slot role name
+ * @return ContentHandler
+ */
+ private function getContentHandler( $role ) {
+ // TODO: inject something like a ContentHandlerRegistry
+ if ( $this->slotsUpdate->isModifiedSlot( $role ) ) {
+ $slot = $this->slotsUpdate->getModifiedSlot( $role );
+ } else {
+ $parent = $this->grabParentRevision();
+
+ if ( $parent ) {
+ $slot = $parent->getSlot( $role, RevisionRecord::RAW );
+ } else {
+ throw new RevisionAccessException( 'No such slot: ' . $role );
+ }
+ }
+
+ return ContentHandler::getForModelID( $slot->getModel() );
+ }
+
+ /**
+ * @param int $flags Bit mask: a bit mask of EDIT_XXX flags.
+ *
+ * @return CommentStoreComment
+ */
+ private function makeAutoSummary( $flags ) {
+ if ( !$this->useAutomaticEditSummaries || ( $flags & EDIT_AUTOSUMMARY ) === 0 ) {
+ return CommentStoreComment::newUnsavedComment( '' );
+ }
+
+ // NOTE: this generates an auto-summary for SOME RANDOM changed slot!
+ // TODO: combine auto-summaries for multiple slots!
+ // XXX: this logic should not be in the storage layer!
+ $roles = $this->slotsUpdate->getModifiedRoles();
+ $role = reset( $roles );
+
+ if ( $role === false ) {
+ return CommentStoreComment::newUnsavedComment( '' );
+ }
+
+ $handler = $this->getContentHandler( $role );
+ $content = $this->slotsUpdate->getModifiedSlot( $role )->getContent();
+ $old_content = $this->getParentContent( $role );
+ $summary = $handler->getAutosummary( $old_content, $content, $flags );
+
+ return CommentStoreComment::newUnsavedComment( $summary );
+ }
+
+ /**
+ * Change an existing article or create a new article. Updates RC and all necessary caches,
+ * optionally via the deferred update array. This does not check user permissions.
+ *
+ * It is guaranteed that saveRevision() will fail if the current revision of the page
+ * changes after grabParentRevision() was called and before saveRevision() can insert
+ * a new revision, as per the CAS mechanism described above.
+ *
+ * However, the actual parent revision is allowed to be different from the revision set
+ * with setBaseRevisionId(). The caller is responsible for checking this via
+ * hasEditConflict() and adjusting the content of the new revision accordingly,
+ * using a 3-way-merge if desired.
+ *
+ * MCR migration note: this replaces WikiPage::doEditContent. Callers that change to using
+ * saveRevision() now need to check the "minoredit" themselves before using EDIT_MINOR.
+ *
+ * @param CommentStoreComment $summary Edit summary
+ * @param int $flags Bitfield:
+ * EDIT_NEW
+ * Create a new page, or fail with "edit-already-exists" if the page exists.
+ * EDIT_UPDATE
+ * Create a new revision, or fail with "edit-gone-missing" if the page does not exist.
+ * EDIT_MINOR
+ * Mark this revision as minor
+ * EDIT_SUPPRESS_RC
+ * Do not log the change in recentchanges
+ * EDIT_FORCE_BOT
+ * Mark the revision as automated ("bot edit")
+ * EDIT_AUTOSUMMARY
+ * Fill in blank summaries with generated text where possible
+ * EDIT_INTERNAL
+ * Signal that the page retrieve/save cycle happened entirely in this request.
+ *
+ * If neither EDIT_NEW nor EDIT_UPDATE is specified, the expected state is detected
+ * automatically via grabParentRevision(). In this case, the "edit-already-exists" or
+ * "edit-gone-missing" errors may still be triggered due to race conditions, if the page
+ * was unexpectedly created or deleted while revision creation is in progress. This can be
+ * viewed as part of the CAS mechanism described above.
+ *
+ * @return RevisionRecord|null The new revision, or null if no new revision was created due
+ * to a failure or a null-edit. Use isUnchanged(), wasSuccessful() and getStatus()
+ * to determine the outcome of the revision creation.
+ *
+ * @throws MWException
+ * @throws RuntimeException
+ */
+ public function saveRevision( CommentStoreComment $summary, $flags = 0 ) {
+ // Defend against mistakes caused by differences with the
+ // signature of WikiPage::doEditContent.
+ Assert::parameterType( 'integer', $flags, '$flags' );
+ Assert::parameterType( 'CommentStoreComment', $summary, '$summary' );
+
+ if ( $this->wasCommitted() ) {
+ throw new RuntimeException( 'saveRevision() has already been called on this PageUpdater!' );
+ }
+
+ // Low-level sanity check
+ if ( $this->getLinkTarget()->getText() === '' ) {
+ throw new RuntimeException( 'Something is trying to edit an article with an empty title' );
+ }
+
+ // TODO: MCR: check the role and the content's model against the list of supported
+ // and required roles, see T194046.
+
+ // Make sure the given content type is allowed for this page
+ // TODO: decide: Extend check to other slots? Consider the role in check? [PageType]
+ $mainContentHandler = $this->getContentHandler( 'main' );
+ if ( !$mainContentHandler->canBeUsedOn( $this->getTitle() ) ) {
+ $this->status = Status::newFatal( 'content-not-allowed-here',
+ ContentHandler::getLocalizedName( $mainContentHandler->getModelID() ),
+ $this->getTitle()->getPrefixedText()
+ );
+ return null;
+ }
+
+ // Load the data from the master database if needed. Needed to check flags.
+ // NOTE: This grabs the parent revision as the CAS token, if grabParentRevision
+ // wasn't called yet. If the page is modified by another process before we are done with
+ // it, this method must fail (with status 'edit-conflict')!
+ // NOTE: The actual parent revision may be different from $this->baseRevisionId.
+ // The caller is responsible for checking this via hasEditConflict and adjusting the
+ // content of the new revision accordingly, using a 3-way-merge.
+ $this->grabParentRevision();
+ $flags = $this->checkFlags( $flags );
+
+ // Avoid statsd noise and wasted cycles check the edit stash (T136678)
+ if ( ( $flags & EDIT_INTERNAL ) || ( $flags & EDIT_FORCE_BOT ) ) {
+ $useStashed = false;
+ } else {
+ $useStashed = $this->ajaxEditStash;
+ }
+
+ // TODO: use this only for the legacy hook, and only if something uses the legacy hook
+ $wikiPage = $this->getWikiPage();
+
+ $user = $this->user;
+
+ // Prepare the update. This performs PST and generates the canonical ParserOutput.
+ $this->derivedDataUpdater->prepareContent(
+ $this->user,
+ $this->slotsUpdate,
+ $useStashed
+ );
+
+ // TODO: don't force initialization here!
+ // This is a hack to work around the fact that late initialization of the ParserOutput
+ // causes ApiFlowEditHeaderTest::testCache to fail. Whether that failure indicates an
+ // actual problem, or is just an issue with the test setup, remains to be determined
+ // [dk, 2018-03].
+ // Anomie said in 2018-03:
+ /*
+ I suspect that what's breaking is this:
+
+ The old version of WikiPage::doEditContent() called prepareContentForEdit() which
+ generated the ParserOutput right then, so when doEditUpdates() gets called from the
+ DeferredUpdate scheduled by WikiPage::doCreate() there's no need to parse. I note
+ there's a comment there that says "Get the pre-save transform content and final
+ parser output".
+ The new version of WikiPage::doEditContent() makes a PageUpdater and calls its
+ saveRevision(), which calls DerivedPageDataUpdater::prepareContent() and
+ PageUpdater::doCreate() without ever having to actually generate a ParserOutput.
+ Thus, when DerivedPageDataUpdater::doUpdates() is called from the DeferredUpdate
+ scheduled by PageUpdater::doCreate(), it does find that it needs to parse at that point.
+
+ And the order of operations in that Flow test is presumably:
+
+ - Create a page with a call to WikiPage::doEditContent(), in a way that somehow avoids
+ processing the DeferredUpdate.
+ - Set up the "no set!" mock cache in Flow\Tests\Api\ApiTestCase::expectCacheInvalidate()
+ - Then, during the course of doing that test, a $db->commit() results in the
+ DeferredUpdates being run.
+ */
+ $this->derivedDataUpdater->getCanonicalParserOutput();
+
+ $mainContent = $this->derivedDataUpdater->getSlots()->getContent( 'main' );
+
+ // Trigger pre-save hook (using provided edit summary)
+ $hookStatus = Status::newGood( [] );
+ // TODO: replace legacy hook!
+ // TODO: avoid pass-by-reference, see T193950
+ $hook_args = [ &$wikiPage, &$user, &$mainContent, &$summary,
+ $flags & EDIT_MINOR, null, null, &$flags, &$hookStatus ];
+ // Check if the hook rejected the attempted save
+ if ( !Hooks::run( 'PageContentSave', $hook_args ) ) {
+ if ( $hookStatus->isOK() ) {
+ // Hook returned false but didn't call fatal(); use generic message
+ $hookStatus->fatal( 'edit-hook-aborted' );
+ }
+
+ $this->status = $hookStatus;
+ return null;
+ }
+
+ // Provide autosummaries if one is not provided and autosummaries are enabled
+ // XXX: $summary == null seems logical, but the empty string may actually come from the user
+ // XXX: Move this logic out of the storage layer! It does not belong here! Use a callback?
+ if ( $summary->text === '' && $summary->data === null ) {
+ $summary = $this->makeAutoSummary( $flags );
+ }
+
+ // Actually create the revision and create/update the page.
+ // Do NOT yet set $this->status!
+ if ( $flags & EDIT_UPDATE ) {
+ $status = $this->doModify( $summary, $this->user, $flags );
+ } else {
+ $status = $this->doCreate( $summary, $this->user, $flags );
+ }
+
+ // Promote user to any groups they meet the criteria for
+ DeferredUpdates::addCallableUpdate( function () use ( $user ) {
+ $user->addAutopromoteOnceGroups( 'onEdit' );
+ $user->addAutopromoteOnceGroups( 'onView' ); // b/c
+ } );
+
+ // NOTE: set $this->status only after all hooks have been called,
+ // so wasCommitted doesn't return true wehn called indirectly from a hook handler!
+ $this->status = $status;
+
+ // TODO: replace bad status with Exceptions!
+ return ( $this->status && $this->status->isOK() )
+ ? $this->status->value['revision-record']
+ : null;
+ }
+
+ /**
+ * Whether saveRevision() has been called on this instance
+ *
+ * @return bool
+ */
+ public function wasCommitted() {
+ return $this->status !== null;
+ }
+
+ /**
+ * The Status object indicating whether saveRevision() was successful, or null if
+ * saveRevision() was not yet called on this instance.
+ *
+ * @note This is here for compatibility with WikiPage::doEditContent. It may be deprecated
+ * soon.
+ *
+ * Possible status errors:
+ * edit-hook-aborted: The ArticleSave hook aborted the update but didn't
+ * set the fatal flag of $status.
+ * edit-gone-missing: In update mode, but the article didn't exist.
+ * edit-conflict: In update mode, the article changed unexpectedly.
+ * edit-no-change: Warning that the text was the same as before.
+ * edit-already-exists: In creation mode, but the article already exists.
+ *
+ * Extensions may define additional errors.
+ *
+ * $return->value will contain an associative array with members as follows:
+ * new: Boolean indicating if the function attempted to create a new article.
+ * revision: The revision object for the inserted revision, or null.
+ *
+ * @return null|Status
+ */
+ public function getStatus() {
+ return $this->status;
+ }
+
+ /**
+ * Whether saveRevision() completed successfully
+ *
+ * @return bool
+ */
+ public function wasSuccessful() {
+ return $this->status && $this->status->isOK();
+ }
+
+ /**
+ * Whether saveRevision() was called and created a new page.
+ *
+ * @return bool
+ */
+ public function isNew() {
+ return $this->status && $this->status->isOK() && $this->status->value['new'];
+ }
+
+ /**
+ * Whether saveRevision() did not create a revision because the content didn't change
+ * (null-edit). Whether the content changed or not is determined by
+ * DerivedPageDataUpdater::isChange().
+ *
+ * @return bool
+ */
+ public function isUnchanged() {
+ return $this->status
+ && $this->status->isOK()
+ && $this->status->value['revision-record'] === null;
+ }
+
+ /**
+ * The new revision created by saveRevision(), or null if saveRevision() has not yet been
+ * called, failed, or did not create a new revision because the content did not change.
+ *
+ * @return RevisionRecord|null
+ */
+ public function getNewRevision() {
+ return ( $this->status && $this->status->isOK() )
+ ? $this->status->value['revision-record']
+ : null;
+ }
+
+ /**
+ * Constructs a MutableRevisionRecord based on the Content prepared by the
+ * DerivedPageDataUpdater. This takes care of inheriting slots, updating slots
+ * with PST applied, and removing discontinued slots.
+ *
+ * This calls Content::prepareSave() to verify that the slot content can be saved.
+ * The $status parameter is updated with any errors or warnings found by Content::prepareSave().
+ *
+ * @param CommentStoreComment $comment
+ * @param User $user
+ * @param string $timestamp
+ * @param int $flags
+ * @param Status $status
+ *
+ * @return MutableRevisionRecord
+ */
+ private function makeNewRevision(
+ CommentStoreComment $comment,
+ User $user,
+ $timestamp,
+ $flags,
+ Status $status
+ ) {
+ $wikiPage = $this->getWikiPage();
+ $title = $this->getTitle();
+ $parent = $this->grabParentRevision();
+
+ $rev = new MutableRevisionRecord( $title, $this->getWikiId() );
+ $rev->setPageId( $title->getArticleID() );
+
+ if ( $parent ) {
+ $oldid = $parent->getId();
+ $rev->setParentId( $oldid );
+ } else {
+ $oldid = 0;
+ }
+
+ $rev->setComment( $comment );
+ $rev->setUser( $user );
+ $rev->setTimestamp( $timestamp );
+ $rev->setMinorEdit( ( $flags & EDIT_MINOR ) > 0 );
+
+ foreach ( $this->derivedDataUpdater->getSlots()->getSlots() as $slot ) {
+ $content = $slot->getContent();
+
+ // XXX: We may push this up to the "edit controller" level, see T192777.
+ // TODO: change the signature of PrepareSave to not take a WikiPage!
+ $prepStatus = $content->prepareSave( $wikiPage, $flags, $oldid, $user );
+
+ if ( $prepStatus->isOK() ) {
+ $rev->setSlot( $slot );
+ }
+
+ // TODO: MCR: record which problem arose in which slot.
+ $status->merge( $prepStatus );
+ }
+
+ return $rev;
+ }
+
+ /**
+ * @param CommentStoreComment $summary The edit summary
+ * @param User $user The revision's author
+ * @param int $flags EXIT_XXX constants
+ *
+ * @throws MWException
+ * @return Status
+ */
+ private function doModify( CommentStoreComment $summary, User $user, $flags ) {
+ $wikiPage = $this->getWikiPage(); // TODO: use for legacy hooks only!
+
+ // Update article, but only if changed.
+ $status = Status::newGood( [ 'new' => false, 'revision' => null, 'revision-record' => null ] );
+
+ // Convenience variables
+ $now = $this->getTimestampNow();
+
+ $oldRev = $this->grabParentRevision();
+ $oldid = $oldRev ? $oldRev->getId() : 0;
+
+ if ( !$oldRev ) {
+ // Article gone missing
+ $status->fatal( 'edit-gone-missing' );
+
+ return $status;
+ }
+
+ $newRevisionRecord = $this->makeNewRevision(
+ $summary,
+ $user,
+ $now,
+ $flags,
+ $status
+ );
+
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ // XXX: we may want a flag that allows a null revision to be forced!
+ $changed = $this->derivedDataUpdater->isChange();
+ $mainContent = $newRevisionRecord->getContent( 'main' );
+
+ $dbw = $this->getDBConnectionRef( DB_MASTER );
+
+ if ( $changed ) {
+ $dbw->startAtomic( __METHOD__ );
+
+ // Get the latest page_latest value while locking it.
+ // Do a CAS style check to see if it's the same as when this method
+ // started. If it changed then bail out before touching the DB.
+ $latestNow = $wikiPage->lockAndGetLatest(); // TODO: move to storage service, pass DB
+ if ( $latestNow != $oldid ) {
+ // We don't need to roll back, since we did not modify the database yet.
+ // XXX: Or do we want to rollback, any transaction started by calling
+ // code will fail? If we want that, we should probably throw an exception.
+ $dbw->endAtomic( __METHOD__ );
+ // Page updated or deleted in the mean time
+ $status->fatal( 'edit-conflict' );
+
+ return $status;
+ }
+
+ // At this point we are now comitted to returning an OK
+ // status unless some DB query error or other exception comes up.
+ // This way callers don't have to call rollback() if $status is bad
+ // unless they actually try to catch exceptions (which is rare).
+
+ // Save revision content and meta-data
+ $newRevisionRecord = $this->revisionStore->insertRevisionOn( $newRevisionRecord, $dbw );
+ $newLegacyRevision = new Revision( $newRevisionRecord );
+
+ // Update page_latest and friends to reflect the new revision
+ // TODO: move to storage service
+ $wasRedirect = $this->derivedDataUpdater->wasRedirect();
+ if ( !$wikiPage->updateRevisionOn( $dbw, $newLegacyRevision, null, $wasRedirect ) ) {
+ throw new PageUpdateException( "Failed to update page row to use new revision." );
+ }
+
+ // TODO: replace legacy hook!
+ $tags = $this->computeEffectiveTags( $flags );
+ Hooks::run(
+ 'NewRevisionFromEditComplete',
+ [ $wikiPage, $newLegacyRevision, $this->baseRevId, $user, &$tags ]
+ );
+
+ // Update recentchanges
+ if ( !( $flags & EDIT_SUPPRESS_RC ) ) {
+ // Add RC row to the DB
+ RecentChange::notifyEdit(
+ $now,
+ $this->getTitle(),
+ $newRevisionRecord->isMinor(),
+ $user,
+ $summary->text, // TODO: pass object when that becomes possible
+ $oldid,
+ $newRevisionRecord->getTimestamp(),
+ ( $flags & EDIT_FORCE_BOT ) > 0,
+ '',
+ $oldRev->getSize(),
+ $newRevisionRecord->getSize(),
+ $newRevisionRecord->getId(),
+ $this->rcPatrolStatus,
+ $tags
+ );
+ }
+
+ $user->incEditCount();
+
+ $dbw->endAtomic( __METHOD__ );
+ } else {
+ // T34948: revision ID must be set to page {{REVISIONID}} and
+ // related variables correctly. Likewise for {{REVISIONUSER}} (T135261).
+ // Since we don't insert a new revision into the database, the least
+ // error-prone way is to reuse given old revision.
+ $newRevisionRecord = $oldRev;
+ $newLegacyRevision = new Revision( $newRevisionRecord );
+ }
+
+ if ( $changed ) {
+ // Return the new revision to the caller
+ $status->value['revision-record'] = $newRevisionRecord;
+
+ // TODO: globally replace usages of 'revision' with getNewRevision()
+ $status->value['revision'] = $newLegacyRevision;
+ } else {
+ $status->warning( 'edit-no-change' );
+ // Update page_touched as updateRevisionOn() was not called.
+ // Other cache updates are managed in WikiPage::onArticleEdit()
+ // via WikiPage::doEditUpdates().
+ $this->getTitle()->invalidateCache( $now );
+ }
+
+ // Do secondary updates once the main changes have been committed...
+ // NOTE: the updates have to be processed before sending the response to the client
+ // (DeferredUpdates::PRESEND), otherwise the client may already be following the
+ // HTTP redirect to the standard view before dervide data has been created - most
+ // importantly, before the parser cache has been updated. This would cause the
+ // content to be parsed a second time, or may cause stale content to be shown.
+ DeferredUpdates::addUpdate(
+ new AtomicSectionUpdate(
+ $dbw,
+ __METHOD__,
+ function () use (
+ $wikiPage, $newRevisionRecord, $newLegacyRevision, $user, $mainContent,
+ $summary, $flags, $changed, $status
+ ) {
+ // Update links tables, site stats, etc.
+ $this->derivedDataUpdater->prepareUpdate(
+ $newRevisionRecord,
+ [
+ 'changed' => $changed,
+ ]
+ );
+ $this->derivedDataUpdater->doUpdates();
+
+ // Trigger post-save hook
+ // TODO: replace legacy hook!
+ // TODO: avoid pass-by-reference, see T193950
+ $params = [ &$wikiPage, &$user, $mainContent, $summary->text, $flags & EDIT_MINOR,
+ null, null, &$flags, $newLegacyRevision, &$status, $this->baseRevId,
+ $this->undidRevId ];
+ Hooks::run( 'PageContentSaveComplete', $params );
+ }
+ ),
+ DeferredUpdates::PRESEND
+ );
+
+ return $status;
+ }
+
+ /**
+ * @param CommentStoreComment $summary The edit summary
+ * @param User $user The revision's author
+ * @param int $flags EXIT_XXX constants
+ *
+ * @throws DBUnexpectedError
+ * @throws MWException
+ * @return Status
+ */
+ private function doCreate( CommentStoreComment $summary, User $user, $flags ) {
+ $wikiPage = $this->getWikiPage(); // TODO: use for legacy hooks only!
+
+ if ( !$this->derivedDataUpdater->getSlots()->hasSlot( 'main' ) ) {
+ throw new PageUpdateException( 'Must provide a main slot when creating a page!' );
+ }
+
+ $status = Status::newGood( [ 'new' => true, 'revision' => null, 'revision-record' => null ] );
+
+ $now = $this->getTimestampNow();
+
+ $newRevisionRecord = $this->makeNewRevision(
+ $summary,
+ $user,
+ $now,
+ $flags,
+ $status
+ );
+
+ if ( !$status->isOK() ) {
+ return $status;
+ }
+
+ $dbw = $this->getDBConnectionRef( DB_MASTER );
+ $dbw->startAtomic( __METHOD__ );
+
+ // Add the page record unless one already exists for the title
+ // TODO: move to storage service
+ $newid = $wikiPage->insertOn( $dbw );
+ if ( $newid === false ) {
+ $dbw->endAtomic( __METHOD__ ); // nothing inserted
+ $status->fatal( 'edit-already-exists' );
+
+ return $status; // nothing done
+ }
+
+ // At this point we are now comitted to returning an OK
+ // status unless some DB query error or other exception comes up.
+ // This way callers don't have to call rollback() if $status is bad
+ // unless they actually try to catch exceptions (which is rare).
+ $newRevisionRecord->setPageId( $newid );
+
+ // Save the revision text...
+ $newRevisionRecord = $this->revisionStore->insertRevisionOn( $newRevisionRecord, $dbw );
+ $newLegacyRevision = new Revision( $newRevisionRecord );
+
+ // Update the page record with revision data
+ // TODO: move to storage service
+ if ( !$wikiPage->updateRevisionOn( $dbw, $newLegacyRevision, 0 ) ) {
+ throw new PageUpdateException( "Failed to update page row to use new revision." );
+ }
+
+ // TODO: replace legacy hook!
+ $tags = $this->computeEffectiveTags( $flags );
+ Hooks::run(
+ 'NewRevisionFromEditComplete',
+ [ $wikiPage, $newLegacyRevision, false, $user, &$tags ]
+ );
+
+ // Update recentchanges
+ if ( !( $flags & EDIT_SUPPRESS_RC ) ) {
+ // Add RC row to the DB
+ RecentChange::notifyNew(
+ $now,
+ $this->getTitle(),
+ $newRevisionRecord->isMinor(),
+ $user,
+ $summary->text, // TODO: pass object when that becomes possible
+ ( $flags & EDIT_FORCE_BOT ) > 0,
+ '',
+ $newRevisionRecord->getSize(),
+ $newRevisionRecord->getId(),
+ $this->rcPatrolStatus,
+ $tags
+ );
+ }
+
+ $user->incEditCount();
+
+ if ( $this->usePageCreationLog ) {
+ // Log the page creation
+ // @TODO: Do we want a 'recreate' action?
+ $logEntry = new ManualLogEntry( 'create', 'create' );
+ $logEntry->setPerformer( $user );
+ $logEntry->setTarget( $this->getTitle() );
+ $logEntry->setComment( $summary->text );
+ $logEntry->setTimestamp( $now );
+ $logEntry->setAssociatedRevId( $newRevisionRecord->getId() );
+ $logEntry->insert();
+ // Note that we don't publish page creation events to recentchanges
+ // (i.e. $logEntry->publish()) since this would create duplicate entries,
+ // one for the edit and one for the page creation.
+ }
+
+ $dbw->endAtomic( __METHOD__ );
+
+ // Return the new revision to the caller
+ // TODO: globally replace usages of 'revision' with getNewRevision()
+ $status->value['revision'] = $newLegacyRevision;
+ $status->value['revision-record'] = $newRevisionRecord;
+
+ // XXX: make sure we are not loading the Content from the DB
+ $mainContent = $newRevisionRecord->getContent( 'main' );
+
+ // Do secondary updates once the main changes have been committed...
+ DeferredUpdates::addUpdate(
+ new AtomicSectionUpdate(
+ $dbw,
+ __METHOD__,
+ function () use (
+ $wikiPage,
+ $newRevisionRecord,
+ $newLegacyRevision,
+ $user,
+ $mainContent,
+ $summary,
+ $flags,
+ $status
+ ) {
+ // Update links, etc.
+ $this->derivedDataUpdater->prepareUpdate(
+ $newRevisionRecord,
+ [ 'created' => true ]
+ );
+ $this->derivedDataUpdater->doUpdates();
+
+ // Trigger post-create hook
+ // TODO: replace legacy hook!
+ // TODO: avoid pass-by-reference, see T193950
+ $params = [ &$wikiPage, &$user, $mainContent, $summary->text,
+ $flags & EDIT_MINOR, null, null, &$flags, $newLegacyRevision ];
+ Hooks::run( 'PageContentInsertComplete', $params );
+ // Trigger post-save hook
+ // TODO: replace legacy hook!
+ $params = array_merge( $params, [ &$status, $this->baseRevId, 0 ] );
+ Hooks::run( 'PageContentSaveComplete', $params );
+ }
+ ),
+ DeferredUpdates::PRESEND
+ );
+
+ return $status;
+ }
+
+}
return $this->mSlots->getSlotRoles();
}
+ /**
+ * Returns the slots defined for this revision.
+ *
+ * @return RevisionSlots
+ */
+ public function getSlots() {
+ return $this->mSlots;
+ }
+
+ /**
+ * Returns the slots that originate in this revision.
+ *
+ * Note that this does not include any slots inherited from some earlier revision,
+ * even if they are different from the slots in the immediate parent revision.
+ * This is the case for rollbacks: slots of a rollback revision are inherited from
+ * the rollback target, and are different from the slots in the parent revision,
+ * which was rolled back.
+ *
+ * To find all slots modified by this revision against its immediate parent
+ * revision, use RevisionSlotsUpdate::newFromRevisionSlots().
+ *
+ * @return RevisionSlots
+ */
+ public function getOriginalSlots() {
+ return new RevisionSlots( $this->mSlots->getOriginalSlots() );
+ }
+
+ /**
+ * Returns slots inherited from some previous revision.
+ *
+ * "Inherited" slots are all slots that do not originate in this revision.
+ * Note that these slots may still differ from the one in the parent revision.
+ * This is the case for rollbacks: slots of a rollback revision are inherited from
+ * the rollback target, and are different from the slots in the parent revision,
+ * which was rolled back.
+ *
+ * @return RevisionSlots
+ */
+ public function getInheritedSlots() {
+ return new RevisionSlots( $this->mSlots->getInheritedSlots() );
+ }
+
/**
* 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
}
/**
- * Return all slots that are not inherited.
+ * Return all slots that belong to the revision they originate from (that is,
+ * they are not inherited from some other revision).
*
* @note This may cause the slot meta-data for the revision to be lazy-loaded.
*
* @return SlotRecord[]
*/
- public function getTouchedSlots() {
+ public function getOriginalSlots() {
return array_filter(
$this->getSlots(),
function ( SlotRecord $slot ) {
}
/**
- * Return all slots that are inherited.
+ * Return all slots that are not not originate in the revision they belong to (that is,
+ * they are inherited from some other revision).
*
* @note This may cause the slot meta-data for the revision to be lazy-loaded.
*
return new RevisionSlotsUpdate( $modified, $removed );
}
+ /**
+ * Constructs a RevisionSlotsUpdate representing the update of $parentSlots
+ * when changing $newContent. If a slot has the same content in $newContent
+ * as in $parentSlots, that slot is considered inherited and thus omitted from
+ * the resulting RevisionSlotsUpdate.
+ *
+ * In contrast to newFromRevisionSlots(), slots in $parentSlots that are not present
+ * in $newContent are not considered removed. They are instead assumed to be inherited.
+ *
+ * @param Content[] $newContent The new content, using slot roles as array keys.
+ *
+ * @return RevisionSlotsUpdate
+ */
+ public static function newFromContent( array $newContent, RevisionSlots $parentSlots = null ) {
+ $modified = [];
+
+ foreach ( $newContent as $role => $content ) {
+ $slot = SlotRecord::newUnsaved( $role, $content );
+
+ if ( $parentSlots
+ && $parentSlots->hasSlot( $role )
+ && $slot->hasSameContent( $parentSlots->getSlot( $role ) )
+ ) {
+ // Skip slots that had the same content in the parent revision from $modified.
+ continue;
+ }
+
+ $modified[$role] = $slot;
+ }
+
+ return new RevisionSlotsUpdate( $modified );
+ }
+
/**
* @param SlotRecord[] $modifiedSlots
* @param string[] $removedRoles
* Returns a list of modified slot roles, that is, roles modified by calling modifySlot(),
* and not later removed by calling removeSlot().
*
+ * Note that slots in modified roles may still be inherited slots. This is for instance
+ * the case when the RevisionSlotsUpdate objects represents some kind of rollback
+ * operation, in which slots that existed in an earlier revision are restored in
+ * a new revision.
+ *
* @return string[]
*/
public function getModifiedRoles() {
return true;
}
+ /**
+ * Applies this update to the given MutableRevisionSlots, setting all modified slots,
+ * and removing all removed roles.
+ *
+ * @param MutableRevisionSlots $slots
+ */
+ public function apply( MutableRevisionSlots $slots ) {
+ foreach ( $this->getModifiedRoles() as $role ) {
+ $slots->setSlot( $this->getModifiedSlot( $role ) );
+ }
+
+ foreach ( $this->getRemovedRoles() as $role ) {
+ $slots->removeSlot( $role );
+ }
+ }
+
}
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use RecentChange;
+use Revision;
use stdClass;
use Title;
use User;
class RevisionStore
implements IDBAccessObject, RevisionFactory, RevisionLookup, LoggerAwareInterface {
+ const ROW_CACHE_KEY = 'revision-row-1.29';
+
/**
* @var SqlBlobStore
*/
*/
private $logger;
+ /**
+ * @var NameTableStore
+ */
+ private $contentModelStore;
+
+ /**
+ * @var NameTableStore
+ */
+ private $slotRoleStore;
+
+ /** @var int One of the MIGRATION_* constants */
+ private $mcrMigrationStage;
+
/**
* @todo $blobStore should be allowed to be any BlobStore!
*
* @param SqlBlobStore $blobStore
* @param WANObjectCache $cache
* @param CommentStore $commentStore
+ * @param NameTableStore $contentModelStore
+ * @param NameTableStore $slotRoleStore
+ * @param int $migrationStage
* @param ActorMigration $actorMigration
* @param bool|string $wikiId
*/
SqlBlobStore $blobStore,
WANObjectCache $cache,
CommentStore $commentStore,
+ NameTableStore $contentModelStore,
+ NameTableStore $slotRoleStore,
+ $migrationStage,
ActorMigration $actorMigration,
$wikiId = false
) {
Assert::parameterType( 'string|boolean', $wikiId, '$wikiId' );
+ Assert::parameterType( 'integer', $migrationStage, '$migrationStage' );
$this->loadBalancer = $loadBalancer;
$this->blobStore = $blobStore;
$this->cache = $cache;
$this->commentStore = $commentStore;
+ $this->contentModelStore = $contentModelStore;
+ $this->slotRoleStore = $slotRoleStore;
+ $this->mcrMigrationStage = $migrationStage;
$this->actorMigration = $actorMigration;
$this->wikiId = $wikiId;
$this->logger = new NullLogger();
/**
* @param bool $contentHandlerUseDB
+ * @throws MWException
*/
public function setContentHandlerUseDB( $contentHandlerUseDB ) {
+ if ( !$contentHandlerUseDB && $this->mcrMigrationStage > MIGRATION_OLD ) {
+ throw new MWException(
+ 'Content model must be stored in the database for multi content revision migration.'
+ );
+ }
$this->contentHandlerUseDB = $contentHandlerUseDB;
}
// 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!' );
+ $slotRoles = $rev->getSlotRoles();
+
+ // Make sure the main slot is always provided throughout migration
+ if ( !in_array( 'main', $slotRoles ) ) {
+ throw new InvalidArgumentException(
+ 'main slot must be provided'
+ );
}
- if ( $rev->getSlotRoles() !== [ 'main' ] ) {
- throw new InvalidArgumentException( 'Only the main slot is supported for now!' );
+ // While inserting into the old schema make sure only the main slot is allowed.
+ // TODO: support extra slots in MIGRATION_WRITE_BOTH mode!
+ if ( $this->mcrMigrationStage <= MIGRATION_WRITE_BOTH && $slotRoles !== [ 'main' ] ) {
+ throw new InvalidArgumentException(
+ 'Only the main slot is supported with MCR migration mode <= MIGRATION_WRITE_BOTH!'
+ );
}
+ // Checks
+ $this->failOnNull( $rev->getSize(), 'size field' );
+ $this->failOnEmpty( $rev->getSha1(), 'sha1 field' );
+ $this->failOnEmpty( $rev->getTimestamp(), 'timestamp field' );
+ $comment = $this->failOnNull( $rev->getComment( RevisionRecord::RAW ), 'comment' );
+ $user = $this->failOnNull( $rev->getUser( RevisionRecord::RAW ), 'user' );
+ $this->failOnNull( $user->getId(), 'user field' );
+ $this->failOnEmpty( $user->getName(), 'user_text field' );
+
// 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
? $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,
- ];
+ /** @var RevisionRecord $rev */
+ $rev = $dbw->doAtomicSection(
+ __METHOD__,
+ function ( IDatabase $dbw, $fname ) use (
+ $rev,
+ $user,
+ $comment,
+ $title,
+ $pageId,
+ $parentId
+ ) {
+ return $this->insertRevisionInternal(
+ $rev,
+ $dbw,
+ $user,
+ $comment,
+ $title,
+ $pageId,
+ $parentId
+ );
+ }
+ );
- $blobAddress = $this->blobStore->storeBlob( $data, $blobHints );
- } else {
- $blobAddress = $slot->getAddress();
- $model = $slot->getModel();
- $format = $slot->getFormat();
+ // 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'
+ );
+
+ // Trigger exception if the main slot is missing.
+ // Technically, this could go away with MIGRATION_NEW: while
+ // calling code may require a main slot to exist, RevisionStore
+ // really should not know or care about that requirement.
+ $rev->getSlot( 'main', RevisionRecord::RAW );
+
+ foreach ( $slotRoles as $role ) {
+ $slot = $rev->getSlot( $role, RevisionRecord::RAW );
+ Assert::postcondition(
+ $slot->getContent() !== null,
+ $role . ' slot must have content'
+ );
+ Assert::postcondition(
+ $slot->hasRevision(),
+ $role . ' slot must have a revision associated'
+ );
}
- $textId = $this->blobStore->getTextIdFromAddress( $blobAddress );
+ Hooks::run( 'RevisionRecordInserted', [ $rev ] );
+ // TODO: deprecate in 1.32!
+ $legacyRevision = new Revision( $rev );
+ Hooks::run( 'RevisionInsertComplete', [ &$legacyRevision, null, null ] );
+
+ return $rev;
+ }
+
+ private function insertRevisionInternal(
+ RevisionRecord $rev,
+ IDatabase $dbw,
+ User $user,
+ CommentStoreComment $comment,
+ Title $title,
+ $pageId,
+ $parentId
+ ) {
+ $slotRoles = $rev->getSlotRoles();
+
+ $revisionRow = $this->insertRevisionRowOn(
+ $dbw,
+ $rev,
+ $title,
+ $parentId
+ );
+
+ $revisionId = $revisionRow['rev_id'];
+
+ $blobHints = [
+ BlobStore::PAGE_HINT => $pageId,
+ BlobStore::REVISION_HINT => $revisionId,
+ BlobStore::PARENT_HINT => $parentId,
+ ];
+
+ $newSlots = [];
+ foreach ( $slotRoles as $role ) {
+ $slot = $rev->getSlot( $role, RevisionRecord::RAW );
+
+ if ( $slot->hasRevision() ) {
+ // If the SlotRecord already has a revision ID set, this means it already exists
+ // in the database, and should already belong to the current revision.
+ // TODO: properly abort transaction if the assertion fails!
+ Assert::parameter(
+ $slot->getRevision() === $revisionId,
+ 'slot role ' . $slot->getRole(),
+ 'Existing slot should belong to revision '
+ . $revisionId . ', but belongs to revision ' . $slot->getRevision() . '!'
+ );
+
+ // Slot exists, nothing to do, move along.
+ // This happens when restoring archived revisions.
+
+ $newSlots[$role] = $slot;
+
+ // Write the main slot's text ID to the revision table for backwards compatibility
+ if ( $slot->getRole() === 'main' && $this->mcrMigrationStage <= MIGRATION_WRITE_BOTH ) {
+ $blobAddress = $slot->getAddress();
+ $this->updateRevisionTextId( $dbw, $revisionId, $blobAddress );
+ }
+ } else {
+ $newSlots[$role] = $this->insertSlotOn( $dbw, $revisionId, $slot, $title, $blobHints );
+ }
+ }
+
+ $this->insertIpChangesRow( $dbw, $user, $rev, $revisionId );
+
+ $rev = new RevisionStoreRecord(
+ $title,
+ $user,
+ $comment,
+ (object)$revisionRow,
+ new RevisionSlots( $newSlots ),
+ $this->wikiId
+ );
+
+ return $rev;
+ }
+
+ /**
+ * @param IDatabase $dbw
+ * @param int $revisionId
+ * @param string &$blobAddress (may change!)
+ */
+ private function updateRevisionTextId( IDatabase $dbw, $revisionId, &$blobAddress ) {
+ $textId = $this->blobStore->getTextIdFromAddress( $blobAddress );
if ( !$textId ) {
throw new LogicException(
'Blob address not supported in 1.29 database schema: ' . $blobAddress
// may be a new value, not anything already contained in $blobAddress.
$blobAddress = SqlBlobStore::makeAddressFromTextId( $textId );
- $comment = $this->failOnNull( $rev->getComment( RevisionRecord::RAW ), 'comment' );
- $user = $this->failOnNull( $rev->getUser( RevisionRecord::RAW ), 'user' );
- $timestamp = $this->failOnEmpty( $rev->getTimestamp(), 'timestamp field' );
-
- // Checks.
- $this->failOnNull( $user->getId(), 'user field' );
- $this->failOnEmpty( $user->getName(), 'user_text 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_timestamp' => $dbw->timestamp( $timestamp ),
- 'rev_deleted' => $rev->getVisibility(),
- 'rev_len' => $size,
- 'rev_sha1' => $sha1,
- ];
+ $dbw->update(
+ 'revision',
+ [ 'rev_text_id' => $textId ],
+ [ 'rev_id' => $revisionId ],
+ __METHOD__
+ );
+ }
- if ( $rev->getId() !== null ) {
- // Needed to restore revisions with their original ID
- $row['rev_id'] = $rev->getId();
+ /**
+ * @param IDatabase $dbw
+ * @param int $revisionId
+ * @param SlotRecord $protoSlot
+ * @param Title $title
+ * @param array $blobHints See the BlobStore::XXX_HINT constants
+ * @return SlotRecord
+ */
+ private function insertSlotOn(
+ IDatabase $dbw,
+ $revisionId,
+ SlotRecord $protoSlot,
+ Title $title,
+ array $blobHints = []
+ ) {
+ if ( $protoSlot->hasAddress() ) {
+ $blobAddress = $protoSlot->getAddress();
+ } else {
+ $blobAddress = $this->storeContentBlob( $protoSlot, $title, $blobHints );
}
- list( $commentFields, $commentCallback ) =
- $this->commentStore->insertWithTempTable( $dbw, 'rev_comment', $comment );
- $row += $commentFields;
-
- list( $actorFields, $actorCallback ) =
- $this->actorMigration->getInsertValuesWithTempTable( $dbw, 'rev_user', $user );
- $row += $actorFields;
-
- if ( $this->contentHandlerUseDB ) {
- // MCR migration note: rev_content_model and rev_content_format will go away
+ // Write the main slot's text ID to the revision table for backwards compatibility
+ if ( $protoSlot->getRole() === 'main' && $this->mcrMigrationStage <= MIGRATION_WRITE_BOTH ) {
+ $this->updateRevisionTextId( $dbw, $revisionId, $blobAddress );
+ }
- $defaultModel = ContentHandler::getDefaultModelFor( $title );
- $defaultFormat = ContentHandler::getForModelID( $defaultModel )->getDefaultFormat();
+ if ( $this->mcrMigrationStage >= MIGRATION_WRITE_BOTH ) {
+ if ( $protoSlot->hasContentId() ) {
+ $contentId = $protoSlot->getContentId();
+ } else {
+ $contentId = $this->insertContentRowOn( $protoSlot, $dbw, $blobAddress );
+ }
- $row['rev_content_model'] = ( $model === $defaultModel ) ? null : $model;
- $row['rev_content_format'] = ( $format === $defaultFormat ) ? null : $format;
+ $this->insertSlotRowOn( $protoSlot, $dbw, $revisionId, $contentId );
+ } else {
+ $contentId = null;
}
- $dbw->insert( 'revision', $row, __METHOD__ );
+ $savedSlot = SlotRecord::newSaved(
+ $revisionId,
+ $contentId,
+ $blobAddress,
+ $protoSlot
+ );
- if ( !isset( $row['rev_id'] ) ) {
- // only if auto-increment was used
- $row['rev_id'] = intval( $dbw->insertId() );
- }
- $commentCallback( $row['rev_id'] );
- $actorCallback( $row['rev_id'], $row );
+ return $savedSlot;
+ }
- // Insert IP revision into ip_changes for use when querying for a range.
+ /**
+ * Insert IP revision into ip_changes for use when querying for a range.
+ * @param IDatabase $dbw
+ * @param User $user
+ * @param RevisionRecord $rev
+ * @param int $revisionId
+ */
+ private function insertIpChangesRow(
+ IDatabase $dbw,
+ User $user,
+ RevisionRecord $rev,
+ $revisionId
+ ) {
if ( $user->getId() === 0 && IP::isValid( $user->getName() ) ) {
$ipcRow = [
- 'ipc_rev_id' => $row['rev_id'],
- 'ipc_rev_timestamp' => $row['rev_timestamp'],
+ 'ipc_rev_id' => $revisionId,
+ 'ipc_rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ),
'ipc_hex' => IP::toHex( $user->getName() ),
];
$dbw->insert( 'ip_changes', $ipcRow, __METHOD__ );
}
+ }
- $newSlot = SlotRecord::newSaved( $row['rev_id'], $textId, $blobAddress, $slot );
- $slots = new RevisionSlots( [ 'main' => $newSlot ] );
+ /**
+ * @param IDatabase $dbw
+ * @param RevisionRecord $rev
+ * @param Title $title
+ * @param int $parentId
+ *
+ * @return array a revision table row
+ *
+ * @throws MWException
+ * @throws MWUnknownContentModelException
+ */
+ private function insertRevisionRowOn(
+ IDatabase $dbw,
+ RevisionRecord $rev,
+ Title $title,
+ $parentId
+ ) {
+ $revisionRow = $this->getBaseRevisionRow( $dbw, $rev, $title, $parentId );
- $rev = new RevisionStoreRecord(
- $title,
- $user,
- $comment,
- (object)$row,
- $slots,
- $this->wikiId
- );
+ list( $commentFields, $commentCallback ) =
+ $this->commentStore->insertWithTempTable(
+ $dbw,
+ 'rev_comment',
+ $rev->getComment( RevisionRecord::RAW )
+ );
+ $revisionRow += $commentFields;
- $newSlot = $rev->getSlot( 'main', RevisionRecord::RAW );
+ list( $actorFields, $actorCallback ) =
+ $this->actorMigration->getInsertValuesWithTempTable(
+ $dbw,
+ 'rev_user',
+ $rev->getUser( RevisionRecord::RAW )
+ );
+ $revisionRow += $actorFields;
- // 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'
- );
+ $dbw->insert( 'revision', $revisionRow, __METHOD__ );
- Assert::postcondition( $newSlot !== null, 'revision must have a main slot' );
- Assert::postcondition(
- $newSlot->getAddress() !== null,
- 'main slot must have an addess'
+ if ( !isset( $revisionRow['rev_id'] ) ) {
+ // only if auto-increment was used
+ $revisionRow['rev_id'] = intval( $dbw->insertId() );
+ }
+
+ $commentCallback( $revisionRow['rev_id'] );
+ $actorCallback( $revisionRow['rev_id'], $revisionRow );
+
+ return $revisionRow;
+ }
+
+ /**
+ * @param IDatabase $dbw
+ * @param RevisionRecord $rev
+ * @param Title $title
+ * @param int $parentId
+ *
+ * @return array [ 0 => array $revisionRow, 1 => callable ]
+ * @throws MWException
+ * @throws MWUnknownContentModelException
+ */
+ private function getBaseRevisionRow(
+ IDatabase $dbw,
+ RevisionRecord $rev,
+ Title $title,
+ $parentId
+ ) {
+ // Record the edit in revisions
+ $revisionRow = [
+ 'rev_page' => $rev->getPageId(),
+ 'rev_parent_id' => $parentId,
+ 'rev_minor_edit' => $rev->isMinor() ? 1 : 0,
+ 'rev_timestamp' => $dbw->timestamp( $rev->getTimestamp() ),
+ 'rev_deleted' => $rev->getVisibility(),
+ 'rev_len' => $rev->getSize(),
+ 'rev_sha1' => $rev->getSha1(),
+ ];
+
+ if ( $rev->getId() !== null ) {
+ // Needed to restore revisions with their original ID
+ $revisionRow['rev_id'] = $rev->getId();
+ }
+
+ if ( $this->mcrMigrationStage <= MIGRATION_WRITE_BOTH ) {
+ // In non MCR more this IF section will relate to the main slot
+ $mainSlot = $rev->getSlot( 'main' );
+ $model = $mainSlot->getModel();
+ $format = $mainSlot->getFormat();
+
+ // MCR migration note: rev_content_model and rev_content_format will go away
+ if ( $this->contentHandlerUseDB ) {
+ $defaultModel = ContentHandler::getDefaultModelFor( $title );
+ $defaultFormat = ContentHandler::getForModelID( $defaultModel )->getDefaultFormat();
+
+ $revisionRow['rev_content_model'] = ( $model === $defaultModel ) ? null : $model;
+ $revisionRow['rev_content_format'] = ( $format === $defaultFormat ) ? null : $format;
+ }
+ }
+
+ return $revisionRow;
+ }
+
+ /**
+ * @param SlotRecord $slot
+ * @param Title $title
+ * @param array $blobHints See the BlobStore::XXX_HINT constants
+ *
+ * @throws MWException
+ * @return string the blob address
+ */
+ private function storeContentBlob(
+ SlotRecord $slot,
+ Title $title,
+ array $blobHints = []
+ ) {
+ $content = $slot->getContent();
+ $format = $content->getDefaultFormat();
+ $model = $content->getModel();
+
+ $this->checkContent( $content, $title );
+
+ return $this->blobStore->storeBlob(
+ $content->serialize( $format ),
+ // These hints "leak" some information from the higher abstraction layer to
+ // low level storage to allow for optimization.
+ array_merge(
+ $blobHints,
+ [
+ BlobStore::DESIGNATION_HINT => 'page-content',
+ BlobStore::ROLE_HINT => $slot->getRole(),
+ BlobStore::SHA1_HINT => $slot->getSha1(),
+ BlobStore::MODEL_HINT => $model,
+ BlobStore::FORMAT_HINT => $format,
+ ]
+ )
);
+ }
- Hooks::run( 'RevisionRecordInserted', [ $rev ] );
+ /**
+ * @param SlotRecord $slot
+ * @param IDatabase $dbw
+ * @param int $revisionId
+ * @param int $contentId
+ */
+ private function insertSlotRowOn( SlotRecord $slot, IDatabase $dbw, $revisionId, $contentId ) {
+ $slotRow = [
+ 'slot_revision_id' => $revisionId,
+ 'slot_role_id' => $this->slotRoleStore->acquireId( $slot->getRole() ),
+ 'slot_content_id' => $contentId,
+ // If the slot has a specific origin use that ID, otherwise use the ID of the revision
+ // that we just inserted.
+ 'slot_origin' => $slot->hasOrigin() ? $slot->getOrigin() : $revisionId,
+ ];
+ $dbw->insert( 'slots', $slotRow, __METHOD__ );
+ }
- return $rev;
+ /**
+ * @param SlotRecord $slot
+ * @param IDatabase $dbw
+ * @param string $blobAddress
+ * @return int content row ID
+ */
+ private function insertContentRowOn( SlotRecord $slot, IDatabase $dbw, $blobAddress ) {
+ $contentRow = [
+ 'content_size' => $slot->getSize(),
+ 'content_sha1' => $slot->getSha1(),
+ 'content_model' => $this->contentModelStore->acquireId( $slot->getModel() ),
+ 'content_address' => $blobAddress,
+ ];
+ $dbw->insert( 'content', $contentRow, __METHOD__ );
+ return intval( $dbw->insertId() );
}
/**
* @throws MWException
* @throws MWUnknownContentModelException
*/
- private function checkContentModel( Content $content, Title $title ) {
+ private function checkContent( Content $content, Title $title ) {
// Note: may return null for revisions that have not yet been inserted
$model = $content->getModel();
* Such revisions can for instance identify page rename
* operations and other such meta-modifications.
*
+ * @note: This method grabs a FOR UPDATE lock on the relevant row of the page table,
+ * to prevent a new revision from being inserted before the null revision has been written
+ * to the database.
+ *
* MCR migration note: this replaces Revision::newNullRevision
*
* @todo Introduce newFromParentRevision(). newNullRevision can then be based on that
* (or go away).
*
- * @param IDatabase $dbw
+ * @param IDatabase $dbw used for obtaining the lock on the page table row
* @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(
) {
$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',
- ],
+ // T51581: Lock the page table row to ensure no other process
+ // is adding a revision to the page at the same time.
+ // Avoid locking extra tables, compare T191892.
+ $pageLatest = $dbw->selectField(
+ 'page',
+ 'page_latest',
+ [ 'page_id' => $title->getArticleID() ],
__METHOD__,
- [ 'FOR UPDATE' ] // T51581
+ [ 'FOR UPDATE' ]
);
- if ( $current ) {
- $fields = [
- 'page' => $title->getArticleID(),
- 'user_text' => $user->getName(),
- 'user' => $user->getId(),
- 'actor' => $user->getActorId(),
- 'comment' => $comment,
- 'minor_edit' => $minor,
- 'text_id' => $current->rev_text_id,
- 'parent_id' => $current->page_latest,
- 'slot_origin' => $current->page_latest,
- 'len' => $current->rev_len,
- 'sha1' => $current->rev_sha1
- ];
+ if ( !$pageLatest ) {
+ return null;
+ }
- if ( $this->contentHandlerUseDB ) {
- $fields['content_model'] = $current->rev_content_model;
- $fields['content_format'] = $current->rev_content_format;
- }
+ // Fetch the actual revision row, without locking all extra tables.
+ $oldRevision = $this->loadRevisionFromId( $dbw, $pageLatest );
- $fields['title'] = Title::makeTitle( $current->page_namespace, $current->page_title );
+ // Construct the new revision
+ $timestamp = wfTimestampNow(); // TODO: use a callback, so we can override it for testing.
+ $newRevision = MutableRevisionRecord::newFromParentRevision( $oldRevision );
- $mainSlot = $this->emulateMainSlot_1_29( $fields, self::READ_LATEST, $title );
- $revision = new MutableRevisionRecord( $title, $this->wikiId );
- $this->initializeMutableRevisionFromArray( $revision, $fields );
- $revision->setSlot( $mainSlot );
- } else {
- $revision = null;
- }
+ $newRevision->setComment( $comment );
+ $newRevision->setUser( $user );
+ $newRevision->setTimestamp( $timestamp );
+ $newRevision->setMinorEdit( $minor );
- return $revision;
+ return $newRevision;
}
/**
$mainSlotRow->model_name = null;
$mainSlotRow->slot_revision_id = null;
$mainSlotRow->content_address = null;
- $mainSlotRow->slot_content_id = null;
$content = null;
$blobData = null;
$blobFlags = null;
if ( is_object( $row ) ) {
+ if ( $this->mcrMigrationStage >= MIGRATION_NEW ) {
+ // Don't emulate from a row when using the new schema.
+ // Emulating from an array is still OK.
+ throw new LogicException( 'Can\'t emulate the main slot when using MCR schema.' );
+ }
+
// archive row
if ( !isset( $row->rev_id ) && ( isset( $row->ar_user ) || isset( $row->ar_actor ) ) ) {
$row = $this->mapArchiveFields( $row );
}
if ( isset( $row->rev_text_id ) && $row->rev_text_id > 0 ) {
- $mainSlotRow->slot_content_id = $row->rev_text_id;
$mainSlotRow->content_address = SqlBlobStore::makeAddressFromTextId(
$row->rev_text_id
);
} elseif ( is_array( $row ) ) {
$mainSlotRow->slot_revision_id = isset( $row['id'] ) ? intval( $row['id'] ) : null;
- $mainSlotRow->slot_content_id = isset( $row['text_id'] )
- ? intval( $row['text_id'] )
- : null;
$mainSlotRow->slot_origin = isset( $row['slot_origin'] )
? intval( $row['slot_origin'] )
: null;
}
if ( !$content ) {
+ // XXX: We should perhaps fail if $blobData is null and $mainSlotRow->content_address
+ // is missing, but "empty revisions" with no content are used in some edge cases.
+
$content = function ( SlotRecord $slot )
use ( $blobData, $blobFlags, $queryFlags, $mainSlotRow )
{
};
}
- $mainSlotRow->slot_id = $mainSlotRow->slot_revision_id;
+ // NOTE: this callback will be looped through RevisionSlot::newInherited(), allowing
+ // the inherited slot to have the same content_id as the original slot. In that case,
+ // $slot will be the inherited slot, while $mainSlotRow still refers to the original slot.
+ $mainSlotRow->slot_content_id =
+ function ( SlotRecord $slot ) use ( $queryFlags, $mainSlotRow ) {
+ list( $dbMode, ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
+ $db = $this->getDBConnectionRef( $dbMode );
+ return $this->findSlotContentId( $db, $mainSlotRow->slot_revision_id, 'main' );
+ };
+
return new SlotRecord( $mainSlotRow, $content );
}
);
}
+ /**
+ * @param int $revId The revision to load slots for.
+ * @param int $queryFlags
+ *
+ * @return SlotRecord[]
+ */
+ private function loadSlotRecords( $revId, $queryFlags ) {
+ $revQuery = self::getSlotsQueryInfo( [ 'content' ] );
+
+ list( $dbMode, $dbOptions ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
+ $db = $this->getDBConnectionRef( $dbMode );
+
+ $res = $db->select(
+ $revQuery['tables'],
+ $revQuery['fields'],
+ [
+ 'slot_revision_id' => $revId,
+ ],
+ __METHOD__,
+ $dbOptions,
+ $revQuery['joins']
+ );
+
+ $slots = [];
+
+ foreach ( $res as $row ) {
+ $contentCallback = function ( SlotRecord $slot ) use ( $queryFlags, $row ) {
+ return $this->loadSlotContent( $slot, null, null, null, $queryFlags );
+ };
+
+ $slots[$row->role_name] = new SlotRecord( $row, $contentCallback );
+ }
+
+ if ( !isset( $slots['main'] ) ) {
+ throw new RevisionAccessException(
+ 'Main slot of revision ' . $revId . ' not found in database!'
+ );
+ };
+
+ return $slots;
+ }
+
+ /**
+ * Factory method for RevisionSlots.
+ *
+ * @note If other code has a need to construct RevisionSlots objects, this should be made
+ * public, since RevisionSlots instances should not be constructed directly.
+ *
+ * @param int $revId
+ * @param object $revisionRow
+ * @param int $queryFlags
+ * @param Title $title
+ *
+ * @return RevisionSlots
+ * @throws MWException
+ */
+ private function newRevisionSlots(
+ $revId,
+ $revisionRow,
+ $queryFlags,
+ Title $title
+ ) {
+ if ( $this->mcrMigrationStage < MIGRATION_NEW ) {
+ // TODO: in MIGRATION_WRITE_BOTH, we could use the old and the new method:
+ // e.g. call emulateMainSlot_1_29() if loadSlotRecords() fails.
+
+ $mainSlot = $this->emulateMainSlot_1_29( $revisionRow, $queryFlags, $title );
+ $slots = new RevisionSlots( [ 'main' => $mainSlot ] );
+ } else {
+ // XXX: do we need the same kind of caching here
+ // that getKnownCurrentRevision uses (if $revId == page_latest?)
+
+ $slots = new RevisionSlots( function () use( $revId, $queryFlags ) {
+ return $this->loadSlotRecords( $revId, $queryFlags );
+ } );
+ }
+
+ return $slots;
+ }
+
/**
* Make a fake revision object from an archive table row. This is queried
* for permissions or even inserted (as in Special:Undelete)
// Legacy because $row may have come from self::selectFields()
->getCommentLegacy( $this->getDBConnection( DB_REPLICA ), 'ar_comment', $row, true );
- $mainSlot = $this->emulateMainSlot_1_29( $row, $queryFlags, $title );
- $slots = new RevisionSlots( [ 'main' => $mainSlot ] );
+ $slots = $this->newRevisionSlots( $row->ar_rev_id, $row, $queryFlags, $title );
return new RevisionArchiveRecord( $title, $user, $comment, $row, $slots, $this->wikiId );
}
/**
- * @see RevisionFactory::newRevisionFromRow_1_29
+ * @see RevisionFactory::newRevisionFromRow
*
* MCR migration note: this replaces Revision::newFromRow
*
* @param Title|null $title
*
* @return RevisionRecord
- * @throws MWException
- * @throws RevisionAccessException
*/
- private function newRevisionFromRow_1_29( $row, $queryFlags = 0, Title $title = null ) {
+ public function newRevisionFromRow( $row, $queryFlags = 0, Title $title = null ) {
Assert::parameterType( 'object', $row, '$row' );
if ( !$title ) {
// Legacy because $row may have come from self::selectFields()
->getCommentLegacy( $this->getDBConnection( DB_REPLICA ), 'rev_comment', $row, true );
- $mainSlot = $this->emulateMainSlot_1_29( $row, $queryFlags, $title );
- $slots = new RevisionSlots( [ 'main' => $mainSlot ] );
+ $slots = $this->newRevisionSlots( $row->rev_id, $row, $queryFlags, $title );
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.
// 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 ( !( $fields['content'] instanceof Content ) && !is_array( $fields['content'] ) ) {
+ throw new MWException(
+ 'content field must contain a Content object or an array of Content objects.'
+ );
}
+ }
- if ( !empty( $fields['text_id'] ) ) {
+ if ( !empty( $fields['text_id'] ) ) {
+ if ( $this->mcrMigrationStage >= MIGRATION_NEW ) {
+ throw new MWException( "Cannot use text_id field with MCR schema" );
+ }
+
+ if ( !empty( $fields['content'] ) ) {
throw new MWException(
"Text already stored in external store (id {$fields['text_id']}), " .
- "can't serialize content object"
+ "can't specify content object"
);
}
}
}
}
- $mainSlot = $this->emulateMainSlot_1_29( $fields, $queryFlags, $title );
-
$revision = new MutableRevisionRecord( $title, $this->wikiId );
$this->initializeMutableRevisionFromArray( $revision, $fields );
- $revision->setSlot( $mainSlot );
+
+ if ( isset( $fields['content'] ) && is_array( $fields['content'] ) ) {
+ foreach ( $fields['content'] as $role => $content ) {
+ $revision->setContent( $role, $content );
+ }
+ } else {
+ $mainSlot = $this->emulateMainSlot_1_29( $fields, $queryFlags, $title );
+ $revision->setSlot( $mainSlot );
+ }
return $revision;
}
private function fetchRevisionRowFromConds( IDatabase $db, $conditions, $flags = 0 ) {
$this->checkDatabaseWikiId( $db );
- $revQuery = self::getQueryInfo( [ 'page', 'user' ] );
+ $revQuery = $this->getQueryInfo( [ 'page', 'user' ] );
$options = [];
if ( ( $flags & self::READ_LOCKING ) == self::READ_LOCKING ) {
$options[] = 'FOR UPDATE';
);
}
+ /**
+ * Finds the ID of a content row for a given revision and slot role.
+ * This can be used to re-use content rows even while the content ID
+ * is still missing from SlotRecords, in MIGRATION_WRITE_BOTH mode.
+ *
+ * @todo remove after MCR schema migration is complete.
+ *
+ * @param IDatabase $db
+ * @param int $revId
+ * @param string $role
+ *
+ * @return int|null
+ */
+ private function findSlotContentId( IDatabase $db, $revId, $role ) {
+ if ( $this->mcrMigrationStage < MIGRATION_WRITE_BOTH ) {
+ return null;
+ }
+
+ try {
+ $roleId = $this->slotRoleStore->getId( $role );
+ $conditions = [
+ 'slot_revision_id' => $revId,
+ 'slot_role_id' => $roleId,
+ ];
+
+ $contentId = $db->selectField( 'slots', 'slot_content_id', $conditions, __METHOD__ );
+
+ return $contentId ?: null;
+ } catch ( NameTableAccessException $ex ) {
+ // If the role is missing from the slot_roles table,
+ // the corresponding row in slots cannot exist.
+ return null;
+ }
+ }
+
/**
* Return the tables, fields, and join conditions to be selected to create
- * a new revision object.
+ * a new RevisionStoreRecord object.
*
* MCR migration note: this replaces Revision::getQueryInfo
*
+ * If the format of fields returned changes in any way then the cache key provided by
+ * self::getRevisionRowCacheKey should be updated.
+ *
* @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
+ * - 'text': Join with the text table, and select fields to load page text. This
+ * option is deprecated in MW 1.32 with MCR migration stage MIGRATION_WRITE_BOTH,
+ * and disallowed with MIGRATION_MEW.
*
* @return array With three keys:
* - tables: (string[]) to include in the `$table` to `IDatabase->select()`
$ret['fields'] = array_merge( $ret['fields'], [
'rev_id',
'rev_page',
- 'rev_text_id',
'rev_timestamp',
'rev_minor_edit',
'rev_deleted',
$ret['fields'] = array_merge( $ret['fields'], $actorQuery['fields'] );
$ret['joins'] = array_merge( $ret['joins'], $actorQuery['joins'] );
- if ( $this->contentHandlerUseDB ) {
- $ret['fields'][] = 'rev_content_format';
- $ret['fields'][] = 'rev_content_model';
+ if ( $this->mcrMigrationStage < MIGRATION_NEW ) {
+ $ret['fields'][] = 'rev_text_id';
+
+ if ( $this->contentHandlerUseDB ) {
+ $ret['fields'][] = 'rev_content_format';
+ $ret['fields'][] = 'rev_content_model';
+ }
}
if ( in_array( 'page', $options, true ) ) {
}
if ( in_array( 'text', $options, true ) ) {
+ if ( $this->mcrMigrationStage === MIGRATION_NEW ) {
+ throw new InvalidArgumentException( 'text table can no longer be joined directly' );
+ } elseif ( $this->mcrMigrationStage >= MIGRATION_WRITE_BOTH ) {
+ wfDeprecated( __METHOD__ . ' with `text` option', '1.32' );
+ }
+
$ret['tables'][] = 'text';
$ret['fields'] = array_merge( $ret['fields'], [
'old_text',
/**
* Return the tables, fields, and join conditions to be selected to create
- * a new archived revision object.
+ * a new SlotRecord.
+ *
+ * @since 1.32
+ *
+ * @param array $options Any combination of the following strings
+ * - 'content': Join with the content table, and select content meta-data fields
+ *
+ * @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 getSlotsQueryInfo( $options = [] ) {
+ $ret = [
+ 'tables' => [],
+ 'fields' => [],
+ 'joins' => [],
+ ];
+
+ if ( $this->mcrMigrationStage < MIGRATION_NEW ) {
+ $db = $this->getDBConnectionRef( DB_REPLICA );
+ $ret['tables']['slots'] = 'revision';
+
+ $ret['fields']['slot_revision_id'] = 'slots.rev_id';
+ $ret['fields']['slot_content_id'] = 'NULL';
+ $ret['fields']['slot_origin'] = 'slots.rev_id';
+ $ret['fields']['role_name'] = $db->addQuotes( 'main' );
+
+ if ( in_array( 'content', $options, true ) ) {
+ $ret['fields']['content_size'] = 'slots.rev_len';
+ $ret['fields']['content_sha1'] = 'slots.rev_sha1';
+ $ret['fields']['content_address']
+ = $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] );
+
+ if ( $this->contentHandlerUseDB ) {
+ $ret['fields']['model_name'] = 'slots.rev_content_model';
+ } else {
+ $ret['fields']['model_name'] = 'NULL';
+ }
+ }
+
+ // XXX: in MIGRATION_WRITE_BOTH mode, emulate *and* select - using a UNION?
+ // See Anomie's idea at <https://gerrit.wikimedia.org/r/c/416465/
+ // 8..10/includes/Storage/RevisionStore.php#2113>
+ } else {
+ $ret['tables'][] = 'slots';
+ $ret['tables'][] = 'slot_roles';
+ $ret['fields'] = array_merge( $ret['fields'], [
+ 'slot_revision_id',
+ 'slot_content_id',
+ 'slot_origin',
+ 'role_name'
+ ] );
+ $ret['joins']['slot_roles'] = [ 'INNER JOIN', [ 'slot_role_id = role_id' ] ];
+
+ if ( in_array( 'content', $options, true ) ) {
+ $ret['tables'][] = 'content';
+ $ret['tables'][] = 'content_models';
+ $ret['fields'] = array_merge( $ret['fields'], [
+ 'content_size',
+ 'content_sha1',
+ 'content_address',
+ 'model_name'
+ ] );
+ $ret['joins']['content'] = [ 'INNER JOIN', [ 'slot_content_id = content_id' ] ];
+ $ret['joins']['content_models'] = [ 'INNER JOIN', [ 'content_model = model_id' ] ];
+ }
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Return the tables, fields, and join conditions to be selected to create
+ * a new RevisionArchiveRecord object.
*
* MCR migration note: this replaces Revision::getArchiveQueryInfo
*
'ar_namespace',
'ar_title',
'ar_rev_id',
- 'ar_text_id',
'ar_timestamp',
'ar_minor_edit',
'ar_deleted',
'joins' => $commentQuery['joins'] + $actorQuery['joins'],
];
- if ( $this->contentHandlerUseDB ) {
- $ret['fields'][] = 'ar_content_format';
- $ret['fields'][] = 'ar_content_model';
+ if ( $this->mcrMigrationStage < MIGRATION_NEW ) {
+ $ret['fields'][] = 'ar_text_id';
+
+ if ( $this->contentHandlerUseDB ) {
+ $ret['fields'][] = 'ar_content_format';
+ $ret['fields'][] = 'ar_content_model';
+ }
}
return $ret;
return false;
}
- $revQuery = self::getQueryInfo();
+ $revQuery = $this->getQueryInfo();
$res = $db->select(
$revQuery['tables'],
[
$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 ),
+ $this->getRevisionRowCacheKey( $db, $pageId, $revId ),
WANObjectCache::TTL_WEEK,
function ( $curValue, &$ttl, array &$setOpts ) use ( $db, $pageId, $revId ) {
$setOpts += Database::getCacheSetOptions( $db );
}
}
+ /**
+ * Get a cache key for use with a row as selected with getQueryInfo( [ 'page', 'user' ] )
+ * Caching rows without 'page' or 'user' could lead to issues.
+ * If the format of the rows returned by the query provided by getQueryInfo changes the
+ * cache key should be updated to avoid conflicts.
+ *
+ * @param IDatabase $db
+ * @param int $pageId
+ * @param int $revId
+ * @return string
+ */
+ private function getRevisionRowCacheKey( IDatabase $db, $pageId, $revId ) {
+ return $this->cache->makeGlobalKey(
+ self::ROW_CACHE_KEY,
+ $db->getDomainID(),
+ $pageId,
+ $revId
+ );
+ }
+
// TODO: move relevant methods from Title here, e.g. getFirstRevision, isBigDeletion, etc.
}
class SlotRecord {
/**
- * @var object database result row, as a raw object
+ * @var object database result row, as a raw object. Callbacks are supported for field values,
+ * to enable on-demand emulation of these values. This is primarily intended for use
+ * during schema migration.
*/
private $row;
/**
* Constructs a complete SlotRecord for a newly saved revision, based on the incomplete
* proto-slot. This adds information that has only become available during saving,
- * particularly the revision ID and content address.
+ * particularly the revision ID, content ID and content address.
*
* @param int $revisionId the revision the slot is to be associated with (field slot_revision_id).
* If $protoSlot already has a revision, it must be the same.
- * @param int $contentId the ID of the row in the content table describing the content
+ * @param int|null $contentId the ID of the row in the content table describing the content
* referenced by $contentAddress (field slot_content_id).
* If $protoSlot already has a content ID, it must be the same.
* @param string $contentAddress the slot's content address (field content_address).
SlotRecord $protoSlot
) {
Assert::parameterType( 'integer', $revisionId, '$revisionId' );
- Assert::parameterType( 'integer', $contentId, '$contentId' );
+ // TODO once migration is over $contentId must be an integer
+ Assert::parameterType( 'integer|null', $contentId, '$contentId' );
Assert::parameterType( 'string', $contentAddress, '$contentAddress' );
if ( $protoSlot->hasRevision() && $protoSlot->getRevision() !== $revisionId ) {
);
}
- if ( $protoSlot->hasAddress() && $protoSlot->getContentId() !== $contentId ) {
+ if ( $protoSlot->hasContentId() && $protoSlot->getContentId() !== $contentId ) {
throw new LogicException(
"Mismatching content ID $contentId: "
. "The slot already has content row {$protoSlot->getContentId()} associated."
Assert::parameterType( 'object', $row, '$row' );
Assert::parameterType( 'Content|callable', $content, '$content' );
- Assert::parameter(
- property_exists( $row, 'slot_id' ),
- '$row->slot_id',
- 'must exist'
- );
Assert::parameter(
property_exists( $row, 'slot_revision_id' ),
'$row->slot_revision_id',
* @return bool whether this record contains the given field
*/
private function hasField( $name ) {
+ if ( isset( $this->row->$name ) ) {
+ // if the field is a callback, resolve first, then re-check
+ if ( !is_string( $this->row->$name ) && is_callable( $this->row->$name ) ) {
+ $this->getField( $name );
+ }
+ }
+
return isset( $this->row->$name );
}
return $this->hasField( 'content_address' );
}
+ /**
+ * Whether this slot has an origin (revision ID that originated the slot's content.
+ *
+ * @since 1.32
+ *
+ * @return bool
+ */
+ public function hasOrigin() {
+ return $this->hasField( 'slot_origin' );
+ }
+
+ /**
+ * Whether this slot has a content ID. Slots will have a content ID if their
+ * content has been stored in the content table. While building a new revision,
+ * SlotRecords will not have an ID associated.
+ *
+ * @since 1.32
+ *
+ * @return bool
+ */
+ public function hasContentId() {
+ return $this->hasField( 'slot_content_id' );
+ }
+
/**
* 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,
&& ( !$this->mContentModel || $flags === self::GAID_FOR_UPDATE )
&& $this->getArticleID( $flags )
) {
- $linkCache = LinkCache::singleton();
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
$linkCache->addLinkObj( $this ); # in case we already had an article ID
$this->mContentModel = $linkCache->getGoodLinkFieldObj( $this, 'model' );
}
$this->mArticleID = 0;
return $this->mArticleID;
}
- $linkCache = LinkCache::singleton();
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
if ( $flags & self::GAID_FOR_UPDATE ) {
$oldUpdate = $linkCache->forUpdate( true );
$linkCache->clearLink( $this );
return $this->mRedirect;
}
- $linkCache = LinkCache::singleton();
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
$linkCache->addLinkObj( $this ); # in case we already had an article ID
$cached = $linkCache->getGoodLinkFieldObj( $this, 'redirect' );
if ( $cached === null ) {
$this->mLength = 0;
return $this->mLength;
}
- $linkCache = LinkCache::singleton();
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
$linkCache->addLinkObj( $this ); # in case we already had an article ID
$cached = $linkCache->getGoodLinkFieldObj( $this, 'length' );
if ( $cached === null ) {
$this->mLatestID = 0;
return $this->mLatestID;
}
- $linkCache = LinkCache::singleton();
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
$linkCache->addLinkObj( $this ); # in case we already had an article ID
$cached = $linkCache->getGoodLinkFieldObj( $this, 'revision' );
if ( $cached === null ) {
* @param int $newid The new Article ID
*/
public function resetArticleID( $newid ) {
- $linkCache = LinkCache::singleton();
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
$linkCache->clearLink( $this );
if ( $newid === false ) {
}
public static function clearCaches() {
- $linkCache = LinkCache::singleton();
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
$linkCache->clear();
$titleCache = self::getTitleCache();
$retVal = [];
if ( $res->numRows() ) {
- $linkCache = LinkCache::singleton();
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
foreach ( $res as $row ) {
$titleObj = self::makeTitle( $row->page_namespace, $row->page_title );
if ( $titleObj ) {
);
$retVal = [];
- $linkCache = LinkCache::singleton();
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
foreach ( $res as $row ) {
if ( $row->page_id ) {
$titleObj = self::newFromRow( $row );
// check, if the page language could be saved in the database, and if so and
// the value is not requested already, lookup the page language using LinkCache
if ( $wgPageLanguageUseDB && $this->mDbPageLanguage === false ) {
- $linkCache = LinkCache::singleton();
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
$linkCache->addLinkObj( $this );
$this->mDbPageLanguage = $linkCache->getGoodLinkFieldObj( $this, 'lang' );
}
*/
protected static $setCookies = [];
+ /** @var bool Used to disable setters before running jobs post-request (T191537) */
+ protected static $disableForPostSend = false;
+
+ /**
+ * Disable setters for post-send processing
+ *
+ * After this call, self::setCookie(), self::header(), and
+ * self::statusHeader() will log a warning and return without
+ * setting cookies or headers.
+ *
+ * @since 1.32
+ */
+ public static function disableForPostSend() {
+ self::$disableForPostSend = true;
+ }
+
/**
* Output an HTTP header, wrapper for PHP's header()
* @param string $string Header to output
* @param null|int $http_response_code Forces the HTTP response code to the specified value.
*/
public function header( $string, $replace = true, $http_response_code = null ) {
+ if ( self::$disableForPostSend ) {
+ wfDebugLog( 'header', 'ignored post-send header {header}', 'all', [
+ 'header' => $string,
+ 'replace' => $replace,
+ 'http_response_code' => $http_response_code,
+ 'exception' => new RuntimeException( 'Ignored post-send header' ),
+ ] );
+ return;
+ }
+
\MediaWiki\HeaderCallback::warnIfHeadersSent();
if ( $http_response_code ) {
header( $string, $replace, $http_response_code );
* @param int $code Status code
*/
public function statusHeader( $code ) {
+ if ( self::$disableForPostSend ) {
+ wfDebugLog( 'header', 'ignored post-send status header {code}', 'all', [
+ 'code' => $code,
+ 'exception' => new RuntimeException( 'Ignored post-send status header' ),
+ ] );
+ return;
+ }
+
HttpStatus::header( $code );
}
$expire = time() + $wgCookieExpiration;
}
+ $cookie = $options['prefix'] . $name;
+ $data = [
+ 'name' => (string)$cookie,
+ 'value' => (string)$value,
+ 'expire' => (int)$expire,
+ 'path' => (string)$options['path'],
+ 'domain' => (string)$options['domain'],
+ 'secure' => (bool)$options['secure'],
+ 'httpOnly' => (bool)$options['httpOnly'],
+ ];
+
+ if ( self::$disableForPostSend ) {
+ wfDebugLog( 'cookie', 'ignored post-send cookie {cookie}', 'all', [
+ 'cookie' => $cookie,
+ 'data' => $data,
+ 'exception' => new RuntimeException( 'Ignored post-send cookie' ),
+ ] );
+ return;
+ }
+
$func = $options['raw'] ? 'setrawcookie' : 'setcookie';
if ( Hooks::run( 'WebResponseSetCookie', [ &$name, &$value, &$expire, &$options ] ) ) {
- $cookie = $options['prefix'] . $name;
- $data = [
- 'name' => (string)$cookie,
- 'value' => (string)$value,
- 'expire' => (int)$expire,
- 'path' => (string)$options['path'],
- 'domain' => (string)$options['domain'],
- 'secure' => (bool)$options['secure'],
- 'httpOnly' => (bool)$options['httpOnly'],
- ];
-
// Per RFC 6265, key is name + domain + path
$key = "{$data['name']}\n{$data['domain']}\n{$data['path']}";
// Store Title object in various data structures
$title = Title::newFromRow( $row );
- LinkCache::singleton()->addGoodLinkObjFromRow( $title, $row );
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
+ $linkCache->addGoodLinkObjFromRow( $title, $row );
$pageId = intval( $row->page_id );
$this->mAllPages[$row->page_namespace][$row->page_title] = $pageId;
// Any items left in the $remaining list are added as missing
if ( $processTitles ) {
// The remaining titles in $remaining are non-existent pages
- $linkCache = LinkCache::singleton();
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
foreach ( $remaining as $ns => $dbkeys ) {
foreach ( array_keys( $dbkeys ) as $dbkey ) {
$title = Title::makeTitle( $ns, $dbkey );
$hiddencats[$row->page_title] = isset( $row->pp_propname );
}
- $linkCache = LinkCache::singleton();
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
foreach ( $links as $link => $sortkey ) {
$entry = [];
$offset = $params['offset'];
$searchEngine = $this->buildSearchEngine( $params );
- $titles = $searchEngine->extractTitles( $searchEngine->completionSearchWithVariants( $search ) );
+ $suggestions = $searchEngine->completionSearchWithVariants( $search );
+ $titles = $searchEngine->extractTitles( $suggestions );
+
+ if ( $suggestions->hasMoreResults() ) {
+ $this->setContinueEnumParameter( 'offset', $offset + $limit );
+ }
if ( $resultPageSet ) {
$resultPageSet->setRedirectMergePolicy( function ( array $current, array $new ) {
}
return $current;
} );
- if ( count( $titles ) > $limit ) {
- $this->setContinueEnumParameter( 'offset', $offset + $limit );
- array_pop( $titles );
- }
$resultPageSet->populateFromTitles( $titles );
foreach ( $titles as $index => $title ) {
$resultPageSet->setGeneratorData( $title, [ 'index' => $index + $offset + 1 ] );
$result = $this->getResult();
$count = 0;
foreach ( $titles as $title ) {
- if ( ++$count > $limit ) {
- $this->setContinueEnumParameter( 'offset', $offset + $limit );
- break;
- }
$vals = [
'ns' => intval( $title->getNamespace() ),
'title' => $title->getPrefixedText(),
$vals['pageid'] = intval( $title->getArticleID() );
}
$fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $vals );
+ ++$count;
if ( !$fit ) {
- $this->setContinueEnumParameter( 'offset', $offset + $count - 1 );
+ $this->setContinueEnumParameter( 'offset', $offset + $count );
break;
}
}
// Create search engine instance and set options
$search = $this->buildSearchEngine( $params );
+ if ( isset( $params['sort'] ) ) {
+ $search->setSort( $params['sort'] );
+ }
$search->setFeatureData( 'rewrite', (bool)$params['enablerewrites'] );
$search->setFeatureData( 'interwiki', (bool)$interwiki );
$count = 0;
$limit = $params['limit'];
- foreach ( $matches as $result ) {
- if ( ++$count > $limit ) {
- // We've reached the one extra which shows that there are
- // additional items to be had. Stop here...
- $this->setContinueEnumParameter( 'offset', $params['offset'] + $params['limit'] );
- break;
- }
+ if ( $matches->hasMoreResults() ) {
+ $this->setContinueEnumParameter( 'offset', $params['offset'] + $params['limit'] );
+ }
+ foreach ( $matches as $result ) {
+ $count++;
// Silently skip broken and missing titles
if ( $result->isBrokenTitle() || $result->isMissingRevision() ) {
continue;
'enablerewrites' => false,
];
+ // If we have more than one engine the list of available sorts is
+ // difficult to represent. For now don't expose it.
+ $alternatives = MediaWiki\MediaWikiServices::getInstance()
+ ->getSearchEngineConfig()
+ ->getSearchTypes();
+ if ( count( $alternatives ) == 1 ) {
+ $this->allowedParams['sort'] = [
+ ApiBase::PARAM_DFLT => 'relevance',
+ ApiBase::PARAM_TYPE => MediaWiki\MediaWikiServices::getInstance()
+ ->newSearchEngine()
+ ->getValidSorts(),
+ ];
+ }
+
return $this->allowedParams;
}
if ( $alternatives[0] === null ) {
$alternatives[0] = self::$BACKEND_NULL_PARAM;
}
- $this->allowedParams['backend'] = [
+ $params['backend'] = [
ApiBase::PARAM_DFLT => $searchConfig->getSearchType(),
ApiBase::PARAM_TYPE => $alternatives,
];
* will be set:
* - backend: which search backend to use
* - limit: mandatory
- * - offset: optional, if set limit will be incremented by
- * one ( to support the continue parameter )
+ * - offset: optional
* - namespace: mandatory
* - search engine profiles defined by SearchApi::getSearchProfileParams()
* @param string[]|null $params API request params (must be sanitized by
$searchEngine = MediaWikiServices::getInstance()->getSearchEngineFactory()->create( $type );
$limit = $params['limit'];
$searchEngine->setNamespaces( $params['namespace'] );
- $offset = null;
- if ( isset( $params['offset'] ) ) {
- // If the API supports offset then it probably
- // wants to fetch limit+1 so it can check if
- // more results are available to properly set
- // the continue param
- $offset = $params['offset'];
- $limit += 1;
- }
+ $offset = isset( $params['offset'] ) ? $params['offset'] : null;
$searchEngine->setLimitOffset( $limit, $offset );
// Initialize requested search profiles.
"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-param-sort": "Legt die Sortierreihenfolge der zurückgegebenen Ergebnisse fest.",
"apihelp-query+search-example-simple": "Nach <kbd>meaning</kbd> suchen.",
"apihelp-query+search-example-text": "Texte nach <kbd>meaning</kbd> durchsuchen.",
"apihelp-query+siteinfo-paramvalue-prop-specialpagealiases": "Liste von Spezialseiten-Aliasse.",
"apihelp-query+search-param-interwiki": "Include interwiki results in the search, if available.",
"apihelp-query+search-param-backend": "Which search backend to use, if not the default.",
"apihelp-query+search-param-enablerewrites": "Enable internal query rewriting. Some search backends can rewrite the query into another which is thought to provide better results, for instance by correcting spelling errors.",
+ "apihelp-query+search-param-sort": "Set the sort order of returned results.",
"apihelp-query+search-example-simple": "Search for <kbd>meaning</kbd>.",
"apihelp-query+search-example-text": "Search texts for <kbd>meaning</kbd>.",
"apihelp-query+search-example-generator": "Get page info about the pages returned for a search for <kbd>meaning</kbd>.",
"apihelp-query+search-param-interwiki": "Inclure les résultats interwiki dans la recherche, s’ils sont disponibles.",
"apihelp-query+search-param-backend": "Quel serveur de recherche utiliser, si ce n’est pas celui par défaut.",
"apihelp-query+search-param-enablerewrites": "Activer la réécriture interne de la requête. Les serveurs de recherche peuvent réécrire la requête en une autre qui est censée donner de meilleurs résultats, par exemple en corrigeant les erreurs d’orthographe.",
+ "apihelp-query+search-param-sort": "Fixe l’ordre de tri des résultats renvoyés.",
"apihelp-query+search-example-simple": "Rechercher <kbd>meaning</kbd>.",
"apihelp-query+search-example-text": "Rechercher des textes pour <kbd>meaning</kbd>.",
"apihelp-query+search-example-generator": "Obtenir les informations sur les pages renvoyées par une recherche de <kbd>meaning</kbd>.",
"apihelp-query+search-param-interwiki": "לכלול תוצאות בינוויקי בחיפוש, אם זמין.",
"apihelp-query+search-param-backend": "באיזה שרת חיפוש להשתמש אם לא בבררת המחדל.",
"apihelp-query+search-param-enablerewrites": "הפעלת שכתוב שאילתות פנימי. שרתי חיפוש אחדים יכולים לשכתב את השאילתה לצורה אחרת שנחשבת לכזאת שמספקת תוצאות טובות יותר, למשל באמצעות תיקון שגיאות כתיב.",
+ "apihelp-query+search-param-sort": "קביעת סדר המיון של התוצאות המוחזרות.",
"apihelp-query+search-example-simple": "חיפוש <kbd>meaning</kbd>.",
"apihelp-query+search-example-text": "חיפוש טקסטים עבור <kbd>meaning</kbd>.",
"apihelp-query+search-example-generator": "קבלת מידע על הדף עבור שמוחזרים מחיפוש אחרי <kbd>meaning</kbd>.",
"apihelp-query+search-paramvalue-prop-score": "Ignorato.",
"apihelp-query+search-paramvalue-prop-hasrelated": "Ignorato.",
"apihelp-query+search-param-limit": "Quante pagine totali restituire.",
+ "apihelp-query+search-param-sort": "Imposta l'ordinamento dei risultati restituiti.",
"apihelp-query+siteinfo-param-prop": "Quali informazioni ottenere:",
"apihelp-query+siteinfo-paramvalue-prop-statistics": "Restituisce le statistiche del sito.",
"apihelp-query+siteinfo-paramvalue-prop-libraries": "Restituisci librerie installate sul wiki.",
"apihelp-query+search-paramvalue-prop-timestamp": "문서가 마지막으로 편집된 시기의 타임스탬프를 추가합니다.",
"apihelp-query+search-paramvalue-prop-score": "무시됨.",
"apihelp-query+search-paramvalue-prop-hasrelated": "무시됨.",
+ "apihelp-query+search-param-sort": "반환된 결과의 정렬 순서를 설정합니다.",
"apihelp-query+search-example-simple": "<kbd>meaning</kbd>을 검색합니다.",
"apihelp-query+search-example-text": "<kbd>meaning</kbd>의 텍스트를 검색합니다.",
"apihelp-query+siteinfo-summary": "사이트의 전반적인 정보를 반환합니다.",
"apihelp-query+search-param-interwiki": "Incluir resultados interwikis na pesquisa, se disponíveis.",
"apihelp-query+search-param-backend": "O servidor de pesquisas a ser usado, se diferente do servidor padrão.",
"apihelp-query+search-param-enablerewrites": "Ativar a reescrita da pesquisa interna. Alguns motores de pesquisa podem reescrever a pesquisa substituindo-a por outra que consideram que dará melhores resultados, por exemplo, por corrigir erros de ortografia.",
+ "apihelp-query+search-param-sort": "Definir o ordenamento dos resultados devolvidos.",
"apihelp-query+search-example-simple": "Pesquisar <kbd>meaning</kbd>.",
"apihelp-query+search-example-text": "Pesquisar <kbd>meaning</kbd> nos textos.",
"apihelp-query+search-example-generator": "Obter informação sobre as páginas devolvidas por uma pesquisa do termo <kbd>meaning</kbd>.",
"apihelp-query+search-param-interwiki": "{{doc-apihelp-param|query+search|interwiki}}",
"apihelp-query+search-param-backend": "{{doc-apihelp-param|query+search|backend}}",
"apihelp-query+search-param-enablerewrites": "{{doc-apihelp-param|query+search|enablerewrites}}",
+ "apihelp-query+search-param-sort": "{{doc-apihelp-param|query+search|sort}}",
"apihelp-query+search-example-simple": "{{doc-apihelp-example|query+search}}",
"apihelp-query+search-example-text": "{{doc-apihelp-example|query+search}}",
"apihelp-query+search-example-generator": "{{doc-apihelp-example|query+search}}",
"apihelp-query+search-param-interwiki": "Включить результаты из других вики, если доступны.",
"apihelp-query+search-param-backend": "Какой поисковый движок использовать, если не стандартный.",
"apihelp-query+search-param-enablerewrites": "Разрешить редактирование запроса. Некоторые поисковые движки могут отредактировать запрос, например, исправив опечатку, если посчитают, что это приведёт к лучшим результатам.",
+ "apihelp-query+search-param-sort": "Задаёт порядок сортировки возвращаемых результатов.",
"apihelp-query+search-example-simple": "Найти <kbd>meaning</kbd>.",
"apihelp-query+search-example-text": "Найти тексты, содержащие <kbd>meaning</kbd>.",
"apihelp-query+search-example-generator": "Получить информацию о страницах, возвращённых по поисковому запросу <kbd>meaning</kbd>.",
* Makes an entry in the database corresponding to an edit
*
* @param string $timestamp
- * @param Title &$title
+ * @param Title $title
* @param bool $minor
- * @param User &$user
+ * @param User $user
* @param string $comment
* @param int $oldId
* @param string $lastTimestamp
* @return RecentChange
*/
public static function notifyEdit(
- $timestamp, &$title, $minor, &$user, $comment, $oldId, $lastTimestamp,
+ $timestamp, $title, $minor, $user, $comment, $oldId, $lastTimestamp,
$bot, $ip = '', $oldSize = 0, $newSize = 0, $newId = 0, $patrol = 0,
$tags = []
) {
* Note: the title object must be loaded with the new id using resetArticleID()
*
* @param string $timestamp
- * @param Title &$title
+ * @param Title $title
* @param bool $minor
- * @param User &$user
+ * @param User $user
* @param string $comment
* @param bool $bot
* @param string $ip
* @return RecentChange
*/
public static function notifyNew(
- $timestamp, &$title, $minor, &$user, $comment, $bot,
+ $timestamp, $title, $minor, $user, $comment, $bot,
$ip = '', $size = 0, $newId = 0, $patrol = 0, $tags = []
) {
$rc = new RecentChange;
/**
* @param string $timestamp
- * @param Title &$title
- * @param User &$user
+ * @param Title $title
+ * @param User $user
* @param string $actionComment
* @param string $ip
* @param string $type
* @param string $actionCommentIRC
* @return bool
*/
- public static function notifyLog( $timestamp, &$title, &$user, $actionComment, $ip, $type,
+ public static function notifyLog( $timestamp, $title, $user, $actionComment, $ip, $type,
$action, $target, $logComment, $params, $newId = 0, $actionCommentIRC = ''
) {
global $wgLogRestrictions;
/**
* @param string $timestamp
- * @param Title &$title
- * @param User &$user
+ * @param Title $title
+ * @param User $user
* @param string $actionComment
* @param string $ip
* @param string $type
* @param bool $isPatrollable Whether this log entry is patrollable
* @return RecentChange
*/
- public static function newLogEntry( $timestamp, &$title, &$user, $actionComment, $ip,
+ public static function newLogEntry( $timestamp, $title, $user, $actionComment, $ip,
$type, $action, $target, $logComment, $params, $newId = 0, $actionCommentIRC = '',
$revId = 0, $isPatrollable = false ) {
global $wgRequest;
/**
* Represents information returned by WikiPage::prepareContentForEdit()
*
+ * @deprecated since 1.32, use DerivedPageDataUpdater instead.
+ *
* @since 1.30
*/
class PreparedEdit {
// If extra data (metadata) was not loaded then it must have been large
return $this->extraDataLoaded
- && strlen( serialize( $this->metadata ) ) <= self::CACHE_FIELD_MAX_LEN;
+ && strlen( serialize( $this->metadata ) ) <= self::CACHE_FIELD_MAX_LEN;
}
/**
* @since 1.28
*/
public function acquireFileLock() {
- return $this->getRepo()->getBackend()->lockFiles(
+ return Status::wrap( $this->getRepo()->getBackend()->lockFiles(
[ $this->getPath() ], LockManager::LOCK_EX, 10
- );
+ ) );
}
/**
* @since 1.28
*/
public function releaseFileLock() {
- return $this->getRepo()->getBackend()->unlockFiles(
+ return Status::wrap( $this->getRepo()->getBackend()->unlockFiles(
[ $this->getPath() ], LockManager::LOCK_EX
- );
+ ) );
}
/**
/** @var string Hash of client parameters */
protected $clientId;
/** @var string[] Map of client information fields for logging */
- protected $clientInfo;
+ protected $clientLogInfo;
/** @var int|null Expected minimum index of the last write to the position store */
protected $waitForPosIndex;
/** @var int Max seconds to wait on positions to appear */
$this->key = $store->makeGlobalKey( __CLASS__, $this->clientId, 'v2' );
$this->waitForPosIndex = $posIndex;
- $this->clientInfo = $client + [ 'clientId' => '' ];
+ $this->clientLogInfo = [
+ 'clientIP' => $client['ip'],
+ 'clientAgent' => $client['agent'],
+ 'clientId' => $client['clientId'] ?? null
+ ];
$this->logger = new NullLogger();
}
[
'cpPosIndex' => $this->waitForPosIndex,
'waitTimeMs' => $waitedMs
- ] + $this->clientInfo
+ ] + $this->clientLogInfo
);
} else {
$this->logger->warning(
'cpPosIndex' => $this->waitForPosIndex,
'indexReached' => $indexReached,
'waitTimeMs' => $waitedMs
- ] + $this->clientInfo
+ ] + $this->clientLogInfo
);
}
} else {
// Unfortunately, mysql_num_rows does not reset the last errno.
// We are not checking for any errors here, since
- // these are no errors mysql_num_rows can cause.
+ // there are no errors mysql_num_rows can cause.
// See https://dev.mysql.com/doc/refman/5.0/en/mysql-fetch-row.html.
// See https://phabricator.wikimedia.org/T44430
return $n;
// Request opted out of using position wait logic. This is useful for requests
// done by the job queue or background ETL that do not have a meaningful session.
$this->chronProt->setWaitEnabled( false );
+ } elseif ( $this->memStash instanceof EmptyBagOStuff ) {
+ // No where to store any DB positions and wait for them to appear
+ $this->chronProt->setEnabled( false );
+ $this->replLogger->info( 'Cannot use ChronologyProtector with EmptyBagOStuff.' );
}
$this->replLogger->debug( __METHOD__ . ': using request info ' .
/**
* @param string $value Possible result of LBFactory::makeCookieValueFromCPIndex()
- * @param int $minTimestamp Lowest UNIX timestamp of non-expired values (if present)
+ * @param int $minTimestamp Lowest UNIX timestamp that a non-expired value can have
* @return array (index: int or null, clientId: string or null)
* @since 1.32
*/
public static function getCPInfoFromCookieValue( $value, $minTimestamp ) {
static $placeholder = [ 'index' => null, 'clientId' => null ];
- if ( !preg_match( '/^(\d+)(?:@(\d+))?(?:#([0-9a-f]{32}))?$/', $value, $m ) ) {
+ if ( !preg_match( '/^(\d+)@(\d+)#([0-9a-f]{32})$/', $value, $m ) ) {
return $placeholder; // invalid
}
$realHtml = $html = null;
}
if ( !Hooks::run( 'LinkBegin',
- [ $dummy, $title, &$html, &$extraAttribs, &$query, &$options, &$ret ] )
+ [ $dummy, $title, &$html, &$extraAttribs, &$query, &$options, &$ret ], '1.28' )
) {
return $ret;
}
$title = Title::newFromLinkTarget( $target );
$options = $this->getLegacyOptions( $isKnown );
if ( !Hooks::run( 'LinkEnd',
- [ $dummy, $title, $options, &$html, &$attribs, &$ret ] )
+ [ $dummy, $title, $options, &$html, &$attribs, &$ret ], '1.28' )
) {
return $ret;
}
use MediaWiki\Edit\PreparedEdit;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MediaWikiServices;
+use MediaWiki\Storage\DerivedPageDataUpdater;
+use MediaWiki\Storage\PageUpdater;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\RevisionSlotsUpdate;
+use MediaWiki\Storage\RevisionStore;
use Wikimedia\Assert\Assert;
use Wikimedia\Rdbms\FakeResultWrapper;
use Wikimedia\Rdbms\IDatabase;
-use Wikimedia\Rdbms\DBUnexpectedError;
+use Wikimedia\Rdbms\LoadBalancer;
/**
* Class representing a MediaWiki article and history.
*/
protected $mLinksUpdated = '19700101000000';
+ /**
+ * @var DerivedPageDataUpdater|null
+ */
+ private $derivedDataUpdater = null;
+
/**
* Constructor and clear the article
* @param Title $title Reference to a Title object.
}
}
+ /**
+ * @return RevisionStore
+ */
+ private function getRevisionStore() {
+ return MediaWikiServices::getInstance()->getRevisionStore();
+ }
+
+ /**
+ * @return ParserCache
+ */
+ private function getParserCache() {
+ return MediaWikiServices::getInstance()->getParserCache();
+ }
+
+ /**
+ * @return LoadBalancer
+ */
+ private function getDBLoadBalancer() {
+ return MediaWikiServices::getInstance()->getDBLoadBalancer();
+ }
+
/**
* @todo Move this UI stuff somewhere else
*
$this->mTimestamp = '';
$this->mIsRedirect = false;
$this->mLatest = false;
- // T59026: do not clear mPreparedEdit since prepareTextForEdit() already checks
- // the requested rev ID and content against the cached one for equality. For most
+ // T59026: do not clear $this->derivedDataUpdater since getDerivedDataUpdater() already
+ // checks the requested rev ID and content against the cached one. For most
// content types, the output should not change during the lifetime of this cache.
// Clearing it can cause extra parses on edit for no reason.
}
if ( is_int( $from ) ) {
list( $index, $opts ) = DBAccessObjectUtils::getDBOptions( $from );
- $loadBalancer = MediaWikiServices::getInstance()->getDBLoadBalancer();
+ $loadBalancer = $this->getDBLoadBalancer();
$db = $loadBalancer->getConnection( $index );
$data = $this->pageDataFromTitle( $db, $this->mTitle, $opts );
$this->loadFromRow( $data, $from );
}
+ /**
+ * Checks whether the page data was loaded using the given database access mode (or better).
+ *
+ * @since 1.32
+ *
+ * @param string|int $from One of the following:
+ * - "fromdb" or WikiPage::READ_NORMAL to get from a replica DB.
+ * - "fromdbmaster" or WikiPage::READ_LATEST to get from the master DB.
+ * - "forupdate" or WikiPage::READ_LOCKING to get from the master DB
+ * using SELECT FOR UPDATE.
+ *
+ * @return bool
+ */
+ public function wasLoadedFrom( $from ) {
+ $from = self::convertSelectType( $from );
+
+ if ( !is_int( $from ) ) {
+ // No idea from where the caller got this data, assume replica DB.
+ $from = self::READ_NORMAL;
+ }
+
+ if ( is_int( $from ) && $from <= $this->mDataLoadedFrom ) {
+ return true;
+ }
+
+ return false;
+ }
+
/**
* Load the object from a database row
*
* the master DB using SELECT FOR UPDATE
*/
public function loadFromRow( $data, $from ) {
- $lc = LinkCache::singleton();
+ $lc = MediaWikiServices::getInstance()->getLinkCache();
$lc->clearLink( $this->mTitle );
if ( $data ) {
public function isCountable( $editInfo = false ) {
global $wgArticleCountMethod;
+ // NOTE: Keep in sync with DerivedPageDataUpdater::isCountable.
+
if ( !$this->mTitle->isContentPage() ) {
return false;
}
if ( $editInfo ) {
+ // NOTE: only the main slot can make a page a redirect
$content = $editInfo->pstContent;
} else {
$content = $this->getContent();
* @return UserArrayFromResult
*/
public function getContributors() {
- // @todo FIXME: This is expensive; cache this info somewhere.
+ // @todo: This is expensive; cache this info somewhere.
$dbr = wfGetDB( DB_REPLICA );
}
if ( $useParserCache ) {
- $parserOutput = MediaWikiServices::getInstance()->getParserCache()
+ $parserOutput = $this->getParserCache()
->get( $this, $parserOptions );
if ( $parserOutput !== false ) {
return $parserOutput;
* or else the record will be left in a funky state.
* Best if all done inside a transaction.
*
+ * @todo Factor out into a PageStore service, to be used by PageUpdater.
+ *
* @param IDatabase $dbw
* @param int|null $pageId Custom page ID that will be used for the insert statement
*
/**
* Update the page record to point to a newly saved revision.
*
+ * @todo Factor out into a PageStore service, or move into PageUpdater.
+ *
* @param IDatabase $dbw
* @param Revision $revision For ID number, and text used to set
* length and redirect status fields
) {
global $wgContentHandlerUseDB;
+ // TODO: move into PageUpdater or PageStore
+ // NOTE: when doing that, make sure cached fields get reset in doEditContent,
+ // and in the compat stub!
+
// Assertion to try to catch T92046
if ( (int)$revision->getId() === 0 ) {
throw new InvalidArgumentException(
$this->mLatest = $revision->getId();
$this->mIsRedirect = (bool)$rt;
// Update the LinkCache.
- LinkCache::singleton()->addGoodLinkObj(
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
+ $linkCache->addGoodLinkObj(
$this->getId(),
$this->mTitle,
$len,
) {
$baseRevId = null;
if ( $edittime && $sectionId !== 'new' ) {
- $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
+ $lb = $this->getDBLoadBalancer();
$dbr = $lb->getConnection( DB_REPLICA );
$rev = Revision::loadFromTimestamp( $dbr, $this->mTitle, $edittime );
// Try the master if this thread may have just added it.
/**
* Check flags and add EDIT_NEW or EDIT_UPDATE to them as needed.
+ *
+ * @deprecated since 1.32, use exists() instead, or simply omit the EDIT_UPDATE
+ * and EDIT_NEW flags. To protect against race conditions, use PageUpdater::grabParentRevision.
+ *
* @param int $flags
* @return int Updated $flags
*/
return $flags;
}
+ /**
+ * @return DerivedPageDataUpdater
+ */
+ private function newDerivedDataUpdater() {
+ global $wgContLang, $wgRCWatchCategoryMembership, $wgArticleCountMethod;
+
+ $derivedDataUpdater = new DerivedPageDataUpdater(
+ $this, // NOTE: eventually, PageUpdater should not know about WikiPage
+ $this->getRevisionStore(),
+ $this->getParserCache(),
+ JobQueueGroup::singleton(),
+ MessageCache::singleton(),
+ $wgContLang,
+ LoggerFactory::getInstance( 'SaveParse' )
+ );
+
+ $derivedDataUpdater->setRcWatchCategoryMembership( $wgRCWatchCategoryMembership );
+ $derivedDataUpdater->setArticleCountMethod( $wgArticleCountMethod );
+
+ return $derivedDataUpdater;
+ }
+
+ /**
+ * Returns a DerivedPageDataUpdater for use with the given target revision or new content.
+ * This method attempts to re-use the same DerivedPageDataUpdater instance for subsequent calls.
+ * The parameters passed to this method are used to ensure that the DerivedPageDataUpdater
+ * returned matches that caller's expectations, allowing an existing instance to be re-used
+ * if the given parameters match that instance's internal state according to
+ * DerivedPageDataUpdater::isReusableFor(), and creating a new instance of the parameters do not
+ * match the existign one.
+ *
+ * If neither $forRevision nor $forUpdate is given, a new DerivedPageDataUpdater is always
+ * created, replacing any DerivedPageDataUpdater currently cached.
+ *
+ * MCR migration note: this replaces WikiPage::prepareContentForEdit.
+ *
+ * @since 1.32
+ *
+ * @param User|null $forUser The user that will be used for, or was used for, PST.
+ * @param RevisionRecord|null $forRevision The revision created by the edit for which
+ * to perform updates, if the edit was already saved.
+ * @param RevisionSlotsUpdate|null $forUpdate The new content to be saved by the edit (pre PST),
+ * if the edit was not yet saved.
+ *
+ * @return DerivedPageDataUpdater
+ */
+ private function getDerivedDataUpdater(
+ User $forUser = null,
+ RevisionRecord $forRevision = null,
+ RevisionSlotsUpdate $forUpdate = null
+ ) {
+ if ( !$forRevision && !$forUpdate ) {
+ // NOTE: can't re-use an existing derivedDataUpdater if we don't know what the caller is
+ // going to use it with.
+ $this->derivedDataUpdater = null;
+ }
+
+ if ( $this->derivedDataUpdater && !$this->derivedDataUpdater->isContentPrepared() ) {
+ // NOTE: can't re-use an existing derivedDataUpdater if other code that has a reference
+ // to it did not yet initialize it, because we don't know what data it will be
+ // initialized with.
+ $this->derivedDataUpdater = null;
+ }
+
+ // XXX: It would be nice to have an LRU cache instead of trying to re-use a single instance.
+ // However, there is no good way to construct a cache key. We'd need to check against all
+ // cached instances.
+
+ if ( $this->derivedDataUpdater
+ && !$this->derivedDataUpdater->isReusableFor(
+ $forUser,
+ $forRevision,
+ $forUpdate
+ )
+ ) {
+ $this->derivedDataUpdater = null;
+ }
+
+ if ( !$this->derivedDataUpdater ) {
+ $this->derivedDataUpdater = $this->newDerivedDataUpdater();
+ }
+
+ return $this->derivedDataUpdater;
+ }
+
+ /**
+ * Returns a PageUpdater for creating new revisions on this page (or creating the page).
+ *
+ * The PageUpdater can also be used to detect the need for edit conflict resolution,
+ * and to protected such conflict resolution from concurrent edits using a check-and-set
+ * mechanism.
+ *
+ * @since 1.32
+ *
+ * @param User $user
+ *
+ * @return PageUpdater
+ */
+ public function newPageUpdater( User $user ) {
+ global $wgAjaxEditStash, $wgUseAutomaticEditSummaries, $wgPageCreationLog;
+
+ $pageUpdater = new PageUpdater(
+ $user,
+ $this, // NOTE: eventually, PageUpdater should not know about WikiPage
+ $this->getDerivedDataUpdater( $user ),
+ $this->getDBLoadBalancer(),
+ $this->getRevisionStore()
+ );
+
+ $pageUpdater->setUsePageCreationLog( $wgPageCreationLog );
+ $pageUpdater->setAjaxEditStash( $wgAjaxEditStash );
+ $pageUpdater->setUseAutomaticEditSummaries( $wgUseAutomaticEditSummaries );
+
+ return $pageUpdater;
+ }
+
/**
* Change an existing article or create a new article. Updates RC and all necessary caches,
* optionally via the deferred update array.
*
+ * @deprecated since 1.32, use PageUpdater::saveRevision instead. Note that the new method
+ * expects callers to take care of checking EDIT_MINOR against the minoredit right, and to
+ * apply the autopatrol right as appropriate.
+ *
* @param Content $content New content
- * @param string $summary Edit summary
+ * @param string|CommentStoreComment $summary Edit summary
* @param int $flags Bitfield:
* EDIT_NEW
* Article is known or assumed to be non-existent, create a new one
* This is not the parent revision ID, rather the revision ID for older
* content used as the source for a rollback, for example.
* @param User $user The user doing the edit
- * @param string $serialFormat Format for storing the content in the
- * database.
+ * @param string $serialFormat IGNORED.
* @param array|null $tags Change tags to apply to this edit
* Callers are responsible for permission checks
* (with ChangeTags::canAddTagsAccompanyingChange)
Content $content, $summary, $flags = 0, $baseRevId = false,
User $user = null, $serialFormat = null, $tags = [], $undidRevId = 0
) {
- global $wgUser, $wgUseAutomaticEditSummaries;
+ global $wgUser, $wgUseNPPatrol, $wgUseRCPatrol;
- // Old default parameter for $tags was null
- if ( $tags === null ) {
- $tags = [];
+ if ( !( $summary instanceof CommentStoreComment ) ) {
+ $summary = CommentStoreComment::newUnsavedComment( trim( $summary ) );
}
- // Low-level sanity check
- if ( $this->mTitle->getText() === '' ) {
- throw new MWException( 'Something is trying to edit an article with an empty title' );
- }
- // Make sure the given content type is allowed for this page
- if ( !$content->getContentHandler()->canBeUsedOn( $this->mTitle ) ) {
- return Status::newFatal( 'content-not-allowed-here',
- ContentHandler::getLocalizedName( $content->getModel() ),
- $this->mTitle->getPrefixedText()
- );
- }
-
- // Load the data from the master database if needed.
- // The caller may already loaded it from the master or even loaded it using
- // SELECT FOR UPDATE, so do not override that using clear().
- $this->loadPageData( 'fromdbmaster' );
-
- $user = $user ?: $wgUser;
- $flags = $this->checkFlags( $flags );
-
- // Avoid PHP 7.1 warning of passing $this by reference
- $wikiPage = $this;
-
- // Trigger pre-save hook (using provided edit summary)
- $hookStatus = Status::newGood( [] );
- $hook_args = [ &$wikiPage, &$user, &$content, &$summary,
- $flags & EDIT_MINOR, null, null, &$flags, &$hookStatus ];
- // Check if the hook rejected the attempted save
- if ( !Hooks::run( 'PageContentSave', $hook_args ) ) {
- if ( $hookStatus->isOK() ) {
- // Hook returned false but didn't call fatal(); use generic message
- $hookStatus->fatal( 'edit-hook-aborted' );
- }
-
- return $hookStatus;
- }
-
- $old_revision = $this->getRevision(); // current revision
- $old_content = $this->getContent( Revision::RAW ); // current revision's content
-
- $handler = $content->getContentHandler();
- $tag = $handler->getChangeTag( $old_content, $content, $flags );
- // If there is no applicable tag, null is returned, so we need to check
- if ( $tag ) {
- $tags[] = $tag;
- }
-
- // Check for undo tag
- if ( $undidRevId !== 0 && in_array( 'mw-undo', ChangeTags::getSoftwareTags() ) ) {
- $tags[] = 'mw-undo';
- }
-
- // Provide autosummaries if summary is not provided and autosummaries are enabled
- if ( $wgUseAutomaticEditSummaries && ( $flags & EDIT_AUTOSUMMARY ) && $summary == '' ) {
- $summary = $handler->getAutosummary( $old_content, $content, $flags );
- }
-
- // Avoid statsd noise and wasted cycles check the edit stash (T136678)
- if ( ( $flags & EDIT_INTERNAL ) || ( $flags & EDIT_FORCE_BOT ) ) {
- $useCache = false;
- } else {
- $useCache = true;
- }
-
- // Get the pre-save transform content and final parser output
- $editInfo = $this->prepareContentForEdit( $content, null, $user, $serialFormat, $useCache );
- $pstContent = $editInfo->pstContent; // Content object
- $meta = [
- 'bot' => ( $flags & EDIT_FORCE_BOT ),
- 'minor' => ( $flags & EDIT_MINOR ) && $user->isAllowed( 'minoredit' ),
- 'serialized' => $pstContent->serialize( $serialFormat ),
- 'serialFormat' => $serialFormat,
- 'baseRevId' => $baseRevId,
- 'oldRevision' => $old_revision,
- 'oldContent' => $old_content,
- 'oldId' => $this->getLatest(),
- 'oldIsRedirect' => $this->isRedirect(),
- 'oldCountable' => $this->isCountable(),
- 'tags' => ( $tags !== null ) ? (array)$tags : [],
- 'undidRevId' => $undidRevId
- ];
-
- // Actually create the revision and create/update the page
- if ( $flags & EDIT_UPDATE ) {
- $status = $this->doModify( $pstContent, $flags, $user, $summary, $meta );
- } else {
- $status = $this->doCreate( $pstContent, $flags, $user, $summary, $meta );
+ if ( !$user ) {
+ $user = $wgUser;
}
- // Promote user to any groups they meet the criteria for
- DeferredUpdates::addCallableUpdate( function () use ( $user ) {
- $user->addAutopromoteOnceGroups( 'onEdit' );
- $user->addAutopromoteOnceGroups( 'onView' ); // b/c
- } );
-
- return $status;
- }
-
- /**
- * @param Content $content Pre-save transform content
- * @param int $flags
- * @param User $user
- * @param string $summary
- * @param array $meta
- * @return Status
- * @throws DBUnexpectedError
- * @throws Exception
- * @throws FatalError
- * @throws MWException
- */
- private function doModify(
- Content $content, $flags, User $user, $summary, array $meta
- ) {
- global $wgUseRCPatrol;
-
- // Update article, but only if changed.
- $status = Status::newGood( [ 'new' => false, 'revision' => null ] );
-
- // Convenience variables
- $now = wfTimestampNow();
- $oldid = $meta['oldId'];
- /** @var Content|null $oldContent */
- $oldContent = $meta['oldContent'];
- $newsize = $content->getSize();
-
- if ( !$oldid ) {
- // Article gone missing
- $status->fatal( 'edit-gone-missing' );
-
- return $status;
- } elseif ( !$oldContent ) {
- // Sanity check for T39225
- throw new MWException( "Could not find text for current revision {$oldid}." );
+ // TODO: this check is here for backwards-compatibility with 1.31 behavior.
+ // Checking the minoredit right should be done in the same place the 'bot' right is
+ // checked for the EDIT_FORCE_BOT flag, which is currently in EditPage::attemptSave.
+ if ( ( $flags & EDIT_MINOR ) && !$user->isAllowed( 'minoredit' ) ) {
+ $flags = ( $flags & ~EDIT_MINOR );
}
- $changed = !$content->equals( $oldContent );
-
- $dbw = wfGetDB( DB_MASTER );
+ // NOTE: while doEditContent() executes, callbacks to getDerivedDataUpdater and
+ // prepareContentForEdit will generally use the DerivedPageDataUpdater that is also
+ // used by this PageUpdater. However, there is no guarantee for this.
+ $updater = $this->newPageUpdater( $user );
+ $updater->setContent( 'main', $content );
+ $updater->setBaseRevisionId( $baseRevId );
+ $updater->setUndidRevisionId( $undidRevId );
- if ( $changed ) {
- // @TODO: pass content object?!
- $revision = new Revision( [
- 'page' => $this->getId(),
- 'title' => $this->mTitle, // for determining the default content model
- 'comment' => $summary,
- 'minor_edit' => $meta['minor'],
- 'text' => $meta['serialized'],
- 'len' => $newsize,
- 'parent_id' => $oldid,
- 'user' => $user->getId(),
- 'user_text' => $user->getName(),
- 'timestamp' => $now,
- 'content_model' => $content->getModel(),
- 'content_format' => $meta['serialFormat'],
- ] );
+ $needsPatrol = $wgUseRCPatrol || ( $wgUseNPPatrol && !$this->exists() );
- $prepStatus = $content->prepareSave( $this, $flags, $oldid, $user );
- $status->merge( $prepStatus );
- if ( !$status->isOK() ) {
- return $status;
- }
-
- $dbw->startAtomic( __METHOD__ );
- // Get the latest page_latest value while locking it.
- // Do a CAS style check to see if it's the same as when this method
- // started. If it changed then bail out before touching the DB.
- $latestNow = $this->lockAndGetLatest();
- if ( $latestNow != $oldid ) {
- $dbw->endAtomic( __METHOD__ );
- // Page updated or deleted in the mean time
- $status->fatal( 'edit-conflict' );
-
- return $status;
- }
-
- // At this point we are now comitted to returning an OK
- // status unless some DB query error or other exception comes up.
- // This way callers don't have to call rollback() if $status is bad
- // unless they actually try to catch exceptions (which is rare).
-
- // Save the revision text
- $revisionId = $revision->insertOn( $dbw );
- // Update page_latest and friends to reflect the new revision
- if ( !$this->updateRevisionOn( $dbw, $revision, null, $meta['oldIsRedirect'] ) ) {
- throw new MWException( "Failed to update page row to use new revision." );
- }
-
- $tags = $meta['tags'];
- Hooks::run( 'NewRevisionFromEditComplete',
- [ $this, $revision, $meta['baseRevId'], $user, &$tags ] );
-
- // Update recentchanges
- if ( !( $flags & EDIT_SUPPRESS_RC ) ) {
- // Mark as patrolled if the user can do so
- $autopatrolled = $wgUseRCPatrol && !count(
- $this->mTitle->getUserPermissionsErrors( 'autopatrol', $user ) );
- // Add RC row to the DB
- RecentChange::notifyEdit(
- $now,
- $this->mTitle,
- $revision->isMinor(),
- $user,
- $summary,
- $oldid,
- $this->getTimestamp(),
- $meta['bot'],
- '',
- $oldContent ? $oldContent->getSize() : 0,
- $newsize,
- $revisionId,
- $autopatrolled ? RecentChange::PRC_AUTOPATROLLED :
- RecentChange::PRC_UNPATROLLED,
- $tags
- );
- }
-
- $user->incEditCount();
-
- $dbw->endAtomic( __METHOD__ );
- $this->mTimestamp = $now;
- } else {
- // T34948: revision ID must be set to page {{REVISIONID}} and
- // related variables correctly. Likewise for {{REVISIONUSER}} (T135261).
- // Since we don't insert a new revision into the database, the least
- // error-prone way is to reuse given old revision.
- $revision = $meta['oldRevision'];
+ // TODO: this logic should not be in the storage layer, it's here for compatibility
+ // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same
+ // place the 'bot' right is handled, which is currently in EditPage::attemptSave.
+ if ( $needsPatrol && $this->getTitle()->userCan( 'autopatrol', $user ) ) {
+ $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
}
- if ( $changed ) {
- // Return the new revision to the caller
- $status->value['revision'] = $revision;
- } else {
- $status->warning( 'edit-no-change' );
- // Update page_touched as updateRevisionOn() was not called.
- // Other cache updates are managed in onArticleEdit() via doEditUpdates().
- $this->mTitle->invalidateCache( $now );
- }
+ $updater->addTags( $tags );
- // Do secondary updates once the main changes have been committed...
- DeferredUpdates::addUpdate(
- new AtomicSectionUpdate(
- $dbw,
- __METHOD__,
- function () use (
- $revision, &$user, $content, $summary, &$flags,
- $changed, $meta, &$status
- ) {
- // Update links tables, site stats, etc.
- $this->doEditUpdates(
- $revision,
- $user,
- [
- 'changed' => $changed,
- 'oldcountable' => $meta['oldCountable'],
- 'oldrevision' => $meta['oldRevision']
- ]
- );
- // Avoid PHP 7.1 warning of passing $this by reference
- $wikiPage = $this;
- // Trigger post-save hook
- $params = [ &$wikiPage, &$user, $content, $summary, $flags & EDIT_MINOR,
- null, null, &$flags, $revision, &$status, $meta['baseRevId'],
- $meta['undidRevId'] ];
- Hooks::run( 'PageContentSaveComplete', $params );
- }
- ),
- DeferredUpdates::PRESEND
+ $revRec = $updater->saveRevision(
+ $summary,
+ $flags
);
- return $status;
- }
-
- /**
- * @param Content $content Pre-save transform content
- * @param int $flags
- * @param User $user
- * @param string $summary
- * @param array $meta
- * @return Status
- * @throws DBUnexpectedError
- * @throws Exception
- * @throws FatalError
- * @throws MWException
- */
- private function doCreate(
- Content $content, $flags, User $user, $summary, array $meta
- ) {
- global $wgUseRCPatrol, $wgUseNPPatrol, $wgPageCreationLog;
-
- $status = Status::newGood( [ 'new' => true, 'revision' => null ] );
-
- $now = wfTimestampNow();
- $newsize = $content->getSize();
- $prepStatus = $content->prepareSave( $this, $flags, $meta['oldId'], $user );
- $status->merge( $prepStatus );
- if ( !$status->isOK() ) {
- return $status;
- }
-
- $dbw = wfGetDB( DB_MASTER );
- $dbw->startAtomic( __METHOD__ );
-
- // Add the page record unless one already exists for the title
- $newid = $this->insertOn( $dbw );
- if ( $newid === false ) {
- $dbw->endAtomic( __METHOD__ ); // nothing inserted
- $status->fatal( 'edit-already-exists' );
-
- return $status; // nothing done
- }
-
- // At this point we are now comitted to returning an OK
- // status unless some DB query error or other exception comes up.
- // This way callers don't have to call rollback() if $status is bad
- // unless they actually try to catch exceptions (which is rare).
-
- // @TODO: pass content object?!
- $revision = new Revision( [
- 'page' => $newid,
- 'title' => $this->mTitle, // for determining the default content model
- 'comment' => $summary,
- 'minor_edit' => $meta['minor'],
- 'text' => $meta['serialized'],
- 'len' => $newsize,
- 'user' => $user->getId(),
- 'user_text' => $user->getName(),
- 'timestamp' => $now,
- 'content_model' => $content->getModel(),
- 'content_format' => $meta['serialFormat'],
- ] );
-
- // Save the revision text...
- $revisionId = $revision->insertOn( $dbw );
- // Update the page record with revision data
- if ( !$this->updateRevisionOn( $dbw, $revision, 0 ) ) {
- throw new MWException( "Failed to update page row to use new revision." );
- }
-
- Hooks::run( 'NewRevisionFromEditComplete', [ $this, $revision, false, $user ] );
-
- // Update recentchanges
- if ( !( $flags & EDIT_SUPPRESS_RC ) ) {
- // Mark as patrolled if the user can do so
- $patrolled = ( $wgUseRCPatrol || $wgUseNPPatrol ) &&
- !count( $this->mTitle->getUserPermissionsErrors( 'autopatrol', $user ) );
- // Add RC row to the DB
- RecentChange::notifyNew(
- $now,
- $this->mTitle,
- $revision->isMinor(),
- $user,
- $summary,
- $meta['bot'],
- '',
- $newsize,
- $revisionId,
- $patrolled,
- $meta['tags']
- );
- }
-
- $user->incEditCount();
-
- if ( $wgPageCreationLog ) {
- // Log the page creation
- // @TODO: Do we want a 'recreate' action?
- $logEntry = new ManualLogEntry( 'create', 'create' );
- $logEntry->setPerformer( $user );
- $logEntry->setTarget( $this->mTitle );
- $logEntry->setComment( $summary );
- $logEntry->setTimestamp( $now );
- $logEntry->setAssociatedRevId( $revisionId );
- $logid = $logEntry->insert();
- // Note that we don't publish page creation events to recentchanges
- // (i.e. $logEntry->publish()) since this would create duplicate entries,
- // one for the edit and one for the page creation.
+ // $revRec will be null if the edit failed, or if no new revision was created because
+ // the content did not change.
+ if ( $revRec ) {
+ // update cached fields
+ // TODO: this is currently redundant to what is done in updateRevisionOn.
+ // But updateRevisionOn() should move into PageStore, and then this will be needed.
+ $this->setLastEdit( new Revision( $revRec ) ); // TODO: use RevisionRecord
+ $this->mLatest = $revRec->getId();
}
- $dbw->endAtomic( __METHOD__ );
- $this->mTimestamp = $now;
-
- // Return the new revision to the caller
- $status->value['revision'] = $revision;
-
- // Do secondary updates once the main changes have been committed...
- DeferredUpdates::addUpdate(
- new AtomicSectionUpdate(
- $dbw,
- __METHOD__,
- function () use (
- $revision, &$user, $content, $summary, &$flags, $meta, &$status
- ) {
- // Update links, etc.
- $this->doEditUpdates( $revision, $user, [ 'created' => true ] );
- // Avoid PHP 7.1 warning of passing $this by reference
- $wikiPage = $this;
- // Trigger post-create hook
- $params = [ &$wikiPage, &$user, $content, $summary,
- $flags & EDIT_MINOR, null, null, &$flags, $revision ];
- Hooks::run( 'PageContentInsertComplete', $params );
- // Trigger post-save hook
- $params = array_merge( $params, [ &$status, $meta['baseRevId'], 0 ] );
- Hooks::run( 'PageContentSaveComplete', $params );
- }
- ),
- DeferredUpdates::PRESEND
- );
-
- return $status;
+ return $updater->getStatus();
}
/**
/**
* Prepare content which is about to be saved.
*
- * Prior to 1.30, this returned a stdClass object with the same class
- * members.
+ * Prior to 1.30, this returned a stdClass.
+ *
+ * @deprecated since 1.32, use getDerivedDataUpdater instead.
*
* @param Content $content
- * @param Revision|int|null $revision Revision object. For backwards compatibility, a
- * revision ID is also accepted, but this is deprecated.
+ * @param Revision|RevisionRecord|int|null $revision Revision object.
+ * For backwards compatibility, a revision ID is also accepted,
+ * but this is deprecated.
+ * Used with vary-revision or vary-revision-id.
* @param User|null $user
- * @param string|null $serialFormat
+ * @param string|null $serialFormat IGNORED
* @param bool $useCache Check shared prepared edit cache
*
* @return PreparedEdit
* @since 1.21
*/
public function prepareContentForEdit(
- Content $content, $revision = null, User $user = null,
- $serialFormat = null, $useCache = true
+ Content $content,
+ $revision = null,
+ User $user = null,
+ $serialFormat = null,
+ $useCache = true
) {
- global $wgContLang, $wgUser, $wgAjaxEditStash;
+ global $wgUser;
- if ( is_object( $revision ) ) {
- $revid = $revision->getId();
- } else {
+ if ( !$user ) {
+ $user = $wgUser;
+ }
+
+ if ( !is_object( $revision ) ) {
$revid = $revision;
// This code path is deprecated, and nothing is known to
// use it, so performance here shouldn't be a worry.
if ( $revid !== null ) {
wfDeprecated( __METHOD__ . ' with $revision = revision ID', '1.25' );
- $revision = Revision::newFromId( $revid, Revision::READ_LATEST );
+ $store = $this->getRevisionStore();
+ $revision = $store->getRevisionById( $revid, Revision::READ_LATEST );
} else {
$revision = null;
}
+ } elseif ( $revision instanceof Revision ) {
+ $revision = $revision->getRevisionRecord();
}
- $user = is_null( $user ) ? $wgUser : $user;
- // XXX: check $user->getId() here???
+ $slots = RevisionSlotsUpdate::newFromContent( [ 'main' => $content ] );
+ $updater = $this->getDerivedDataUpdater( $user, $revision, $slots );
- // Use a sane default for $serialFormat, see T59026
- if ( $serialFormat === null ) {
- $serialFormat = $content->getContentHandler()->getDefaultFormat();
- }
+ if ( !$updater->isUpdatePrepared() ) {
+ $updater->prepareContent( $user, $slots, [], $useCache );
- if ( $this->mPreparedEdit
- && isset( $this->mPreparedEdit->newContent )
- && $this->mPreparedEdit->newContent->equals( $content )
- && $this->mPreparedEdit->revid == $revid
- && $this->mPreparedEdit->format == $serialFormat
- // XXX: also check $user here?
- ) {
- // Already prepared
- return $this->mPreparedEdit;
- }
-
- // The edit may have already been prepared via api.php?action=stashedit
- $cachedEdit = $useCache && $wgAjaxEditStash
- ? ApiStashEdit::checkCache( $this->getTitle(), $content, $user )
- : false;
-
- $popts = ParserOptions::newFromUserAndLang( $user, $wgContLang );
- Hooks::run( 'ArticlePrepareTextForEdit', [ $this, $popts ] );
-
- $edit = new PreparedEdit();
- if ( $cachedEdit ) {
- $edit->timestamp = $cachedEdit->timestamp;
- } else {
- $edit->timestamp = wfTimestampNow();
- }
- // @note: $cachedEdit is safely not used if the rev ID was referenced in the text
- $edit->revid = $revid;
-
- if ( $cachedEdit ) {
- $edit->pstContent = $cachedEdit->pstContent;
- } else {
- $edit->pstContent = $content
- ? $content->preSaveTransform( $this->mTitle, $user, $popts )
- : null;
- }
-
- $edit->format = $serialFormat;
- $edit->popts = $this->makeParserOptions( 'canonical' );
- if ( $cachedEdit ) {
- $edit->output = $cachedEdit->output;
- } else {
if ( $revision ) {
- // We get here if vary-revision is set. This means that this page references
- // itself (such as via self-transclusion). In this case, we need to make sure
- // that any such self-references refer to the newly-saved revision, and not
- // to the previous one, which could otherwise happen due to replica DB lag.
- $oldCallback = $edit->popts->getCurrentRevisionCallback();
- $edit->popts->setCurrentRevisionCallback(
- function ( Title $title, $parser = false ) use ( $revision, &$oldCallback ) {
- if ( $title->equals( $revision->getTitle() ) ) {
- return $revision;
- } else {
- return call_user_func( $oldCallback, $title, $parser );
- }
- }
- );
- } else {
- // Try to avoid a second parse if {{REVISIONID}} is used
- $dbIndex = ( $this->mDataLoadedFrom & self::READ_LATEST ) === self::READ_LATEST
- ? DB_MASTER // use the best possible guess
- : DB_REPLICA; // T154554
-
- $edit->popts->setSpeculativeRevIdCallback( function () use ( $dbIndex ) {
- $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
- // Use a fresh connection in order to see the latest data, by avoiding
- // stale data from REPEATABLE-READ snapshots.
- $db = $lb->getConnectionRef( $dbIndex, [], false, $lb::CONN_TRX_AUTO );
-
- return 1 + (int)$db->selectField(
- 'revision',
- 'MAX(rev_id)',
- [],
- __METHOD__
- );
- } );
+ $updater->prepareUpdate( $revision );
}
- $edit->output = $edit->pstContent
- ? $edit->pstContent->getParserOutput( $this->mTitle, $revid, $edit->popts )
- : null;
}
- $edit->newContent = $content;
- $edit->oldContent = $this->getContent( Revision::RAW );
-
- if ( $edit->output ) {
- $edit->output->setCacheTime( wfTimestampNow() );
- }
-
- // Process cache the result
- $this->mPreparedEdit = $edit;
-
- return $edit;
+ return $updater->getPreparedEdit();
}
/**
* Purges pages that include this page if the text was changed here.
* Every 100th edit, prune the recent changes table.
*
+ * @deprecated since 1.32, use PageUpdater::doEditUpdates instead.
+ *
* @param Revision $revision
* @param User $user User object that did the revision
* @param array $options Array of options, following indexes are used:
* - 'no-change': don't update the article count, ever
*/
public function doEditUpdates( Revision $revision, User $user, array $options = [] ) {
- global $wgRCWatchCategoryMembership;
-
- $options += [
- 'changed' => true,
- 'created' => false,
- 'moved' => false,
- 'restored' => false,
- 'oldrevision' => null,
- 'oldcountable' => null
- ];
- $content = $revision->getContent();
-
- $logger = LoggerFactory::getInstance( 'SaveParse' );
-
- // See if the parser output before $revision was inserted is still valid
- $editInfo = false;
- if ( !$this->mPreparedEdit ) {
- $logger->debug( __METHOD__ . ": No prepared edit...\n" );
- } elseif ( $this->mPreparedEdit->output->getFlag( 'vary-revision' ) ) {
- $logger->info( __METHOD__ . ": Prepared edit has vary-revision...\n" );
- } elseif ( $this->mPreparedEdit->output->getFlag( 'vary-revision-id' )
- && $this->mPreparedEdit->output->getSpeculativeRevIdUsed() !== $revision->getId()
- ) {
- $logger->info( __METHOD__ . ": Prepared edit has vary-revision-id with wrong ID...\n" );
- } elseif ( $this->mPreparedEdit->output->getFlag( 'vary-user' ) && !$options['changed'] ) {
- $logger->info( __METHOD__ . ": Prepared edit has vary-user and is null...\n" );
- } else {
- wfDebug( __METHOD__ . ": Using prepared edit...\n" );
- $editInfo = $this->mPreparedEdit;
- }
-
- if ( !$editInfo ) {
- // Parse the text again if needed. Be careful not to do pre-save transform twice:
- // $text is usually already pre-save transformed once. Avoid using the edit stash
- // as any prepared content from there or in doEditContent() was already rejected.
- $editInfo = $this->prepareContentForEdit( $content, $revision, $user, null, false );
- }
-
- // Save it to the parser cache.
- // Make sure the cache time matches page_touched to avoid double parsing.
- MediaWikiServices::getInstance()->getParserCache()->save(
- $editInfo->output, $this, $editInfo->popts,
- $revision->getTimestamp(), $editInfo->revid
- );
-
- // Update the links tables and other secondary data
- if ( $content ) {
- $recursive = $options['changed']; // T52785
- $updates = $content->getSecondaryDataUpdates(
- $this->getTitle(), null, $recursive, $editInfo->output
- );
- foreach ( $updates as $update ) {
- $update->setCause( 'edit-page', $user->getName() );
- if ( $update instanceof LinksUpdate ) {
- $update->setRevision( $revision );
- $update->setTriggeringUser( $user );
- }
- DeferredUpdates::addUpdate( $update );
- }
- if ( $wgRCWatchCategoryMembership
- && $this->getContentHandler()->supportsCategories() === true
- && ( $options['changed'] || $options['created'] )
- && !$options['restored']
- ) {
- // Note: jobs are pushed after deferred updates, so the job should be able to see
- // the recent change entry (also done via deferred updates) and carry over any
- // bot/deletion/IP flags, ect.
- JobQueueGroup::singleton()->lazyPush( new CategoryMembershipChangeJob(
- $this->getTitle(),
- [
- 'pageId' => $this->getId(),
- 'revTimestamp' => $revision->getTimestamp()
- ]
- ) );
- }
- }
-
- // Avoid PHP 7.1 warning of passing $this by reference
- $wikiPage = $this;
-
- Hooks::run( 'ArticleEditUpdates', [ &$wikiPage, &$editInfo, $options['changed'] ] );
-
- if ( Hooks::run( 'ArticleEditUpdatesDeleteFromRecentchanges', [ &$wikiPage ] ) ) {
- // Flush old entries from the `recentchanges` table
- if ( mt_rand( 0, 9 ) == 0 ) {
- JobQueueGroup::singleton()->lazyPush( RecentChangesUpdateJob::newPurgeJob() );
- }
- }
-
- if ( !$this->exists() ) {
- return;
- }
-
- $id = $this->getId();
- $title = $this->mTitle->getPrefixedDBkey();
- $shortTitle = $this->mTitle->getDBkey();
-
- if ( $options['oldcountable'] === 'no-change' ||
- ( !$options['changed'] && !$options['moved'] )
- ) {
- $good = 0;
- } elseif ( $options['created'] ) {
- $good = (int)$this->isCountable( $editInfo );
- } elseif ( $options['oldcountable'] !== null ) {
- $good = (int)$this->isCountable( $editInfo ) - (int)$options['oldcountable'];
- } else {
- $good = 0;
- }
- $edits = $options['changed'] ? 1 : 0;
- $pages = $options['created'] ? 1 : 0;
+ $revision = $revision->getRevisionRecord();
- DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
- [ 'edits' => $edits, 'articles' => $good, 'pages' => $pages ]
- ) );
- DeferredUpdates::addUpdate( new SearchUpdate( $id, $title, $content ) );
-
- // If this is another user's talk page, update newtalk.
- // Don't do this if $options['changed'] = false (null-edits) nor if
- // it's a minor edit and the user doesn't want notifications for those.
- if ( $options['changed']
- && $this->mTitle->getNamespace() == NS_USER_TALK
- && $shortTitle != $user->getTitleKey()
- && !( $revision->isMinor() && $user->isAllowed( 'nominornewtalk' ) )
- ) {
- $recipient = User::newFromName( $shortTitle, false );
- if ( !$recipient ) {
- wfDebug( __METHOD__ . ": invalid username\n" );
- } else {
- // Avoid PHP 7.1 warning of passing $this by reference
- $wikiPage = $this;
-
- // Allow extensions to prevent user notification
- // when a new message is added to their talk page
- if ( Hooks::run( 'ArticleEditUpdateNewTalk', [ &$wikiPage, $recipient ] ) ) {
- if ( User::isIP( $shortTitle ) ) {
- // An anonymous user
- $recipient->setNewtalk( true, $revision );
- } elseif ( $recipient->isLoggedIn() ) {
- $recipient->setNewtalk( true, $revision );
- } else {
- wfDebug( __METHOD__ . ": don't need to notify a nonexistent user\n" );
- }
- }
- }
- }
+ $updater = $this->getDerivedDataUpdater( $user, $revision );
- if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI ) {
- MessageCache::singleton()->updateMessageOverride( $this->mTitle, $content );
- }
+ $updater->prepareUpdate( $revision, $options );
- if ( $options['created'] ) {
- self::onArticleCreate( $this->mTitle );
- } elseif ( $options['changed'] ) { // T52785
- self::onArticleEdit( $this->mTitle, $revision );
- }
-
- ResourceLoaderWikiModule::invalidateModuleCache(
- $this->mTitle, $options['oldrevision'], $revision, wfWikiID()
- );
+ $updater->doUpdates();
}
/**
// Take this opportunity to purge out expired restrictions
Title::purgeExpiredRestrictions();
- // @todo FIXME: Same limitations as described in ProtectionForm.php (line 37);
+ // @todo: Same limitations as described in ProtectionForm.php (line 37);
// we expect a single selection, but the schema allows otherwise.
$isProtected = false;
$protect = false;
$tags = [], $logsubtype = 'delete'
) {
global $wgUser, $wgContentHandlerUseDB, $wgCommentTableSchemaMigrationStage,
- $wgActorTableSchemaMigrationStage;
+ $wgActorTableSchemaMigrationStage, $wgMultiContentRevisionSchemaMigrationStage;
wfDebug( __METHOD__ . "\n" );
// Note array_intersect() preserves keys from the first arg, and we're
// assuming $revQuery has `revision` primary and isn't using subtables
// for anything we care about.
+ $tablesFlat = [];
+ array_walk_recursive(
+ $revQuery['tables'],
+ function ( $a ) use ( &$tablesFlat ) {
+ $tablesFlat[] = $a;
+ }
+ );
+
$res = $dbw->select(
array_intersect(
- $revQuery['tables'],
+ $tablesFlat,
[ 'revision', 'revision_comment_temp', 'revision_actor_temp' ]
),
'1',
'ar_minor_edit' => $row->rev_minor_edit,
'ar_rev_id' => $row->rev_id,
'ar_parent_id' => $row->rev_parent_id,
- 'ar_text_id' => $row->rev_text_id,
+ /**
+ * ar_text_id should probably not be written to when the multi content schema has
+ * been migrated to (wgMultiContentRevisionSchemaMigrationStage) however there is no
+ * default for the field in WMF production currently so we must keep writing
+ * writing until a default of 0 is set.
+ * Task: https://phabricator.wikimedia.org/T190148
+ * Copying the value from the revision table should not lead to any issues for now.
+ */
'ar_len' => $row->rev_len,
'ar_page_id' => $id,
'ar_deleted' => $suppress ? $bitfield : $row->rev_deleted,
'ar_sha1' => $row->rev_sha1,
] + $commentStore->insert( $dbw, 'ar_comment', $comment )
+ $actorMigration->getInsertValues( $dbw, 'ar_user', $user );
- if ( $wgContentHandlerUseDB ) {
+
+ if ( $wgMultiContentRevisionSchemaMigrationStage < MIGRATION_NEW ) {
+ $rowInsert['ar_text_id'] = $row->rev_text_id;
+ }
+
+ if (
+ $wgContentHandlerUseDB &&
+ $wgMultiContentRevisionSchemaMigrationStage <= MIGRATION_WRITE_BOTH
+ ) {
$rowInsert['ar_content_model'] = $row->rev_content_model;
$rowInsert['ar_content_format'] = $row->rev_content_format;
}
* @param Title $title
*/
public static function onArticleCreate( Title $title ) {
+ // TODO: move this into a PageEventEmitter service
+
// Update existence markers on article/talk tabs...
$other = $title->getOtherPage();
* @param Title $title
*/
public static function onArticleDelete( Title $title ) {
+ // TODO: move this into a PageEventEmitter service
+
// Update existence markers on article/talk tabs...
// Clear Backlink cache first so that purge jobs use more up-to-date backlink information
BacklinkCache::get( $title )->clear();
*
* @param Title $title
* @param Revision|null $revision Revision that was just saved, may be null
+ * @param string[]|null $slotsChanged The role names of the slots that were changed.
+ * If not given, all slots are assumed to have changed.
*/
- public static function onArticleEdit( Title $title, Revision $revision = null ) {
- // Invalidate caches of articles which include this page
- DeferredUpdates::addUpdate(
- new HTMLCacheUpdate( $title, 'templatelinks', 'page-edit' )
- );
+ public static function onArticleEdit(
+ Title $title,
+ Revision $revision = null,
+ $slotsChanged = null
+ ) {
+ // TODO: move this into a PageEventEmitter service
+
+ if ( $slotsChanged === null || in_array( 'main', $slotsChanged ) ) {
+ // Invalidate caches of articles which include this page.
+ // Only for the main slot, because only the main slot is transcluded.
+ // TODO: MCR: not true for TemplateStyles! [SlotHandler]
+ DeferredUpdates::addUpdate(
+ new HTMLCacheUpdate( $title, 'templatelinks', 'page-edit' )
+ );
+ }
// Invalidate the caches of all pages which redirect here
DeferredUpdates::addUpdate(
return $linkCache->getMutableCacheKeys( $cache, $this->getTitle()->getTitleValue() );
}
+
}
$parser->mOutput->setDisplayTitle( $text );
}
if ( $old !== false && $old !== $text && !$arg ) {
- $converter = $parser->getConverterLanguage()->getConverter();
+ $converter = $parser->getTargetLanguage()->getConverter();
return '<span class="error">' .
wfMessage( 'duplicate-displaytitle',
// Message should be parsed, but these params should only be escaped.
return '';
}
} else {
- $converter = $parser->getConverterLanguage()->getConverter();
+ $converter = $parser->getTargetLanguage()->getConverter();
$parser->getOutput()->addWarning(
wfMessage( 'restricted-displaytitle',
// Message should be parsed, but this param should only be escaped.
if ( $old === false || $old == $text || $arg ) {
return '';
} else {
- $converter = $parser->getConverterLanguage()->getConverter();
+ $converter = $parser->getTargetLanguage()->getConverter();
return '<span class="error">' .
wfMessage( 'duplicate-defaultsort',
// Message should be parsed, but these params should only be escaped.
}
// Check the link cache, maybe something already looked it up.
- $linkCache = LinkCache::singleton();
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
$pdbk = $t->getPrefixedDBkey();
$id = $linkCache->getGoodLinkID( $pdbk );
if ( $id != 0 ) {
* @ingroup Parser
*/
+use MediaWiki\MediaWikiServices;
+
/**
* @ingroup Parser
*/
global $wgContLang;
$colours = [];
- $linkCache = LinkCache::singleton();
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
$output = $this->parent->getOutput();
$linkRenderer = $this->parent->getLinkRenderer();
$linkBatch = new LinkBatch();
$variantMap = []; // maps $pdbkey_Variant => $keys (of link holders)
$output = $this->parent->getOutput();
- $linkCache = LinkCache::singleton();
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
$titlesToBeConverted = '';
$titlesAttrs = [];
|| isset( $this->mDoubleUnderscores['notitleconvert'] )
|| $this->mOutput->getDisplayTitle() !== false )
) {
- $convruletitle = $this->getConverterLanguage()->getConvRuleTitle();
+ $convruletitle = $this->getTargetLanguage()->getConvRuleTitle();
if ( $convruletitle ) {
$this->mOutput->setTitleText( $convruletitle );
} else {
- $titleText = $this->getConverterLanguage()->convertTitle( $title );
+ $titleText = $this->getTargetLanguage()->convertTitle( $title );
$this->mOutput->setTitleText( $titleText );
}
}
/**
* Get the language object for language conversion
+ * @deprecated since 1.32, just use getTargetLanguage()
* @return Language|null
*/
public function getConverterLanguage() {
# The position of the convert() call should not be changed. it
# assumes that the links are all replaced and the only thing left
# is the <nowiki> mark.
- $text = $this->getConverterLanguage()->convert( $text );
+ $text = $this->getTargetLanguage()->convert( $text );
}
}
if ( $text === false ) {
# Not an image, make a link
$text = Linker::makeExternalLink( $url,
- $this->getConverterLanguage()->markNoConversion( $url, true ),
+ $this->getTargetLanguage()->getConverter()->markNoConversion( $url ),
true, 'free',
$this->getExternalLinkAttribs( $url ), $this->mTitle );
# Register it in the output object...
list( $dtrail, $trail ) = Linker::splitTrail( $trail );
}
- $text = $this->getConverterLanguage()->markNoConversion( $text );
+ // Excluding protocol-relative URLs may avoid many false positives.
+ if ( preg_match( '/^(?:' . wfUrlProtocolsWithoutProtRel() . ')/', $text ) ) {
+ $text = $this->getTargetLanguage()->getConverter()->markNoConversion( $text );
+ }
$url = Sanitizer::cleanUrl( $url );
}
$sortkey = Sanitizer::decodeCharReferences( $sortkey );
$sortkey = str_replace( "\n", '', $sortkey );
- $sortkey = $this->getConverterLanguage()->convertCategoryKey( $sortkey );
+ $sortkey = $this->getTargetLanguage()->convertCategoryKey( $sortkey );
$this->mOutput->addCategory( $nt->getDBkey(), $sortkey );
continue;
if ( $title ) {
$titleText = $title->getPrefixedText();
# Check for language variants if the template is not found
- if ( $this->getConverterLanguage()->hasVariants() && $title->getArticleID() == 0 ) {
- $this->getConverterLanguage()->findVariantLink( $part1, $title, true );
+ if ( $this->getTargetLanguage()->hasVariants() && $title->getArticleID() == 0 ) {
+ $this->getTargetLanguage()->findVariantLink( $part1, $title, true );
}
# Do recursion depth check
$limit = $this->mOptions->getMaxTemplateDepth();
$rev_id = $rev ? $rev->getId() : 0;
# If there is no current revision, there is no page
if ( $id === false && !$rev ) {
- $linkCache = LinkCache::singleton();
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
$linkCache->addBadLinkObj( $title );
}
* @file
* @ingroup Parser
*/
+
+use MediaWiki\MediaWikiServices;
use Wikimedia\ScopedCallback;
/**
};
end( $wgHooks['TitleExists'] );
$key = key( $wgHooks['TitleExists'] );
- LinkCache::singleton()->clearBadLink( $title->getPrefixedDBkey() );
- return new ScopedCallback( function () use ( $title, $key ) {
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
+ $linkCache->clearBadLink( $title->getPrefixedDBkey() );
+ return new ScopedCallback( function () use ( $title, $key, $linkCache ) {
global $wgHooks;
unset( $wgHooks['TitleExists'][$key] );
- LinkCache::singleton()->clearLink( $title );
+ $linkCache->clearLink( $title );
} );
}
}
}
// Language
- $languages = Language::fetchLanguageNames( null, 'mw' );
+ $languages = Language::fetchLanguageNames( null, 'mwfile' );
$languageCode = $this->config->get( 'LanguageCode' );
if ( !array_key_exists( $languageCode, $languages ) ) {
$languages[$languageCode] = $languageCode;
/**
* Ensure the styles of one or more modules are loaded.
*
- * @deprecated since 1.28
* @param array $modules Array of module names
*/
public function setModuleStyles( array $modules ) {
) {
static $formats = [ CONTENT_FORMAT_CSS, CONTENT_FORMAT_JAVASCRIPT ];
+ // TODO: MCR: differentiate between page functionality and content model!
+ // Not all pages containing CSS or JS have to be modules! [PageType]
if ( $old && in_array( $old->getContentFormat(), $formats ) ) {
$purge = true;
} elseif ( $new && in_array( $new->getContentFormat(), $formats ) ) {
--- /dev/null
+<?php
+
+/**
+ * Marker class for search engines that can handle their own pagination, by
+ * reporting in their SearchResultSet when a next page is available. This
+ * only applies to search{Title,Text} and not to completion search.
+ *
+ * SearchEngine implementations not implementing this interface will have
+ * an over-fetch performed to determine next page availability.
+ */
+interface PaginatingSearchEngine {
+}
* @return SearchResultSet|Status|null
*/
public function searchText( $term ) {
- return $this->doSearchText( $term );
+ return $this->maybePaginate( function () use ( $term ) {
+ return $this->doSearchText( $term );
+ } );
}
/**
* @return SearchResultSet|null
*/
public function searchTitle( $term ) {
- return $this->doSearchTitle( $term );
+ return $this->maybePaginate( function () use ( $term ) {
+ return $this->doSearchTitle( $term );
+ } );
}
/**
return null;
}
+ /**
+ * Performs an overfetch and shrink operation to determine if
+ * the next page is available for search engines that do not
+ * explicitly implement their own pagination.
+ *
+ * @param Closure $fn Takes no arguments
+ * @return SearchResultSet|Status<SearchResultSet>|null Result of calling $fn
+ */
+ private function maybePaginate( Closure $fn ) {
+ if ( $this instanceof PaginatingSearchEngine ) {
+ return $fn();
+ }
+ $this->limit++;
+ try {
+ $resultSetOrStatus = $fn();
+ } finally {
+ $this->limit--;
+ }
+
+ $resultSet = null;
+ if ( $resultSetOrStatus instanceof SearchResultSet ) {
+ $resultSet = $resultSetOrStatus;
+ } elseif ( $resultSetOrStatus instanceof Status &&
+ $resultSetOrStatus->getValue() instanceof SearchResultSet
+ ) {
+ $resultSet = $resultSetOrStatus->getValue();
+ }
+ if ( $resultSet ) {
+ $resultSet->shrink( $this->limit );
+ }
+
+ return $resultSetOrStatus;
+ }
+
/**
* @since 1.18
* @param string $feature
return $search;
}
+ /**
+ * Perform an overfetch of completion search results. This allows
+ * determining if another page of results is available.
+ *
+ * @param string $search
+ * @return SearchSuggestionSet
+ */
+ protected function completionSearchBackendOverfetch( $search ) {
+ $this->limit++;
+ try {
+ return $this->completionSearchBackend( $search );
+ } finally {
+ $this->limit--;
+ }
+ }
+
/**
* Perform a completion search.
* Does not resolve namespaces and does not check variants.
return SearchSuggestionSet::emptySuggestionSet(); // Return empty result
}
$search = $this->normalizeNamespaces( $search );
- return $this->processCompletionResults( $search, $this->completionSearchBackend( $search ) );
+ $suggestions = $this->completionSearchBackendOverfetch( $search );
+ return $this->processCompletionResults( $search, $suggestions );
}
/**
}
$search = $this->normalizeNamespaces( $search );
- $results = $this->completionSearchBackend( $search );
- $fallbackLimit = $this->limit - $results->getSize();
+ $results = $this->completionSearchBackendOverfetch( $search );
+ $fallbackLimit = 1 + $this->limit - $results->getSize();
if ( $fallbackLimit > 0 ) {
global $wgContLang;
* @return SearchSuggestionSet
*/
protected function processCompletionResults( $search, SearchSuggestionSet $suggestions ) {
+ // We over-fetched to determine pagination. Shrink back down if we have extra results
+ // and mark if pagination is possible
+ $suggestions->shrink( $this->limit );
+
$search = trim( $search );
// preload the titles with LinkBatch
- $titles = $suggestions->map( function ( SearchSuggestion $sugg ) {
+ $lb = new LinkBatch( $suggestions->map( function ( SearchSuggestion $sugg ) {
return $sugg->getSuggestedTitle();
- } );
- $lb = new LinkBatch( $titles );
+ } ) );
$lb->setCaller( __METHOD__ );
$lb->execute();
+ $diff = $suggestions->filter( function ( SearchSuggestion $sugg ) {
+ return $sugg->getSuggestedTitle()->isKnown();
+ } );
+ if ( $diff > 0 ) {
+ MediaWikiServices::getInstance()->getStatsdDataFactory()
+ ->updateCount( 'search.completion.missing', $diff );
+ }
+
$results = $suggestions->map( function ( SearchSuggestion $sugg ) {
return $sugg->getSuggestedTitle()->getPrefixedText();
} );
$setAugmentors = [];
$rowAugmentors = [];
Hooks::run( "SearchResultsAugment", [ &$setAugmentors, &$rowAugmentors ] );
-
if ( !$setAugmentors && !$rowAugmentors ) {
// We're done here
return;
/**
* @ingroup Search
*/
-class SearchResultSet implements IteratorAggregate {
-
+class SearchResultSet implements Countable, IteratorAggregate {
/**
* Types of interwiki results
*/
*/
protected $extraData = [];
- /** @var ArrayIterator|null Iterator supporting BC iteration methods */
+ /**
+ * @var boolean True when there are more pages of search results available.
+ */
+ private $hasMoreResults;
+
+ /**
+ * @var ArrayIterator|null Iterator supporting BC iteration methods
+ */
private $bcIterator;
- public function __construct( $containedSyntax = false ) {
+ /**
+ * @param bool $containedSyntax True when query is not requesting a simple
+ * term match
+ * @param bool $hasMoreResults True when there are more pages of search
+ * results available.
+ */
+ public function __construct( $containedSyntax = false, $hasMoreResults = false ) {
if ( static::class === __CLASS__ ) {
// This class will eventually be abstract. SearchEngine implementations
// already have to extend this class anyways to provide the actual
wfDeprecated( __METHOD__, 1.32 );
}
$this->containedSyntax = $containedSyntax;
+ $this->hasMoreResults = $hasMoreResults;
}
/**
}
function numRows() {
- return 0;
+ return $this->count();
+ }
+
+ final public function count() {
+ return count( $this->extractResults() );
}
/**
return $this->containedSyntax;
}
+ /**
+ * @return bool True when there are more pages of search results available.
+ */
+ public function hasMoreResults() {
+ return $this->hasMoreResults;
+ }
+
+ /**
+ * @param int $limit Shrink result set to $limit and flag
+ * if more results are available.
+ */
+ public function shrink( $limit ) {
+ if ( $this->count() > $limit ) {
+ $this->hasMoreResults = true;
+ // shrinking result set for implementations that
+ // have not implemented extractResults and use
+ // the default cache location. Other implementations
+ // must override this as well.
+ if ( is_array( $this->results ) ) {
+ $this->results = array_slice( $this->results, 0, $limit );
+ } else {
+ throw new \UnexpectedValueException(
+ "When overriding result store extending classes must "
+ . " also override " . __METHOD__ );
+ }
+ }
+ }
+
/**
* Extract all the results in the result set as array.
* @return SearchResult[]
*/
private $pageMap = [];
+ /**
+ * @var bool Are more results available?
+ */
+ private $hasMoreResults;
+
/**
* Builds a new set of suggestions.
*
* unexpected behaviors.
*
* @param SearchSuggestion[] $suggestions (must be sorted by score)
+ * @param bool $hasMoreResults Are more results available?
*/
- public function __construct( array $suggestions ) {
+ public function __construct( array $suggestions, $hasMoreResults = false ) {
+ $this->hasMoreResults = $hasMoreResults;
foreach ( $suggestions as $suggestion ) {
$pageID = $suggestion->getSuggestedTitleID();
if ( $pageID && empty( $this->pageMap[$pageID] ) ) {
}
}
+ /**
+ * @return bool Are more results available?
+ */
+ public function hasMoreResults() {
+ return $this->hasMoreResults;
+ }
+
/**
* Get the list of suggestions.
* @return SearchSuggestion[]
return array_map( $callback, $this->suggestions );
}
+ /**
+ * Filter the suggestions array
+ * @param callback $callback Callable accepting single SearchSuggestion
+ * instance returning bool false to remove the item.
+ * @return int The number of suggestions removed
+ */
+ public function filter( $callback ) {
+ $before = count( $this->suggestions );
+ $this->suggestions = array_values( array_filter( $this->suggestions, $callback ) );
+ return $before - count( $this->suggestions );
+ }
+
/**
* Add a new suggestion at the end.
* If the score of the new suggestion is greater than the worst one,
public function shrink( $limit ) {
if ( count( $this->suggestions ) > $limit ) {
$this->suggestions = array_slice( $this->suggestions, 0, $limit );
+ $this->hasMoreResults = true;
}
}
* NOTE: Suggestion scores will be generated.
*
* @param Title[] $titles
+ * @param bool $hasMoreResults Are more results available?
* @return SearchSuggestionSet
*/
- public static function fromTitles( array $titles ) {
+ public static function fromTitles( array $titles, $hasMoreResults = false ) {
$score = count( $titles );
$suggestions = array_map( function ( $title ) use ( &$score ) {
return SearchSuggestion::fromTitle( $score--, $title );
}, $titles );
- return new SearchSuggestionSet( $suggestions );
+ return new SearchSuggestionSet( $suggestions, $hasMoreResults );
}
/**
* NOTE: Suggestion scores will be generated.
*
* @param string[] $titles
+ * @param bool $hasMoreResults Are more results available?
* @return SearchSuggestionSet
*/
- public static function fromStrings( array $titles ) {
+ public static function fromStrings( array $titles, $hasMoreResults = false ) {
$score = count( $titles );
$suggestions = array_map( function ( $title ) use ( &$score ) {
return SearchSuggestion::fromText( $score--, $title );
}, $titles );
- return new SearchSuggestionSet( $suggestions );
+ return new SearchSuggestionSet( $suggestions, $hasMoreResults );
}
/**
private function createService( $name ) {
if ( isset( $this->serviceInstantiators[$name] ) ) {
$service = ( $this->serviceInstantiators[$name] )(
- $this, ...$this->extraInstantiationParams
+ $this,
+ ...$this->extraInstantiationParams
);
// NOTE: when adding more wiring logic here, make sure copyWiring() is kept in sync!
} else {
// Close any auto-started session, before we replace it
session_write_close();
- // Tell PHP not to mess with cookies itself
- ini_set( 'session.use_cookies', 0 );
- ini_set( 'session.use_trans_sid', 0 );
-
- // T124510: Disable automatic PHP session related cache headers.
- // MediaWiki adds it's own headers and the default PHP behavior may
- // set headers such as 'Pragma: no-cache' that cause problems with
- // some user agents.
- session_cache_limiter( '' );
-
- // Also set a sane serialization handler
- \Wikimedia\PhpSessionSerializer::setSerializeHandler();
-
- // Register this as the save handler, and register an appropriate
- // shutdown function.
- session_set_save_handler( self::$instance, true );
+ try {
+ \Wikimedia\suppressWarnings();
+
+ // Tell PHP not to mess with cookies itself
+ ini_set( 'session.use_cookies', 0 );
+ ini_set( 'session.use_trans_sid', 0 );
+
+ // T124510: Disable automatic PHP session related cache headers.
+ // MediaWiki adds it's own headers and the default PHP behavior may
+ // set headers such as 'Pragma: no-cache' that cause problems with
+ // some user agents.
+ session_cache_limiter( '' );
+
+ // Also set a sane serialization handler
+ \Wikimedia\PhpSessionSerializer::setSerializeHandler();
+
+ // Register this as the save handler, and register an appropriate
+ // shutdown function.
+ session_set_save_handler( self::$instance, true );
+ } finally {
+ \Wikimedia\restoreWarnings();
+ }
}
/**
'size' => '40',
'section' => 'page1',
'validation-callback' => [ $this, 'checkExistingTitle' ],
+ 'required' => false,
],
'Revision1' => [
'type' => 'int',
'size' => '40',
'section' => 'page2',
'validation-callback' => [ $this, 'checkExistingTitle' ],
+ 'required' => false,
],
'Revision2' => [
'type' => 'int',
return $u;
}
+ /**
+ * Returns a User object corresponding to the given UserIdentity.
+ *
+ * @since 1.32
+ *
+ * @param UserIdentity $identity
+ *
+ * @return User
+ */
+ public static function newFromIdentity( UserIdentity $identity ) {
+ if ( $identity instanceof User ) {
+ return $identity;
+ }
+
+ return self::newFromAnyId(
+ $identity->getId() === 0 ? null : $identity->getId(),
+ $identity->getName() === '' ? null : $identity->getName(),
+ $identity->getActorId() === 0 ? null : $identity->getActorId()
+ );
+ }
+
/**
* Static factory method for creation from an ID, name, and/or actor ID
*
}
}
+ // Replace deprecated language codes
+ $this->mOptions['language'] = LanguageCode::replaceDeprecatedCodes(
+ $this->mOptions['language']
+ );
+
$this->mOptionsLoaded = true;
Hooks::run( 'UserLoadOptions', [ $this, &$this->mOptions ] );
* @deprecated No-op since 1.28
*/
function initEncoding() {
+ wfDeprecated( __METHOD__, '1.28' );
// No-op.
}
* @deprecated No-op since 1.28
*/
function recodeForEdit( $s ) {
+ wfDeprecated( __METHOD__, '1.28' );
return $s;
}
* @deprecated No-op since 1.28
*/
function recodeInput( $s ) {
+ wfDeprecated( __METHOD__, '1.28' );
return $s;
}
* the "raw" tag (-{R| }-) to prevent conversion.
*
* This function is called "markNoConversion" for historical
- * reasons.
+ * reasons *BUT DIFFERS SIGNIFICANTLY* from
+ * LanguageConverter::markNoConversion(), with which it is easily
+ * confused.
*
* @param string $text Text to be used for external link
* @param bool $noParse Wrap it without confirming it's a real URL first
* @return string The tagged text
+ * @deprecated since 1.32, use LanguageConverter::markNoConversion()
+ * instead.
*/
public function markNoConversion( $text, $noParse = false ) {
+ wfDeprecated( __METHOD__, '1.32' );
// Excluding protocal-relative URLs may avoid many false positives.
if ( $noParse || preg_match( '/^(?:' . wfUrlProtocolsWithoutProtRel() . ')/', $text ) ) {
return $this->mConverter->markNoConversion( $text );
"retypenew": "Pasoë lom lageuëm barô:",
"resetpass_submit": "Atô lageuëm rahsia lheuëh nyan tamöng",
"changepassword-success": "Lageuëm rahsia droëneuh meuhasé geugantoë!",
+ "botpasswords-label-create": "Peugöt",
+ "botpasswords-label-update": "Peubarô",
+ "botpasswords-label-cancel": "Pubateue",
+ "botpasswords-label-delete": "Sampôh",
+ "botpasswords-label-resetpassword": "Atô keulayi lageuem rahsia",
"resetpass_forbidden": "Lageuëm rahsia h'an jeuët geugantoë",
"resetpass_forbidden-reason": "Lageuëm rahsia h`an jeuët geugantoë: $1",
"resetpass-no-info": "Droëneuh suwah neutamöng mangat jeuët neu'eu laman nyoë",
"rcfilters-empty-filter": "Nun hai filtros activos. Amuésense toles contribuciones.",
"rcfilters-filterlist-title": "Filtros",
"rcfilters-filterlist-whatsthis": "¿Como funciona esto?",
- "rcfilters-filterlist-feedbacklink": "Dinos lo que pienses sobre estes (nueves) ferramientes de filtriáu",
+ "rcfilters-filterlist-feedbacklink": "Dinos lo que pienses sobre estes ferramientes de filtriáu",
"rcfilters-highlightbutton-title": "Resaltar resultaos",
"rcfilters-highlightmenu-title": "Seleiciona un color",
"rcfilters-highlightmenu-help": "Seleiciona un color pa resaltar esta propiedá",
"rcfilters-watchlist-showupdated": "Los cambeos fechos en páxines que nun visitasti desque se ficieron apaecen en <strong>negrina</strong>, con marcadores sólidos.",
"rcfilters-preference-label": "Tapecer la versión meyorada de Cambios recién",
"rcfilters-preference-help": "Revierte'l rediseñu de la interfaz de 2017 y toles ferramientes añadíes d'entós aquí.",
+ "rcfilters-watchlist-preference-label": "Tapecer la versión ameyorada de la Llista de siguimientu",
+ "rcfilters-watchlist-preference-help": "Desfai el rediseñu de la interfaz de 2017 y toles ferramientes añadíes d'entós acá.",
"rcfilters-filter-showlinkedfrom-label": "Amosar los cambios nes páxines enllazaes dende",
"rcfilters-filter-showlinkedfrom-option-label": "<strong>Páxines enllazaes dende</strong> la páxina seleicionada",
"rcfilters-filter-showlinkedto-label": "Amosar los cambios nes páxines qu'enllacen a",
"rcfilters-empty-filter": "Няма актыўных фільтраў. Паказаны ўвесь унёсак.",
"rcfilters-filterlist-title": "Фільтры",
"rcfilters-filterlist-whatsthis": "Як гэта працуе?",
- "rcfilters-filterlist-feedbacklink": "Раскажыце нам, што вы думаеце пра гэтыя (новыя) фільтры",
+ "rcfilters-filterlist-feedbacklink": "Раскажыце нам, што вы думаеце пра гэтыя інструмэнты фільтраў",
"rcfilters-highlightbutton-title": "Вылучыць вынікі",
"rcfilters-highlightmenu-title": "Абярыце колер",
"rcfilters-highlightmenu-help": "Абярыце колер для вылучэньня гэтай уласьцівасьці",
"empty-file": "Дасланы вамі файл пусты.",
"file-too-large": "Дасланы вамі файл занадта вялікі.",
"filename-tooshort": "Назва файлу занадта кароткая.",
- "filetype-banned": "Гэты тып файла забаронены.",
- "verification-error": "Ð\93Ñ\8dÑ\82Ñ\8b Ñ\84айл не пÑ\80айÑ\88оÑ\9e вÑ\8dÑ\80Ñ\8bÑ\84Ñ\96каÑ\86Ñ\8bÑ\8e.",
- "hookaborted": "Ð\9fÑ\80апанаванаÑ\8f Ð\92амÑ\96 зÑ\8cмена бÑ\8bла адÑ\85Ñ\96ленаÑ\8f апÑ\80аÑ\86оÑ\9eÑ\88Ñ\87Ñ\8bкам паÑ\88Ñ\8bÑ\80Ñ\8dнÑ\8cнÑ\8f.",
+ "filetype-banned": "Гэты тып файлу забаронены.",
+ "verification-error": "Ð\93Ñ\8dÑ\82Ñ\8b Ñ\84айл не пÑ\80айÑ\88оÑ\9e пÑ\80авеÑ\80кÑ\83.",
+ "hookaborted": "Ð\9fÑ\80апанаванаÑ\8f вамÑ\96 зÑ\8cмена бÑ\8bла адÑ\85Ñ\96ленаÑ\8f паÑ\88Ñ\8bÑ\80Ñ\8dнÑ\8cнем.",
"illegal-filename": "Недазволеная назва файла.",
"overwrite": "Замена існуючага файла забароненая.",
"unknown-error": "Узьнікла невядомая памылка.",
"image_sample": "Напрыклад.jpg",
"image_tip": "Файл у тэксце",
"media_sample": "Напрыклад.ogg",
- "media_tip": "Спасылка на медыя-файл",
+ "media_tip": "Спасылка на медыяфайл",
"sig_tip": "Ваш подпіс і адзначаны час",
"hr_tip": "Гарызантальная рыса (не злоўжывайце гэтым)",
"summary": "Тлумачэнне:",
"listduplicatedfiles": "Пералік дублікатных файлаў",
"listduplicatedfiles-summary": "Гэта пералік файлаў, у якіх найноўшая версія файла ўяўляе сабою копію апошняй версіі якога-небудзь іншага файла. Улічваюцца толькі лакальныя файлы.",
"listduplicatedfiles-entry": "[[:File:$1|$1]] мае [[$3|{{PLURAL:$2|дублікат|$2 дублікаты|$2 дублікатаў}}]].",
- "unusedtemplates": "ШаблонÑ\8b, Ñ\8fкÑ\96Ñ\8f не вÑ\8bкаÑ\80Ñ\8bÑ\81Ñ\82аны",
+ "unusedtemplates": "Ð\9dевÑ\8bкаÑ\80Ñ\8bÑ\81Ñ\82анÑ\8bÑ\8f Ñ\88аблоны",
"unusedtemplatestext": "Тут пералічаныя ўсе старонкі ў прасторы назваў {{ns:template}}, якія не ўкладзеныя ў іншыя старонкі.\nПамятайце, што трэба правяраць спасылкі на шаблоны перад тым, як выдаляць іх.",
"unusedtemplateswlh": "іншыя спасылкі",
"randompage": "Выпадковая старонка",
"expansion-depth-exceeded-warning": "পাতাটি এক্সেপশন সীমানা অতিক্রম করেছে",
"parser-unstrip-loop-warning": "ত্রুটিপূর্ণ লুপ খুঁজে পাওয়া গিয়েছে",
"unstrip-depth-warning": "লুপ রিকারশন সীমানা অতিক্রম করেছে ($1)",
+ "unstrip-depth-category": "পাতাগুলি যেখানে unstrip গভীরতার সীমা অতিক্রম করেছেন",
+ "unstrip-size-warning": "Unstrip আকারের সীমা অতিক্রম করেছে ($1)",
"converter-manual-rule-error": "ম্যানুয়াল ভাষা রূপান্তর নিয়মে ত্রুটি পাওয়া গিয়েছে",
"undo-success": "সম্পাদনাটি বাতিল করা যাবে। অনুগ্রহ করে নিচের তুলনাটি পরীক্ষা করে দেখুন ও নিশ্চিত করুন যে এটাই আপনি করতে চান, এবং তারপর নিচের সম্পাদনাগুলি সংরক্ষণ করে সম্পাদনাটির বাতিল প্রক্রিয়া সমাপ্ত করুন।",
"undo-failure": "এ সম্পাদনা মধ্যবর্তী সম্পাদনাসমূহের কারণে পূর্বাবস্থায় ফিরিয়ে নেওয়া যাবে না।",
"rcfilters-savedqueries-rename": "নামান্তর",
"rcfilters-savedqueries-setdefault": "পূর্ব-নির্ধারিত হিসেবে নির্ধারন করুন",
"rcfilters-savedqueries-unsetdefault": "পূর্ব-নির্ধারিত হিসেবে নির্ধারন সরান",
- "rcfilters-savedqueries-remove": "সরান",
+ "rcfilters-savedqueries-remove": "à¦\85পসারণ",
"rcfilters-savedqueries-new-name-label": "নাম",
"rcfilters-savedqueries-new-name-placeholder": "ছাঁকনির উদ্দেশ্য বর্ণনা করুন",
"rcfilters-savedqueries-apply-label": "ছাঁকনি তৈরি করুন",
"rcfilters-empty-filter": "কোনো সক্রিয় ফিল্টার নেই। সমস্ত অবদান দেখানো হয়েছে।",
"rcfilters-filterlist-title": "ছাঁকনি",
"rcfilters-filterlist-whatsthis": "এগুলি কিভাবে কাজ করে?",
- "rcfilters-filterlist-feedbacklink": "আপনি এই (নতুন) ছাঁকন সরঞ্জাম সম্পর্কে কি মনে করেন তা আমাদের বলুন",
+ "rcfilters-filterlist-feedbacklink": "আপনি এই ছাঁকন সরঞ্জাম সম্পর্কে কি মনে করেন তা আমাদের বলুন",
"rcfilters-highlightbutton-title": "ফলাফলে আলোকপাত করুন",
"rcfilters-highlightmenu-title": "একটি রং নির্বাচন করুন",
"rcfilters-highlightmenu-help": "এই বৈশিষ্ট্যটিতে আলোকপাত করতে একটি রঙ নির্বাচন করুন",
"protectedtitles-submit": "শিরোনাম প্রদর্শন করুন",
"listusers": "ব্যবহারকারীর তালিকা",
"listusers-editsonly": "শুধুমাত্র এমন ব্যবহারকারীদের দেখাও যাদের অবদান আছে",
+ "listusers-temporarygroupsonly": "শুধুমাত্র অস্থায়ী ব্যবহারকারী দলের ব্যবহারকারীদের দেখান",
"listusers-creationsort": "তৈরির তারিখ অনুসারে সাজাও",
"listusers-desc": "বড় থেকে ছোট ক্রম অনুযায়ী সাজাও",
"usereditcount": "$1 {{PLURAL:$1|সম্পাদনা|সম্পাদনা}}",
"savechanges": "Enrollañ ar c'hemmoù",
"publishpage": "Embann ar bajenn",
"publishchanges": "Embann ar c'hemmoù",
+ "savearticle-start": "Enrollañ ar bajenn...",
+ "savechanges-start": "Enrollañ ar c'hemmoù...",
+ "publishpage-start": "Embann ar bajenn...",
+ "publishchanges-start": "Embann ar c'hemmoù",
"preview": "Rakwelet",
"showpreview": "Rakwelet",
"showdiff": "Diskouez ar c'hemmoù",
"prefs-dateformat": "Furmad an deiziadoù",
"prefs-timeoffset": "Linkadur eur",
"prefs-advancedediting": "Dibarzhioù araokaet",
+ "prefs-developertools": "Ostilhoù diorren",
"prefs-editor": "Aozer",
"prefs-preview": "Rakwelet",
"prefs-advancedrc": "Dibarzhioù araokaet",
+ "prefs-opt-out": "Nac'hañ ar c'hemmoù",
"prefs-advancedrendering": "Dibarzhioù araokaet",
"prefs-advancedsearchoptions": "Dibarzhioù araokaet",
"prefs-advancedwatchlist": "Dibarzhioù araokaet",
"prefs-diffs": "Diforc'hioù",
"prefs-help-prefershttps": "Efediñ a ray an dibarzh-mañ kentañ gwech ma kevreoc'h.",
"prefswarning-warning": "Kemmet eo bet ho penndibaboù ganeoc'h, met enrollet n'int ket bet avat.\nMar kuitait ar bajenn-mañ hep klikañ war \"$1\" ne vo ket nevesaet ho penndibaboù",
- "prefs-tabs-navigation-hint": "Titourig : Gallout a rit implijout an touchennoù bir kleiz ha bir dehoù evit merdeiñ etre an ivinelloù e roll an ivinelloù.",
+ "prefs-tabs-navigation-hint": "Titourig : Gallout a rit implijout an touchennoù bir kleiz ha bir dehou evit merdeiñ etre an ivinelloù e roll an ivinelloù.",
"userrights": "Gwirioù an implijer",
"userrights-lookup-user": "Diuzañ un implijer",
"userrights-user-editname": "Lakait un anv implijer :",
"apisandbox-dynamic-error-exists": "Bez' ez eus c'hoazh eus un arventenn anvet \"$1\".",
"apisandbox-deprecated-parameters": "Arventennoù dispredet",
"apisandbox-fetch-token": "Leuniañ emgefre ar jedouer",
+ "apisandbox-add-multi": "Ouzhpennañ",
"apisandbox-submit-invalid-fields-title": "Direizh eo maeziennoù zo",
"apisandbox-submit-invalid-fields-message": "Reizhit ar maeziennoù merket ha klaskit en-dro.",
"apisandbox-results": "Disoc'hoù",
"newimages-user": "Chomlec'h IP pe anv implijer",
"newimages-showbots": "Diskouez an ezporzhiadennoù graet gant robotoù",
"newimages-hidepatrolled": "Kuzhat ar enporzhiadennoù gwiriet",
+ "newimages-mediatype": "Doare media :",
"noimages": "Netra da welet.",
"gallery-slideshow-toggle": "Gwintañ ar munudoù",
"ilsubmit": "Klask",
"tag-list-wrapper": "([[Special:Tags|{{PLURAL:$1|Dikedenn|Tikedenn}}]] : $2)",
"tag-mw-contentmodelchange": "cheñch ar patrom danvez",
"tag-mw-contentmodelchange-description": "KEmmoù a [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:ChangeContentModel cheñch patrom danvez] ur bajenn",
+ "tag-mw-new-redirect": "Adkas nevez",
"tag-mw-undo": "Dizober",
"tags-title": "Tikedennoù",
"tags-intro": "Rollañ a ra ar bajenn-mañ an tikedennoù a c'hall ar meziant implijout da verkañ kemmoù hag o zalvoudegezh.",
"undelete-cantedit": "N'hallit ket diziverkañ ar bajenn-mañ rak n'oc'h ket aotreet da gemmañ anezhi.",
"undelete-cantcreate": "N'hallit ket diziverkañ ar bajenn-mañ rak n'eus pajenn ebet gant an anv-mañ ha n'oc'h ket aotreet da grouiñ ar bajenn-mañ.",
"pagedata-title": "Roadennoù ar bajenn",
- "pagedata-bad-title": "Titl direizh : $1."
+ "pagedata-bad-title": "Titl direizh : $1.",
+ "passwordpolicies-group": "Strollad",
+ "passwordpolicies-policies": "Politikerezh"
}
"whatlinkshere": "Odkazuje sem",
"whatlinkshere-title": "Stránky odkazující na „$1“",
"whatlinkshere-page": "Strana:",
- "linkshere": "Na <strong>$1</strong> odkazují tyto stránky:",
- "nolinkshere": "Žádná stránka na <strong>$1</strong> neodkazuje.",
- "nolinkshere-ns": "Ve zvoleném jmenném prostoru na <strong>$1</strong> neodkazuje žádná stránka.",
+ "linkshere": "Na <strong>$2</strong> odkazují tyto stránky:",
+ "nolinkshere": "Žádná stránka na <strong>$2</strong> neodkazuje.",
+ "nolinkshere-ns": "Ve zvoleném jmenném prostoru na <strong>$2</strong> neodkazuje žádná stránka.",
"isredirect": "přesměrování",
"istemplate": "vložení",
"isimage": "vložení souboru",
"specialpages-group-media": "Медиа-материалсемпе тултарăшсем",
"specialpages-group-users": "Хутшăнакансем тата правасем",
"specialpages-group-highuse": "Нумай усă куракан страницăсем",
- "tag-list-wrapper": "([[Special:Tags|$1 метка]]: $2)",
+ "tag-list-wrapper": "([[Special:Tags|{{PLURAL:$1|Тег|Тегсем}}]]: $2)",
+ "tags-title": "Тегсем",
"compare-submit": "Танлаштар",
"htmlform-selectorother-other": "Урăххи",
"htmlform-no": "Çук",
"resetpass-submit-loggedin": "Passwort ändern",
"resetpass-submit-cancel": "Abbrechen",
"resetpass-wrong-oldpass": "Ungültiges temporäres oder aktuelles Passwort.\nMöglicherweise hast du dein Passwort bereits geändert oder ein neues temporäres Passwort beantragt.",
- "resetpass-recycled": "Bitte setze dein Passwort auf ein anderes Passwort als dein aktuelles fest.",
+ "resetpass-recycled": "Bitte ändere dein Passwort auf ein anderes als dein aktuelles.",
"resetpass-temp-emailed": "Du hast dich mit einem temporären E-Mail-Code angemeldet.\nUm die Anmeldung abzuschließen, musst du jetzt ein neues Passwort festlegen:",
"resetpass-temp-password": "Temporäres Passwort:",
"resetpass-abort-generic": "Die Passwortänderung wurde durch eine Erweiterung abgebrochen.",
"resetpass-expired": "Dein Passwort ist abgelaufen. Bitte lege ein neues Passwort zur Anmeldung fest.",
- "resetpass-expired-soft": "Dein Passwort ist abgelaufen und muss zurückgesetzt werden. Bitte wähle jetzt ein neues Passwort aus oder klicke auf „{{int:authprovider-resetpass-skip-label}}“, um es später zurückzusetzen.",
- "resetpass-validity-soft": "Dein Passwort ist ungültig: $1\n\nBitte wähle jetzt ein neues Passwort oder klicke auf „{{int:authprovider-resetpass-skip-label}}“, um es später zurückzusetzen.",
+ "resetpass-expired-soft": "Dein Passwort ist abgelaufen und muss geändert werden. Bitte wähle jetzt ein neues Passwort aus oder klicke auf „{{int:authprovider-resetpass-skip-label}}“, um es später zu ändern.",
+ "resetpass-validity-soft": "Dein Passwort ist ungültig: $1\n\nBitte wähle jetzt ein neues Passwort aus oder klicke auf „{{int:authprovider-resetpass-skip-label}}“, um es später zu ändern.",
"passwordreset": "Passwort zurücksetzen",
"passwordreset-text-one": "Fülle dieses Formular aus, um ein temporäres Passwort per E-Mail zu erhalten.",
"passwordreset-text-many": "{{PLURAL:$1|Fülle eines der Felder aus, um ein temporäres Passwort per E-Mail zugesandt zu bekommen.}}",
"rcfilters-savedqueries-rename": "Umbenennen",
"rcfilters-savedqueries-setdefault": "Als Standard festlegen",
"rcfilters-savedqueries-unsetdefault": "Als Standard entfernen",
- "rcfilters-savedqueries-remove": "Entfernen",
+ "rcfilters-savedqueries-remove": "Löschen",
"rcfilters-savedqueries-new-name-label": "Name",
"rcfilters-savedqueries-new-name-placeholder": "Beschreibe den Zweck des Filters",
"rcfilters-savedqueries-apply-label": "Filter erstellen",
"rcfilters-empty-filter": "Keine aktiven Filter. Es werden alle Beiträge angezeigt.",
"rcfilters-filterlist-title": "Filter",
"rcfilters-filterlist-whatsthis": "Wie funktioniert das?",
- "rcfilters-filterlist-feedbacklink": "Erzähle uns, was du über diese (neuen) Filterwerkzeuge denkst.",
+ "rcfilters-filterlist-feedbacklink": "Erzähle uns, was du über diese Filterwerkzeuge denkst.",
"rcfilters-highlightbutton-title": "Ergebnisse hervorheben",
"rcfilters-highlightmenu-title": "Eine Farbe auswählen",
"rcfilters-highlightmenu-help": "Eine Farbe auswählen, um diese Eigenschaft hervorzuheben.",
"resetpass-submit-loggedin": "Change password",
"resetpass-submit-cancel": "Cancel",
"resetpass-wrong-oldpass": "Invalid temporary or current password.\nYou may have already changed your password or requested a new temporary password.",
- "resetpass-recycled": "Please reset your password to something other than your current password.",
+ "resetpass-recycled": "Please change your password to something other than your current password.",
"resetpass-temp-emailed": "You logged in with a temporary emailed code.\nTo finish logging in, you must set a new password here:",
"resetpass-temp-password": "Temporary password:",
"resetpass-abort-generic": "Password change has been aborted by an extension.",
"resetpass-expired": "Your password has expired. Please set a new password to log in.",
- "resetpass-expired-soft": "Your password has expired and needs to be reset. Please choose a new password now, or click \"{{int:authprovider-resetpass-skip-label}}\" to reset it later.",
- "resetpass-validity-soft": "Your password is not valid: $1\n\nPlease choose a new password now, or click \"{{int:authprovider-resetpass-skip-label}}\" to reset it later.",
+ "resetpass-expired-soft": "Your password has expired and needs to be changed. Please choose a new password now, or click \"{{int:authprovider-resetpass-skip-label}}\" to change it later.",
+ "resetpass-validity-soft": "Your password is not valid: $1\n\nPlease choose a new password now, or click \"{{int:authprovider-resetpass-skip-label}}\" to change it later.",
"passwordreset": "Reset password",
"passwordreset-text-one": "Complete this form to receive a temporary password via email.",
"passwordreset-text-many": "{{PLURAL:$1|Fill in one of the fields to receive a temporary password via email.}}",
"rcfilters-savedqueries-rename": "Rename",
"rcfilters-savedqueries-setdefault": "Set as default",
"rcfilters-savedqueries-unsetdefault": "Remove as default",
- "rcfilters-savedqueries-remove": "Remove",
+ "rcfilters-savedqueries-remove": "Delete",
"rcfilters-savedqueries-new-name-label": "Name",
"rcfilters-savedqueries-new-name-placeholder": "Describe the purpose of the filter",
"rcfilters-savedqueries-apply-label": "Create filter",
"rcfilters-empty-filter": "No active filters. All contributions are shown.",
"rcfilters-filterlist-title": "Filters",
"rcfilters-filterlist-whatsthis": "How do these work?",
- "rcfilters-filterlist-feedbacklink": "Tell us what you think about these (new) filtering tools",
+ "rcfilters-filterlist-feedbacklink": "Tell us what you think about these filtering tools",
"rcfilters-highlightbutton-title": "Highlight results",
"rcfilters-highlightmenu-title": "Select a color",
"rcfilters-highlightmenu-help": "Select a color to highlight this property",
"revdelete-edit-reasonlist": "Redakti kialojn por forigo",
"revdelete-offender": "Aŭtoro de revizio:",
"suppressionlog": "Protokolo pri subigado",
- "suppressionlogtext": "Malsupre estas listo de forigoj kaj forbaroj pri enhavo kaŝita de administrantoj.\nRigardu la [[Special:BlockList|forbarliston]] por la listo de nune operaciaj forbaroj kaj forigoj.",
+ "suppressionlogtext": "Jen listo de forigoj kaj forbaroj pri enhavo kaŝita per administrantoj.\nVidi la [[Special:BlockList|forbarliston]] por la listo de forigoj kaj forbaroj aktuale operaciaj.",
"mergehistory": "Kunigi historiojn de paĝoj",
"mergehistory-header": "Ĉi tiu paĝo permesas al vi kunigi versiojn de la historio de unu fonta paĝo en pli novan paĝon.\nCertigu ke ĉi tiu ŝanĝo tenos kontinuecon de la historia paĝo.",
"mergehistory-box": "Kunigi versiojn de du paĝoj:",
"explainconflict": "Zu orrialdea aldatzen hasi ondoren beste norbaitek ere aldaketak egin ditu.\nGoiko testu koadroan ikus daiteke orrialdeak uneotan duen edukia.\nZure aldaketak beheko testu koadroan ikus daitezke.\nZure testua dagoenarekin elkartu beharko duzu.\nOrrialdea gordetzeko erabakitzen duzun unean goiko koadroko edukia '''bakarrik''' gordeko da.",
"yourtext": "Zure testua",
"storedversion": "Gordetako bertsioa",
- "editingold": "'''KONTUZ: Artikulu honen bertsio zahar bat aldatzen ari zara. Gorde egiten baduzu, azkenengo aldaketa baino lehenagoko aldakuntzak, ezabatuak izango dira.'''",
+ "editingold": "<strong>KONTUZ: Artikulu honen bertsio zahar bat aldatzen ari zara.</strong> Gordetzen baduzu, bertsio hau baino geroago egindako aldaketa guztiak ezabatuko dira.",
"unicode-support-fail": "Zure nabigatzaileak Unicode onartzen ez duela dirudi. Orrialdeak editatu behar dira, beraz, edizioa ez da gorde.",
"yourdiff": "Ezberdintasunak",
"copyrightwarning": "Kontuan izan ezazu {{SITENAME}} webgunean egindako ekarpen guztiak $2 lizentziaren pean argitaratzen direla (xehetasunetarako, ikus $1). Zuk idatzitakoa libreki aldatua eta banatua izatea nahi ez baduzu, ez ezazu hemen jarri.<br />\nEra berean, hitzematen ari zara hau zuk zeuk idatzia dela, edo jabari publikotik nahiz askea den beste ituri batetik kopiatu duzula.\n'''Ez erabili copyright eskubideek babestutako lanik, baimenik gabe!'''",
"Vexthedorito",
"Djiboun",
"Pols12",
- "KATRINE1992"
+ "KATRINE1992",
+ "Friday83260"
]
},
"tog-underline": "Soulignement des liens :",
"resetpass-submit-loggedin": "Changer de mot de passe",
"resetpass-submit-cancel": "Annuler",
"resetpass-wrong-oldpass": "Mot de passe actuel ou temporaire invalide.\nVous avez peut-être déjà changé votre mot de passe ou demandé un nouveau mot de passe temporaire.",
- "resetpass-recycled": "Veuillez réinitialiser votre mot de passe à quelque chose d’autre que l’actuel.",
+ "resetpass-recycled": "Veuillez modifier votre mot de passe à quelque chose d’autre que l’actuel.",
"resetpass-temp-emailed": "Vous êtes connecté{{GENDER:||e}} avec un code temporaire fourni par courriel.\nPour terminer la connexion, vous devez fournir un nouveau mot de passe ici :",
"resetpass-temp-password": "Mot de passe temporaire :",
"resetpass-abort-generic": "La modification du mot de passe a été annulée par une extension.",
"resetpass-expired": "Votre mot de passe a expiré. Veuillez en fournir un nouveau pour vous connecter.",
- "resetpass-expired-soft": "Votre mot de passe a expiré, et doit être réinitialisé. Veuillez en choisir un nouveau maintenant ou cliquer sur « {{int:authprovider-resetpass-skip-label}} » pour le faire plus tard.",
- "resetpass-validity-soft": "Votre mot de passe n’est pas valide : $1\n\nVeuillez choisir un nouveau mot de passe maintenant, ou cliquez sur « {{int:authprovider-resetpass-skip-label}} » pour le réinitialiser plus tard.",
+ "resetpass-expired-soft": "Votre mot de passe a expiré, et doit être modifié. Veuillez en choisir un nouveau maintenant ou cliquer sur « {{int:authprovider-resetpass-skip-label}} » pour le faire plus tard.",
+ "resetpass-validity-soft": "Votre mot de passe n’est pas valide : $1\n\nVeuillez choisir un nouveau mot de passe maintenant, ou cliquez sur « {{int:authprovider-resetpass-skip-label}} » pour le modifier plus tard.",
"passwordreset": "Réinitialisation du mot de passe",
"passwordreset-text-one": "Remplissez ce formulaire pour réinitialiser votre mot de passe.",
"passwordreset-text-many": "{{PLURAL:$1|Remplissez un des champs pour recevoir un mot de passe temporaire par courriel.}}",
"rcfilters-empty-filter": "Aucun filtre actif. Toutes les contributions sont affichées.",
"rcfilters-filterlist-title": "Filtres",
"rcfilters-filterlist-whatsthis": "Comment ça marche ?",
- "rcfilters-filterlist-feedbacklink": "Nous dire ce que vous pensez de ces (nouveaux) outils de filtrage",
+ "rcfilters-filterlist-feedbacklink": "Dites nous ce que vous pensez de ces outils de filtrage",
"rcfilters-highlightbutton-title": "Mettre en valeur les résultats",
"rcfilters-highlightmenu-title": "Choisir une couleur",
"rcfilters-highlightmenu-help": "Sélectionner une couleur pour mettre en évidence cette propriété",
"rcfilters-preference-label": "Masquer la version améliorée des modifications récentes",
"rcfilters-preference-help": "Désactive la version 2017 de l'interface ainsi que de tous les outils ajoutés alors et depuis.",
"rcfilters-watchlist-preference-label": "Masquer la version améliorée de la liste de suivi",
+ "rcfilters-watchlist-preference-help": "Annuler la nouvelle conception de l’interface 2017 et tous les outils ajoutés alors et depuis.",
"rcfilters-filter-showlinkedfrom-label": "Montrer les modifications des pages liées depuis",
"rcfilters-filter-showlinkedfrom-option-label": "<strong>Pages liées depuis</strong> la page sélectionnée",
"rcfilters-filter-showlinkedto-label": "Montrer les modifications des pages pointant vers",
"newwindow": "(Ka ouvri so kò andan roun nouvèl finèt)",
"cancel": "Anilé",
"moredotdotdot": "Plis...",
- "morenotlisted": "Sa lis pé sa enkonplèt",
+ "morenotlisted": "Sa lis pouvé fika enkonplèt",
"mypage": "Paj",
"mytalk": "Diskisyon",
"anontalk": "Diskisyon",
"nstab-category": "Katégori",
"mainpage-nstab": "Paj prensipal",
"nosuchaction": "Aksyon enkonèt",
- "nosuchactiontext": "Aksyon-an spésifyé andan URL-a sa envalid.\nZòt pitèt mal antré URL-a ou swivi roun lyen éroné.\nLi pé égalman endiké oun anomali andan logisyèl itilizé pa {{SITENAME}}.",
+ "nosuchactiontext": "Aksyon-an èspésifyé annan URL-a sa envalid.\nZòt pitèt mal antré URL-a oben swivi roun lyen éroné.\nLi pouvé égalman endiké oun anomali andan logisyèl itilizé pa {{SITENAME}}.",
"nosuchspecialpage": "Paj èspésyal inègzistant",
"nospecialpagetext": "<strong>Zòt doumandé oun paj èspésyal ki pa ka ègzisté.</strong>\n\nOun lis dé paj èspésyal valid ka trouvé so kò asou [[Special:SpecialPages|{{int:specialpages}}]].",
"error": "Érò",
"databaseerror": "Érò di baz di doné",
- "databaseerror-text": "Oun érò di rékèt di baz di doné aparèt.\nSala pé provini di roun anomali annan lojisyèl-a.",
+ "databaseerror-text": "Oun érò di rékèt di baz di doné aparèt.\nSala pouvé provini di roun anomali annan lojisyèl-a.",
"databaseerror-textcl": "Oun érò di rékèt di baz di doné aparèt.",
"databaseerror-query": "Rékèt : $1",
"databaseerror-function": "Fonksyon : $1",
"databaseerror-error": "Érò : $1",
"transaction-duration-limit-exceeded": "Pou évité roun tròp fò ogmantasyon di délè di réplikasyon, sa tranzaksyon té anilé piskétan douré di ékritir ($1) dépasé limit-a di $2 ségonn. Si zòt ka sasé modifyé oun gran nonm di éléman similtanéman, éséyé plito di éfèktchwé opérasyon-an an plizyò étap pli piti.",
- "laggedslavemode": "Panga, sa paj pa pé kontni tout dannyé modifikasyon éfèktchwé",
+ "laggedslavemode": "Panga, sa paj pa pouvé kontni tout dannyé modifikasyon éfèktchwé",
"readonly": "Baz di doné vérouyé",
"enterlockreason": "Endiké rézon-an di vérouyaj ensi ki roun èstimasyon di so douré",
"readonlytext": "Ajou ké mizajou di baz di doné sa atchwèlman bloké, probabman pou pèrmèt mentnans di baz-a, aprè sa, tout bagaj ké rantré andan lòrd.\n\nAdministratò sistèm ki vérouyé baz di doné té fourni èksplikasyon swivant :<br /> $1",
"filenotfound": "Enposib di trouvé fiché-a « $1 ».",
"unexpected": "Valò ki pa nòrmal : « $1 » = « $2 ».",
"formerror": "Érò : enposib di soumèt fòrmilèr-a.",
- "badarticleerror": "Sa aksyon pa pé sa éfèktchwé asou sa paj.",
+ "badarticleerror": "Sa aksyon pa pouvé fika éfèktchwé asou sa paj.",
"cannotdelete": "Enposib di souprimé paj-a oben fiché-a « $1 ».\nSouprésyon-an pitèt ja té éfèktchwé pa rounòt moun.",
"cannotdelete-title": "Enposib di souprimé paj-a « $1 »",
"delete-hook-aborted": "Souprésyon anilé pa roun ègstansyon.\nPyès èksplikasyon té bay.",
"no-null-revision": "Enposib di kréyé roun nouvèl révizyon vid pou paj-a « $1 »",
"badtitle": "Movè tit",
- "badtitletext": "Tit di paj doumandé pa valid, vid, ou mal formé si a roun tit entèr-lanng ou entèr-projè.\nI ka kontni pitèt oun ou plizyò karaktèr ki pa pé sa itilizé andan tit-ya.",
+ "badtitletext": "Tit di paj doumandé pa valid, vid, oben mal fòrmé si a roun tit entèr-lanng oben entèr-projè.\nI ka kontni pitèt oun oben plizyò karaktèr ki pa pouvé fika itilizé annan tit-ya.",
"title-invalid-empty": "Tit di paj doumandé sa vid oben ka kontni sèlman non-an di roun lèspas di non.",
"title-invalid-utf8": "Tit di paj doumandé ka kontni roun sékans UTF-8 envalid.",
"title-invalid-interwiki": "Paj sib ka kontni roun lyen interwiki ki nou pa pouvé itilizé annan tit-ya.",
- "title-invalid-talk-namespace": "Tit di paj doumandé ka fè référans à roun paj di diskisyon ki pa pé ègzisté.",
+ "title-invalid-talk-namespace": "Tit di paj doumandé ka fè référans à roun paj di diskisyon ki pa pouvé ègzisté.",
"title-invalid-characters": "Tit di paj doumandé ka kontni dé karaktèr ki pa valid : « $1 ».",
"title-invalid-relative": "Tit ka kontni oun chimen roulatif. Tit-ya ki ka référansé dé paj roulativ (./, ../) pa valid pas li sa souvan itilizé pa navigatò di itilizatò-a.",
"title-invalid-magic-tilde": "Tit di paj doumandé ka kontni roun sékans di tilde majik ki pa valid (<nowiki>~~~</nowiki>).",
"actionthrottled": "Aksyon limité",
"actionthrottledtext": "Pou briga kont abi-ya, itilizasyon-an di sa aksyon sa limité à roun sèrten nonm di fwè annan roun laps di tan asé kourt é zòt dépasé sa limit.\nSouplé, éséyé òkò annan tchèk minout.",
"protectedpagetext": "Sa paj té protéjé pou anpéché so modifikasyon oben dé ròt aksyon.",
- "viewsourcetext": "Zòt pé wè é kopyé kontni di sa paj.",
+ "viewsourcetext": "Zòt pouvé wè é kopyé kontni-a di sa paj.",
"viewyourtext": "Zòt pouvé wè ké kopyé kontni-a di <strong>zòt modifikasyon</strong> à sa paj.",
"protectedinterface": "Sa paj ka fourni tèks d'entèrfas pou lojisyèl-a asou sa wiki é sa protéjé pou évité abi-ya.\nPou ajouté oben modifyé dé anmòrfwézaj asou tout wiki, souplé, itilizé [https://translatewiki.net/ translatewiki.net], projè-a di réjyonalizasyon di MediaWiki.",
"editinginterface": "<strong>Panga :</strong> zòt ka modifiyé oun paj itilizé pou kréyé tèks-a di lojisyèl.\nChanjman-yan asou sa paj ké répèrkité asou aparans di entèrfas itilizatò pou ròt itilizatò-ya di sa wiki.",
"createacct-email-ph": "Zòt adrès di kouryé",
"createacct-another-email-ph": "Antré adrès-a di kouryé",
"createaccountmail": "Itilizé roun mo di pas aléyatwar tanporèr é voyé li pou adrès-a di kouryé spésifyé",
- "createaccountmail-help": "Pé sa itilizé pou kréyé roun kont pou rounòt moun san konèt mo di pas.",
+ "createaccountmail-help": "Pouvé fika itilizé pou kréyé roun kont pou rounòt moun san konèt mo di pas-a.",
"createacct-realname": "Non réyèl (fakiltatif)",
"createacct-reason": "Motif",
"createacct-reason-ph": "Poukisa zòt kréyé rounòt kont",
"blockedtext": "<strong>Zòt kont itilizatò oben zòt adrès IP bloké.</strong>\n\nBlokaj té éfèktchwé pa $1.\nRézon-an évoké sa swivant : <em>$2</em>.\n\n* Koumansman di blokaj : $8\n* Èspirasyon di blokaj : $6\n* Kont bloké : $7.\n\nZòt pé kontakté $1 oben rounòt [[{{MediaWiki:Grouppage-sysop}}|administratò]] pou an diskité.\nZòt pa pouvé itilizé fonksyon-an « {{int:emailuser}} » rounso si oun adrès di kouryé valid sa èspésifyé andan zòt [[Special:Preferences|préférans]] é rounso si sa fonksyonalité pa bloké.\nZòt adrès IP atchwèl sa $3 é zòt idantifyan di blokaj sa $5.\nSouplé, enkli tout détay-ya lasou'l annan chakin dé rékèt ki zòt ké fè.",
"loginreqlink": "konèkté so kò",
"newarticletext": "Zòt té ka swiv roun lyen vèr roun paj ki pa ka ègzisté òkò. \nAtò di kréyé sa paj, antré zòt tèks annan bwat ki aprè (zòt pé konsilté [$1 paj d'èd-a] pou plis enfòrmasyon).\nSi zòt pa rivé{{GENDER:|}} isi pa éròr, kliké asou bouton <strong>Routour</strong> di zòt navigatò.",
- "anontalkpagetext": "----\n<em>Zòt asou paj di diskisyon di oun itilizatò anonim ki pa òkò kréyé di kont ou ki pa ka an itilizé</em>.\nPou sa rézon, nou divèt itilizé so adrès IP pou idantifyé li.\nOun adrès IP pé sa partajé pa plizyò itilizatò.\nSi zòt roun itiliza{{GENDER:|ò|ris}} anonim é si zòt ka kontasté ki dé koumantèr ki pa ka konsèrné zòt sa adrèsé à zòt, zòt pé [[Special:CreateAccount|kréyé roun kont]] ou [[Special:UserLogin|konèkté zòt kò]] atò di évité tout konfizyon fitir ké ròt kontribitò anonim.",
+ "anontalkpagetext": "----\n<em>Zòt asou paj di diskisyon di roun itilizatò anonim ki pa òkò kréyé di kont ou ki pa ka an itilizé</em>.\nPou sa rézon, nou divèt itilizé so adrès IP pou idantifyé li.\nOun adrès IP pé sa partajé pa plizyò itilizatò.\nSi zòt roun itiliza{{GENDER:|ò|ris}} anonim é si zòt ka kontasté ki dé koumantèr ki pa ka konsèrné zòt sa adrèsé à zòt, zòt pouvé [[Special:CreateAccount|kréyé roun kont]] oben [[Special:UserLogin|konèkté zòt kò]] atò di évité tout konfizyon fitir ké ròt kontribitò anonim.",
"noarticletext": "I pa gen atchwèlman pyès tèks asou sa paj.\nZòt pouvé [[Special:Search/{{PAGENAME}}|lansé oun sasé asou sa tit]] annan ròt paj-ya,\n<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} sasé annan opérasyon lyé]\noben [{{fullurl:{{FULLPAGENAME}}|action=edit}} kréyé sa paj]</span>.",
"noarticletext-nopermission": "I pa gen atchwèlman pyès tèks asou sa paj.\nZòt pouvé [[Special:Search/{{PAGENAME}}|fè roun sasé asou sa tit]] andan ròt paj-ya,\noben <span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|paj={{FULLPAGENAMEE}}}} sasé annan journal asosyé]</span>, mè zòt pa gen pèrmisyon di kréyé sa paj.",
"userpage-userdoesnotexist-view": "Kont itilizatò-a « $1 » pa anréjistré.",
"template-semiprotected": "(sémi-protéjé)",
"hiddencategories": "{{PLURAL:$1|Katégori kaché}} don sa paj ka fè parti :",
"permissionserrors": "Éròr di pèrmisyon",
- "permissionserrorstext-withaction": "Zòt pa pé $2, pou {{PLURAL:$1|rézon swivant}} :",
+ "permissionserrorstext-withaction": "Zòt pa pouvé $2, pou {{PLURAL:$1|rézon swivant}} :",
"recreate-moveddeleted-warn": "<strong>Panga : zòt ka roukréyé oun paj ki té présédaman souprimé.</strong>\n\nAsouré-zòt ki li sa pertinan di pourswiv modifikasyon-yan asou sa paj.\nJournal dé souprésyon é dé déplasman pou sa paj sa afiché anba à tit di enfòrmasyon :",
"moveddeleted-notice": "Sa paj té souprimé. \nJournal dé souprésyon, dé protèksyon é dé déplasman di paj-a sa afiché anba pou référans.",
"content-model-wikitext": "wikitèks",
- "undo-failure": "Sa modifikasyon pa pé défèt : sala ké antré an konfli ké modifikasyon-yan entèrmédyèr.",
+ "undo-failure": "Sa modifikasyon pa pouvé défèt : sala ké antré an konfli ké modifikasyon entèrmédjèr-ya.",
"viewpagelogs": "Wè opérasyon-yan asou sa paj",
"currentrev-asof": "Vèrsyon atchwèl daté di $1",
"revisionasof": "Vèrsyon di $1",
"linkstoimage-more": "Plis {{PLURAL:$1|di roun paj ka itilizé|di $1 paj ka itilizé}} sa fiché.\nLis swivant ka afiché sèlman {{PLURAL:$1|pronmyé paj-a ki ka itilizé|$1 pronmyé paj-ya ki ka itilizé}} sa fiché.\nOun [[Special:WhatLinksHere/$2|lis konplèt]] sa disponib.",
"nolinkstoimage": "Pyès paj pa ka itilizé sa fiché.",
"linkstoimage-redirect": "$1 (roudirèksyon di fiché) $2",
- "sharedupload-desc-here": "Sa fiché ka provini di $1. Li pé sa itilizé pa dé ròt projè.\nSo dèskripsyon asou so [$2 paj di dèskripsyon] sa afiché anba.",
+ "sharedupload-desc-here": "Sa fiché ka provini di $1. Li pouvé fika itilizé pa ròt projè.\nSo dèskripsyon asou so [$2 paj di dèskripsyon] sa afiché anba.",
"filepage-nofile": "Pyès fiché di sa non ka ègzisté.",
- "upload-disallowed-here": "Zòt pa pé ranplasé sa fiché.",
+ "upload-disallowed-here": "Zòt pa pouvé ranplasé sa fiché.",
"randompage": "Paj o azò",
"statistics": "Èstatistik",
"double-redirect-fixer": "Korèktò di roudirèksyon",
"speciallogtitlelabel": "Sib (tit ou {{ns:user}}:non di itilizatò) :",
"log": "Journal d’opérasyon",
"all-logs-page": "Tout journal piblik",
- "alllogstext": "Afichaj konbiné di tout journal disponib asou {{SITENAME}}.\nZòt pé pèrsobalizé afichaj an sélèksyonan tip di journal, non di itilizatò ou paj-a konserné (sa dé dannyé sa sansib à lakas).",
+ "alllogstext": "Afichaj konbiné di tout journal disponib asou {{SITENAME}}.\nZòt pouvé pèrsonalizé afichaj an sélèksyonnan tip di journal, non di itilizatò oben paj-a ki konsèrné (sa Dé dannyé sa sansib à lakas).",
"logempty": "Pyès opérasyon korèspondant andan journal-ya.",
"allpages": "Tout paj-ya",
"allarticles": "Tout paj-ya",
"tooltip-ca-talk": "Diskisyon o sijè di sa paj di kontni",
"tooltip-ca-edit": "Modifyé wikikod-a",
"tooltip-ca-addsection": "Koumansé roun nouvèl sèksyon",
- "tooltip-ca-viewsource": "Sa paj sa protéjé.\nZòt pé toutfwè vizwalizé sours-a.",
+ "tooltip-ca-viewsource": "Sa paj sa protéjé.\nZòt pouvé toutfwè vizwalizé sours-a.",
"tooltip-ca-history": "Istorik vèrsyon-yan di sa paj",
"tooltip-ca-protect": "Protéjé sa paj",
"tooltip-ca-delete": "Souprimé sa paj",
"tooltip-p-logo": "Vizité paj prensipal-a",
"tooltip-n-mainpage": "Vizité paj prensipal-a di sit",
"tooltip-n-mainpage-description": "Paj prensipal jénéral",
- "tooltip-n-portal": "À propo di projè, sa ki zòt pé fè, koté trouvé enfòrmasyon-yan",
+ "tooltip-n-portal": "À propo di projè, sa ki zòt pouvé fè, koté trouvé enfòrmasyon-yan",
"tooltip-n-currentevents": "Trouvé plis d'enfòrmasyon asou atchwalité an kour",
"tooltip-n-recentchanges": "Lis di modifikasyon résant asou wiki-a",
"tooltip-n-randompage": "Afiché roun paj o azò",
"show-big-image-other": "{{PLURAL:$2|Ròt rézolisyon}} : $1.",
"show-big-image-size": "$1 × $2 piksèl",
"metadata": "Métadoné",
- "metadata-help": "Sa fiché ka kontni dé enfòrmasyon siplémantèr, probabman ajouté pa aparèy foto nimèrik ou nimérizò itilizé pou kréyé. \nSi fiché té modifyé dipi so léta orijinal, serten détay pa pé rouflété antyèrman imaj modifyé-a.",
+ "metadata-help": "Sa fiché ka kontni dé enfòrmasyon siplémantèr, probabman ajouté pa aparèy foto nimèrik-a oben nimérizò-a ki itilizé pou kréyé. \nSi fiché-a té modifyé dipi so léta orijinal, sèrten détay pa pouvé rouflété antchèrman imaj-a ki modifyé.",
"metadata-fields": "Chan di métadoné d'imaj listé andan sa mésaj ké sa enkli andan paj di dèskripsyon di imaj-a lò tab-a di métadoné ké sa rédjwit. Ròt chan ké sa kaché pa défo.\n* make\n* model\n* datetimeoriginal\n* exposuretime\n* fnumber\n* isospeedratings\n* focallength\n* artist\n* copyright\n* imagedescription\n* gpslatitude\n* gpslongitude\n* gpsaltitude",
"exif-orientation": "Oryantasyon",
"exif-xresolution": "Rézolisyon orizontal",
"Amire80"
]
},
+ "tog-underline": "𐌲𐌰𐍅𐌹𐍃𐌰𐌹𐍃 𐌿𐍆𐍃𐍄𐍂𐌹𐌺𐍃:",
"tog-hideminor": "𐌰𐍆𐍆𐌹𐌻𐌷 𐌻𐌴𐌹𐍄𐌹𐌻𐍉𐍃 𐌹𐌽𐌼𐌰𐌹𐌳𐌴𐌹𐌽𐍉𐍃 𐌰𐌽𐌳𐍅𐌰𐌹𐍂𐌸𐌰𐌹𐌶𐍉 𐌹𐌽𐌼𐌰𐌹𐌳𐌴𐌹𐌽𐍉",
"tog-extendwatchlist": "𐌿𐍆𐍂𐌰𐌺𐌴𐌹 𐍅𐌹𐍄𐌰𐍅𐌹𐌺𐍉𐌽 𐌳𐌿 𐌱𐌰𐌽𐌳𐍅𐌾𐌰𐌽 𐌰𐌻𐌻𐍉𐍃 𐌹𐌽𐌼𐌰𐌹𐌳𐌴𐌹𐌽𐍉𐍃, 𐌽𐌹 𐌸𐌰𐍄𐌰𐌹𐌽𐌴𐌹 𐌸𐍉𐍃 𐌰𐍆𐍄𐌿𐌼𐌹𐍃𐍄𐍉𐌽𐍃",
"tog-usenewrc": "𐌺𐌿𐌽𐌾𐌴 𐌹𐌽𐌼𐌰𐌹𐌳𐌴𐌹𐌽𐍉𐍃 𐌰𐍆𐌰𐍂 𐌻𐌰𐌿𐌱𐌰 𐌹𐌽 𐌰𐍆𐍄𐌿𐌼𐌹𐍃𐍄𐌰𐌹𐌼 𐌹𐌽𐌼𐌰𐌹𐌳𐌴𐌹𐌽𐌹𐌼 𐌰𐌽𐌰 𐍅𐌹𐍄𐌰𐍅𐌹𐌺𐍉𐌽",
"category-subcat-count": "{{PLURAL:$2|𐌸𐌰𐍄𐌰 𐌺𐌿𐌽𐌹 𐌷𐌰𐌱𐌰𐌹𐌸 𐌸𐌰𐍄𐌴𐌹𐌽𐌴𐌹 𐌹𐍆𐍄𐌿𐌼 𐌼𐌹𐌽𐌽𐌹𐌶𐍉𐌽 𐌺𐌿𐌽𐌹|𐌸𐌰𐍄𐌰 𐌺𐌿𐌽𐌹 𐌷𐌰𐌱𐌰𐌹𐌸 {{PLURAL:$1|𐌼𐌹𐌽𐌽𐌹𐌶𐍉𐌽 𐌺𐌿𐌽𐌹|𐌹𐍆𐍄𐌿𐌼𐌰 $1 𐌼𐌹𐌽𐌽𐌹𐌶𐍉𐌽𐌰 𐌺𐌿𐌽𐌾𐌰}}, 𐌰𐌻𐌻𐌰𐌹𐌶𐌴 $2 𐌺𐌿𐌽𐌾𐌴.}}",
"category-subcat-count-limited": "𐌸𐌰𐍄𐌰 𐌺𐌿𐌽𐌹 𐌷𐌰𐌱𐌰𐌹𐌸 𐌹𐍆𐍄𐌿𐌼𐍉𐌽/𐌹𐍆𐍄𐌿𐌼𐍉𐌽𐌰\n{{PLURAL:$1|𐌼𐌹𐌽𐌽𐌹𐌶𐍉𐌽 𐌺𐌿𐌽𐌹|$1 𐌼𐌹𐌽𐌽𐌹𐌶𐍉𐌽𐌰 𐌺𐌿𐌽𐌾𐌰}}.",
"category-article-count": "{{PLURAL:$2|𐌸𐌰𐍄𐌰 𐌺𐌿𐌽𐌹 𐌷𐌰𐌱𐌰𐌹𐌸 𐌸𐌰𐍄𐌰𐌹𐌽𐌴𐌹 𐌹𐍆𐍄𐌿𐌼𐌰𐌽 𐌻𐌰𐌿𐍆.|𐌹𐍆𐍄𐌿𐌼𐌰(𐌽𐍃) {{PLURAL:$1|𐌻𐌰𐌿𐍆𐍃 𐌹𐍃𐍄|$1 𐌻𐌰𐌿𐌱𐍉𐍃 𐍃𐌹𐌽𐌳}} 𐌹𐌽 𐌸𐌰𐌼𐌼𐌰 𐌺𐌿𐌽𐌾𐌰, 𐌰𐌻𐌻𐌰𐌹𐌶𐌴 $2 𐌻𐌰𐌿𐌱𐌴.}}",
- "category-article-count-limited": "{{{{PLURAL:$1|𐌹𐍆𐍄𐌿𐌼𐍃 𐌻𐌰𐌿𐍆𐍃 𐌹𐍃𐍄|$1 𐌹𐍆𐍄𐌿𐌼𐌰𐌹 𐌻𐌰𐌿𐌱𐍉𐍃 𐍃𐌹𐌽𐌳}} 𐌹𐌽 𐌰𐌽𐌳𐍅𐌰𐌹𐍂𐌸𐌰𐌼𐌼𐌰 𐌺𐌿𐌽𐌾𐌰.",
+ "category-article-count-limited": "{{PLURAL:$1|𐌹𐍆𐍄𐌿𐌼𐍃 𐌻𐌰𐌿𐍆𐍃 𐌹𐍃𐍄|$1 𐌹𐍆𐍄𐌿𐌼𐌰𐌹 𐌻𐌰𐌿𐌱𐍉𐍃 𐍃𐌹𐌽𐌳}} 𐌹𐌽 𐌰𐌽𐌳𐍅𐌰𐌹𐍂𐌸𐌰𐌼𐌼𐌰 𐌺𐌿𐌽𐌾𐌰.",
"category-file-count": "{{PLURAL:$2|𐌸𐌰𐍄𐌰 𐌺𐌿𐌽𐌹 𐌷𐌰𐌱𐌰𐌹𐌸 𐌸𐌰𐍄𐌰𐌹𐌽𐌴𐌹 𐌹𐍆𐍄𐌿𐌼 𐍆𐌰𐌾𐌻.|𐌹𐍆𐍄𐌿𐌼𐍉(𐌽𐌰) {{PLURAL:$1|𐍆𐌰𐌾𐌻 𐌹𐍃𐍄|$1 𐍆𐌰𐌾𐌻𐌰 𐍃𐌹𐌽𐌳}} 𐌹𐌽 𐌸𐌰𐌼𐌼𐌰 𐌺𐌿𐌽𐌾𐌰, 𐌰𐌻𐌻𐌰𐌹𐌶𐌴 $2 𐌻𐌰𐌿𐌱𐌴.}}",
"broken-file-category": "𐌻𐌰𐌿𐌱𐍉𐍃 𐌼𐌹𐌸 𐌱𐍂𐌹𐌺𐌰𐌽𐌰𐌹𐌼 𐍆𐌰𐌾𐌻𐌰𐌲𐌰𐍅𐌹𐍃𐍃𐌹𐌼",
"about": "𐌱𐌹",
"talk": "𐌲𐌰𐍅𐌰𐌿𐍂𐌳𐌹",
"views": "𐍃𐌹𐌿𐌽𐌴𐌹𐍃",
"toolbox": "𐍃𐌰𐍂𐍅𐌰𐌽𐍃",
- "tool-link-emailuser": "𐍃𐌰𐌽𐌳𐌴𐌹 𐌴-𐌱𐍉𐌺𐍉𐍃 𐌳𐌿 {{{{GENDER:$1|𐌼𐌰𐌽𐌽}}",
+ "tool-link-emailuser": "𐍃𐌰𐌽𐌳𐌴𐌹 𐌴-𐌱𐍉𐌺𐍉𐍃 𐌳𐌿 {{GENDER:$1|𐌼𐌰𐌽𐌽}}",
"imagepage": "𐌱𐌰𐌽𐌳𐍅𐌴𐌹 𐍆𐌰𐌾𐌻𐌰𐌻𐌰𐌿𐍆",
"mediawikipage": "𐌰𐌽𐌳𐌷𐌿𐌻𐌴𐌹 𐍅𐌰𐌿𐍂𐌳𐌰𐌻𐌰𐌿𐍆",
"viewhelppage": "𐌰𐌽𐌳𐌷𐌿𐌻𐌴𐌹 𐌷𐌹𐌻𐍀𐌰𐌻𐌰𐌿𐍆",
"cannotlogoutnow-title": "𐌰𐍆𐌻𐌴𐌹𐌸𐌰𐌽 𐌽𐌿 𐌼𐌰𐌷𐍄𐍃 𐌽𐌹𐍃𐍄",
"cannotlogoutnow-text": "𐌸𐌰𐌽 $1 𐌱𐍂𐌿𐌺𐌾𐌰𐌳𐌰 𐌰𐍆𐌻𐌴𐌹𐌸𐌰𐌽 𐌼𐌰𐌷𐍄𐍃 𐌽𐌹𐍃𐍄.",
"welcomeuser": "𐍅𐌰𐌹𐌻𐌰 𐌰𐌽𐌳𐌰𐌽𐌴𐌼𐍃, $1!",
+ "welcomecreation-msg": "𐌸𐌴𐌹𐌽𐌰 𐍂𐌰𐌷𐌽𐌴𐌹𐌽𐍃 𐌲𐌰𐍃𐌺𐍀𐌰𐌽𐌰 𐌹𐍃𐍄.\n𐌼𐌰𐌲𐍄 {{SITENAME}} [[Special:Preferences|𐍆𐍂𐌹𐌾𐍉𐌽𐍉𐍃 𐍅𐌰𐌹𐌷𐍄𐌹𐌽𐍃]] 𐌹𐌽𐌼𐌰𐌹𐌳𐌾𐌰𐌽 𐌾𐌰𐌱𐌰𐌹 𐍅𐌹𐌻𐌴𐌹𐍃.",
"yourname": "𐌰𐍄𐌲𐌰𐌲𐌲𐌰𐌽𐌰𐌼𐍉:",
"userlogin-yourname": "𐌰𐍄𐌲𐌰𐌲𐌲𐌰𐌽𐌰𐌼𐍉",
"userlogin-yourname-ph": "𐌼𐌴𐌻𐌴𐌹 𐌰𐍄𐌲𐌰𐌲𐌲𐌰𐌽𐌰𐌼𐍉 𐌸𐌴𐌹𐌽",
"cannotlogin-text": "𐌰𐍄𐌲𐌰𐌲𐌲𐌰𐌽 𐌼𐌰𐌷𐍄𐍃 𐌽𐌹𐍃𐍄.",
"cannotloginnow-title": "𐌽𐌿 𐌽𐌹 𐌼𐌰𐌲𐍄 𐌰𐍄𐌲𐌰𐌲𐌲𐌰𐌽",
"cannotloginnow-text": "𐌰𐍄𐌲𐌰𐌲𐌲𐌰𐌽 𐌼𐌰𐌷𐍄𐍃 𐌽𐌹𐍃𐍄 𐌸𐌰𐌽 $1 𐌱𐍂𐌿𐌺𐌾𐌰𐌳𐌰.",
+ "cannotcreateaccount-title": "𐌼𐌰𐌷𐍄𐍃 𐌽𐌹 𐍅𐌰𐍃 𐌳𐌿 𐍃𐌺𐌰𐍀𐌾𐌰𐌽 𐍂𐌰𐌷𐌽𐌴𐌹𐌽𐌹𐌽𐍃",
"password-change-forbidden": "𐌽𐌹 𐌼𐌰𐌲𐍄 𐌹𐌽𐌼𐌰𐌹𐌳𐌾𐌰𐌽 𐌲𐌰𐌼𐍉𐍄𐌰𐍅𐌰𐌿𐍂𐌳𐌰 𐌰𐌽𐌰 𐌸𐌰𐌼𐌼𐌰 𐍅𐌹𐌺𐌾𐌰.",
"login": "𐌰𐍄𐌲𐌰𐌲𐌲",
- "nav-login-createaccount": "ð\90\8c°ð\90\8d\84ð\90\8c²ð\90\8c°ð\90\8c²ð\90\8c² / ð\90\8d\83ð\90\8cºð\90\8c°ð\90\8d\80ð\90\8c´ð\90\8c¹ ð\90\8cºð\90\8c°ð\90\8d\85ð\90\8d\84ð\90\8d\83ð\90\8c¾ð\90\8d\89𐌽",
+ "nav-login-createaccount": "ð\90\8c°ð\90\8d\84ð\90\8c²ð\90\8c°ð\90\8c²ð\90\8c² / ð\90\8d\83ð\90\8cºð\90\8c°ð\90\8d\80ð\90\8c´ð\90\8c¹ ð\90\8d\82ð\90\8c°ð\90\8c·ð\90\8c½ð\90\8c´ð\90\8c¹𐌽",
"logout": "𐌰𐍆𐌻𐌴𐌹𐌸",
"userlogout": "𐌰𐍆𐌻𐌴𐌹𐌸",
- "userlogin-noaccount": "ð\90\8c½ð\90\8c¹ ð\90\8c·ð\90\8c°ð\90\8c±ð\90\8c°ð\90\8c¹ð\90\8d\83 ð\90\8cºð\90\8c°ð\90\8d\85ð\90\8d\84ð\90\8d\83ð\90\8c¾ð\90\8d\89𐌽?",
+ "userlogin-noaccount": "ð\90\8c½ð\90\8c¹ ð\90\8c·ð\90\8c°ð\90\8c±ð\90\8c°ð\90\8c¹ð\90\8d\83 ð\90\8d\82ð\90\8c°ð\90\8c·ð\90\8c½ð\90\8c´ð\90\8c¹𐌽?",
"userlogin-joinproject": "𐌲𐌰𐌳𐌰𐌹𐌻𐌴𐌹 𐌹𐌽 𐌽𐌰𐍄𐌾𐌰𐍃𐍄𐌰𐌳𐌰 {{SITENAME}}",
- "createaccount": "ð\90\8d\83ð\90\8cºð\90\8c°ð\90\8d\80ð\90\8c´ð\90\8c¹ ð\90\8cºð\90\8c°ð\90\8d\85ð\90\8d\84ð\90\8d\83ð\90\8c¾ð\90\8d\89𐌽",
+ "createaccount": "ð\90\8d\83ð\90\8cºð\90\8c°ð\90\8d\80ð\90\8c´ð\90\8c¹ ð\90\8d\82ð\90\8c°ð\90\8c·ð\90\8c½ð\90\8c´ð\90\8c¹𐌽",
"userlogin-resetpassword-link": "𐌿𐍆𐌰𐍂𐌼𐌿𐌽𐌽𐍉𐌳𐌴𐍃 𐌸𐌴𐌹𐌽𐌰𐌼𐌼𐌰 𐌲𐌰𐌼𐍉𐍄𐌰𐍅𐌰𐌿𐍂𐌳𐌰?",
"userlogin-helplink2": "𐌷𐌹𐌻𐍀𐌰 𐌼𐌹𐌸 𐌰𐍄𐌲𐌰𐌲𐌲𐌰",
+ "userlogin-createanother": "𐍃𐌺𐌰𐍀𐌴𐌹 𐌰𐌽𐌸𐌰𐍂𐌰 𐍂𐌰𐌷𐌽𐌴𐌹𐌽",
"createacct-emailoptional": "𐌴-𐌱𐍉𐌺𐍉𐍃 (𐌼𐌰𐌷𐍄𐌴𐌹𐌲𐍉𐍃)",
"createacct-email-ph": "𐌼𐌴𐌻𐌴𐌹 𐌸𐌴𐌹𐌽𐍉𐍃 𐌴-𐌱𐍉𐌺𐍉𐍃",
"createacct-another-email-ph": "𐌼𐌴𐌻𐌴𐌹 𐌴-𐌱𐍉𐌺𐍉𐍃",
+ "createaccountmail-help": "𐌼𐌰𐌲 𐌱𐍂𐌿𐌺𐌾𐌰𐌳𐌰 𐌳𐌿 𐍃𐌺𐌰𐍀𐌾𐌰𐌽 𐍂𐌰𐌷𐌽𐌴𐌹𐌽 𐌰𐌽𐌸𐌰𐍂𐌰𐌼𐌼𐌰 𐌼𐌰𐌽 𐌹𐌽𐌿𐌷 𐌲𐌰𐌼𐍉𐍄𐌰𐍅𐌰𐌿𐍂𐌳 𐌻𐌰𐌹𐍃𐌾𐌰𐌽",
"createacct-realname": "𐍃𐌿𐌽𐌾𐌴𐌹𐌽 𐌽𐌰𐌼𐍉 (𐌼𐌰𐌷𐍄𐌴𐌹𐌲)",
"createacct-reason": "𐌲𐍂𐌿𐌽𐌳𐌿𐍃",
- "createacct-submit": "𐍃𐌺𐌰𐍀𐌴𐌹 𐌸𐌴𐌹𐌽𐌰 𐌺𐌰𐍅𐍄𐍃𐌾𐍉𐌽",
+ "createacct-reason-ph": "𐌳𐌿𐍈𐌴 𐌸𐌿 𐍃𐌺𐌰𐍀𐌾𐌹𐍃 𐍂𐌰𐌷𐌽𐌴𐌹𐌽",
+ "createacct-submit": "𐍃𐌺𐌰𐍀𐌴𐌹 𐌸𐌴𐌹𐌽𐌰 𐍂𐌰𐌷𐌽𐌴𐌹𐌽",
+ "createacct-another-submit": "𐍃𐌺𐌰𐍀𐌴𐌹 𐍂𐌰𐌷𐌽𐌴𐌹𐌽",
+ "createacct-another-continue-submit": "𐌸𐌰𐌹𐍂𐌷𐍅𐌹𐍃 𐌼𐌹𐌸 𐌲𐌰𐍃𐌺𐌰𐍆𐍄𐌰𐌹 𐍂𐌰𐌷𐌽𐌴𐌹𐌽𐌰𐌹𐍃",
"createacct-benefit-heading": "{{SITENAME}} 𐍄𐌰𐍅𐌹𐌸 𐌹𐍃𐍄 𐍆𐍂𐌰𐌼 𐌼𐌰𐌽𐌽𐌰𐌼 𐍃𐍅𐌴 𐌸𐌿𐌺.",
"createacct-benefit-body1": "{{PLURAL:$1|𐌹𐌽𐌼𐌰𐌹𐌳𐌴𐌹𐌽𐍃|𐌹𐌽𐌼𐌰𐌹𐌳𐌴𐌹𐌽𐍉𐍃}}",
"createacct-benefit-body2": "{{PLURAL:$1|𐌻𐌰𐌿𐍆𐍃|𐌻𐌰𐌿𐌱𐍉𐍃}}",
"badretype": "𐌲𐌰𐌼𐍉𐍄𐌰𐍅𐌰𐌿𐍂𐌳𐌰 𐌸𐍉𐌴𐌹 𐌸𐌿 𐌲𐌰𐌼𐌴𐌻𐌹𐌳𐌴𐍃 𐌽𐌹 𐌹𐌽𐌲𐌰𐌻𐌴𐌹𐌺𐍉𐌽𐌳.",
+ "usernameinprogress": "𐍂𐌰𐌷𐌽𐌴𐌹𐌽𐌹𐌲𐌰𐍃𐌺𐌰𐍆𐍄𐍃 𐌸𐌰𐌼𐌼𐌰 𐌰𐍄𐌲𐌰𐌲𐌲𐌰𐌽𐌰𐌼𐌹𐌽 𐌾𐌿 𐍄𐍉𐌾𐌰𐌳𐌰. 𐌱𐌹𐌳𐌾𐌰𐌼 𐌸𐌿𐌺 𐌴𐌹 𐌱𐌹𐌳𐌰𐌹𐍃.",
+ "createacct-error": "𐌰𐌹𐍂𐌶𐌴𐌹 𐌹𐌽 𐌲𐌰𐍃𐌺𐌰𐍆𐍄𐌰𐌹 𐍂𐌰𐌷𐌽𐌴𐌹𐌽𐌰𐌹𐍃",
+ "createaccounterror": "𐌼𐌰𐌷𐍄𐍃 𐌽𐌹 𐍅𐌰𐍃 𐌳𐌿 𐍃𐌺𐌰𐍀𐌾𐌰𐌽 𐍂𐌰𐌷𐌽𐌴𐌹𐌽: $1",
+ "nocookiesfornew": "𐌱𐍂𐌿𐌺𐌾𐌰𐍂𐌰𐌷𐌽𐌴𐌹𐌽𐍃 𐌽𐌹 𐌲𐌰𐍃𐌺𐌰𐍀𐌰𐌽𐌰 𐌿𐌽𐍄𐌴 𐍅𐌴𐌹𐍃 𐌽𐌹 𐌼𐌰𐌷𐍄𐌴𐌳𐌿𐌼 𐌱𐍂𐌿𐌽𐌽𐌰𐌽 𐌲𐌰𐍃𐌹𐌲𐌻𐌾𐌰𐌽. 𐍅𐌹𐍃 𐌰𐍂𐌽𐌹𐌱𐌰 𐌸𐌰𐍄𐌴𐌹 𐌸𐌿 𐌰𐌽𐌳𐌻𐌴𐍄𐌹𐍃 𐌺𐍉𐌺𐍉𐍃, 𐌰𐍆𐍄𐍂𐌰𐌰𐌽𐌰𐌽𐌹𐌿𐌴𐌹 𐌻𐌰𐌿𐍆 𐌾𐌰𐌷 𐍃𐍉𐌺𐌴𐌹 𐌰𐍆𐍄𐍂𐌰.",
+ "nosuchuser": "𐌽𐌹𐍃𐍄 𐌱𐍂𐌿𐌺𐌾𐌰𐌽𐌳𐍃 𐌼𐌹𐌸 𐌽𐌰𐌼𐌹𐌽 \"$1\".\n𐌽𐌰𐌼𐌽𐌰 𐌱𐍂𐌿𐌺𐌾𐌰𐌽𐌳𐌴 𐌼𐌰𐌲𐌿𐌽 𐌷𐌰𐌱𐌰𐌽 𐌷𐌰𐌿𐌱𐌹𐌳𐌹𐍃 𐌱𐍉𐌺𐍉𐍃.\n𐍃𐌰𐌹𐍈 𐌰𐍆𐍄𐍂𐌰 𐌸𐌴𐌹𐌽𐌰 𐌼𐌴𐌻𐌴𐌹𐌽𐍃, 𐌸𐌰𐌿 [[Special:CreateAccount|𐍃𐌺𐌰𐍀𐌴𐌹 𐍂𐌰𐌷𐌽𐌴𐌹𐌽]].",
"nouserspecified": "𐍃𐌺𐌰𐌻𐍄 𐌲𐌹𐌱𐌰𐌽 𐌰𐍄𐌲𐌰𐌲𐌲𐌰𐌽𐌰𐌼𐍉.",
"password-login-forbidden": "𐌱𐍂𐌿𐌺𐌴𐌹𐌽𐍃 𐌸𐌹𐍃 𐌰𐍄𐌲𐌰𐌲𐌲𐌰𐌽𐌰𐌼𐌹𐌽𐍃 𐌾𐌰𐌷 𐌸𐌹𐍃 𐌲𐌰𐌼𐍉𐍄𐌰𐍅𐌰𐌿𐍂𐌳𐌹𐍃 𐍆𐌰𐌿𐍂𐌱𐌹𐌿𐌳𐌰𐌽𐌰 𐌹𐍃𐍄.",
"mailmypassword": "𐌰𐍆𐍄𐍂𐌰 𐍃𐌰𐍄𐌴𐌹 𐌲𐌰𐌼𐍉𐍄𐌰𐍅𐌰𐌿𐍂𐌳",
"emailconfirmlink": "𐌲𐌰𐍃𐌹𐌲𐌻𐌴𐌹 𐌸𐌴𐌹𐌽𐍉𐍃 𐌴-𐌱𐍉𐌺𐍉𐍃",
"emaildisabled": "𐍃𐌰 𐌽𐌰𐍄𐌾𐌰𐍃𐍄𐌰𐌸𐍃 𐌽𐌹 𐌼𐌰𐌲 𐍃𐌰𐌽𐌳𐌾𐌰𐌽 𐌴-𐌱𐍉𐌺𐍉𐍃.",
+ "accountcreatedtext": "𐌱𐍂𐌿𐌺𐌾𐌰𐍂𐌰𐌷𐌽𐌴𐌹𐌽𐍃 [[{{ns:User}}:$1|$1]] ([[{{ns:User talk}}:$1|𐌲𐌰𐍅𐌰𐌿𐍂𐌳𐌹]]) 𐌲𐌰𐍃𐌺𐌰𐍀𐌰𐌽𐌰 𐌹𐍃𐍄.",
"loginlanguagelabel": "𐍂𐌰𐌶𐌳𐌰: $1",
"pt-login": "𐌰𐍄𐌲𐌰𐌲𐌲",
"pt-login-button": "𐌰𐍄𐌲𐌰𐌲𐌲",
"pt-login-continue-button": "𐌸𐌰𐌹𐍂𐌷𐍅𐌹𐍃 𐌰𐍄𐌲𐌰𐌲𐌲𐌰𐌽",
- "pt-createaccount": "ð\90\8d\83ð\90\8cºð\90\8c°ð\90\8d\80ð\90\8c´ð\90\8c¹ ð\90\8cºð\90\8c°ð\90\8d\85ð\90\8d\84ð\90\8d\83ð\90\8c¾ð\90\8d\89𐌽",
+ "pt-createaccount": "ð\90\8d\83ð\90\8cºð\90\8c°ð\90\8d\80ð\90\8c´ð\90\8c¹ ð\90\8d\82ð\90\8c°ð\90\8c·ð\90\8c½ð\90\8c´ð\90\8c¹𐌽",
"pt-userlogout": "𐌰𐍆𐌻𐌴𐌹𐌸",
+ "resetpass_header": "𐌹𐌽𐌼𐌰𐌹𐌳𐌴𐌹 𐌲𐌰𐌼𐍉𐍄𐌰𐍅𐌰𐌿𐍂𐌳 𐍂𐌰𐌷𐌽𐌴𐌹𐌽𐌰𐌹𐍃",
"oldpassword": "𐍆𐌰𐌹𐍂𐌽𐌹 𐌲𐌰𐌼𐍉𐍄𐌰𐍅𐌰𐌿𐍂𐌳:",
"botpasswords": "𐌱𐌰𐌿𐍄𐌹𐍃 𐌲𐌰𐌼𐍉𐍄𐌰𐍅𐌰𐌿𐍂𐌳𐌰",
"botpasswords-existing": "𐍅𐌹𐍃𐌰𐌽𐌳𐍉𐌽𐌳𐌰 𐌱𐌰𐌿𐍄𐌹𐍃 𐌲𐌰𐌼𐍉𐍄𐌰𐍅𐌰𐌿𐍂𐌳𐌰",
"loginreqlink": "𐌰𐍄𐌲𐌰𐌲𐌲",
"newarticle": "(𐌽𐌹𐍅𐌹)",
"newarticletext": "𐌻𐌰𐌹𐍃𐍄𐌹𐌳𐌴𐍃 𐌲𐌰𐍅𐌹𐍃 𐌳𐌿 𐌻𐌰𐌿𐌱𐌰 𐍃𐌰𐌴𐌹 𐌽𐌹𐍃𐍄. 𐌳𐌿 𐍃𐌺𐌰𐍀𐌾𐌰𐌽 𐌸𐌰𐌽𐌰 𐌻𐌰𐌿𐍆, 𐌰𐌽 𐌰𐍃𐍄𐍉𐌳𐌴𐌹 𐌼𐌴𐌻𐌾𐌰𐌽 𐌹𐌽 𐌰𐍂𐌺𐌰𐌹 𐌿𐍆 (𐍃𐌰𐌹𐍈 [$1 𐌷𐌹𐌻𐍀𐌰𐌻𐌰𐌿𐍆] 𐌼𐌰𐌽𐌰𐌲𐌹𐌶𐌹𐌽 𐌺𐌿𐌽𐌸𐌾𐌰). 𐌾𐌰𐌱𐌰𐌹 𐌹𐍃 𐌷𐌴𐍂 𐌹𐌽 𐌰𐌹𐍂𐌶𐌴𐌹𐌽𐍃, 𐌲𐌰𐌲𐌲 𐌳𐌿 <strong>𐌹𐌱𐌿𐌺𐌰𐌷𐌰𐌿𐌱𐌹𐌳𐌹𐌻𐍉𐌽</strong>.",
- "anontalkpagetext": "----\n<em>ð\90\8c¸ð\90\8c°ð\90\8d\84ð\90\8c° ð\90\8c¹ð\90\8d\83ð\90\8d\84 ð\90\8c²ð\90\8c°ð\90\8d\85ð\90\8c°ð\90\8c¿ð\90\8d\82ð\90\8c³ð\90\8c¾ð\90\8c°ð\90\8c»ð\90\8c°ð\90\8c¿ð\90\8d\86ð\90\8d\83 ð\90\8c¿ð\90\8c½ð\90\8c½ð\90\8c°ð\90\8c¼ð\90\8c½ð\90\8c¹ð\90\8c¸ð\90\8c°ð\90\8c¼ð\90\8c¼ð\90\8c° ð\90\8c±ð\90\8d\82ð\90\8c¿ð\90\8cºð\90\8c¾ð\90\8c°ð\90\8c½ð\90\8c³ ð\90\8d\83ð\90\8c°ð\90\8c´ð\90\8c¹ ð\90\8c½ð\90\8c°ð\90\8c¿ð\90\8c· ð\90\8c½ð\90\8c¹ ð\90\8c²ð\90\8c°ð\90\8d\83ð\90\8cºð\90\8d\89ð\90\8d\80 ð\90\8c°ð\90\8cºð\90\8c°ð\90\8d\85ð\90\8c½ð\90\8d\84, ð\90\8c¸ð\90\8c°ð\90\8c¿ ð\90\8d\83ð\90\8c°ð\90\8c´ð\90\8c¹ ð\90\8c½ð\90\8c¹ ð\90\8c±ð\90\8d\82ð\90\8c¿ð\90\8cºð\90\8c¾ð\90\8c¹ð\90\8c¸ ð\90\8c¸ð\90\8c¹ð\90\8d\83.<em>\nð\90\8c¹ð\90\8c½ ð\90\8c¸ð\90\8c¹ð\90\8d\83 ð\90\8d\83ð\90\8cºð\90\8c¿ð\90\8c»ð\90\8c³ ð\90\8c¹ð\90\8d\83ð\90\8d\84 ð\90\8c¿ð\90\8c½ð\90\8d\83 ð\90\8c³ð\90\8c¿ ð\90\8c±ð\90\8d\82ð\90\8c¿ð\90\8cºð\90\8c¾ð\90\8c°ð\90\8c½ ð\90\8d\82ð\90\8c°ð\90\8c¸ð\90\8c¾ð\90\8d\89ð\90\8c½ð\90\8d\83 IP ð\90\8c³ð\90\8c¿ ð\90\8cºð\90\8c¿ð\90\8c½ð\90\8c½ð\90\8c°ð\90\8c½ ð\90\8c¹ð\90\8c½ð\90\8c°/ð\90\8c¹ð\90\8c¾ð\90\8c°.\nð\90\8c¾ð\90\8c°ð\90\8c±ð\90\8c°ð\90\8c¹ ð\90\8c¸ð\90\8c¿ ð\90\8c¹ð\90\8d\83 ð\90\8c¿ð\90\8c½ð\90\8c½ð\90\8c°ð\90\8c¼ð\90\8c½ð\90\8c¹ð\90\8c¸ð\90\8d\83 ð\90\8c±ð\90\8d\82ð\90\8c¿ð\90\8cºð\90\8c¾ð\90\8c°ð\90\8c½ð\90\8c³ð\90\8d\83 ð\90\8c¾ð\90\8c°ð\90\8c· ð\90\8c¹ð\90\8d\83ð\90\8d\84 ð\90\8c¸ð\90\8c¿ð\90\8d\83 ð\90\8d\83ð\90\8d\85ð\90\8c°ð\90\8d\83ð\90\8d\85ð\90\8c´ ð\90\8c¿ð\90\8c½ð\90\8c²ð\90\8c°ð\90\8c·ð\90\8c°ð\90\8c·ð\90\8c¾ð\90\8d\89 ð\90\8d\85ð\90\8c°ð\90\8c¿ð\90\8d\82ð\90\8c³ð\90\8c° ð\90\8d\85ð\90\8c´ð\90\8d\83ð\90\8c¿ð\90\8c½ ð\90\8c¸ð\90\8c¿ð\90\8d\83, ð\90\8c±ð\90\8c¹ð\90\8c³ð\90\8c¾ð\90\8c°ð\90\8c¼ ð\90\8c¸ð\90\8c¿ð\90\8cº, [[Special:CreateAccount|ð\90\8d\83ð\90\8cºð\90\8c°ð\90\8d\80ð\90\8c´ð\90\8c¹ ð\90\8c°ð\90\8cºð\90\8c°ð\90\8d\85ð\90\8c½ð\90\8d\84]] 𐌸𐌰𐌿 [[Special:UserLogin|𐌰𐍄𐌲𐌰𐌲𐌲]] 𐌴𐌹 𐌽𐌹 𐍃𐌹𐌾𐌰𐌹 𐌰𐌹𐍂𐌶𐌴𐌹𐌽𐍃 𐌼𐌹𐌸 𐌰𐌽𐌸𐌰𐍂𐌰𐌹𐌼 𐌿𐌽𐌽𐌰𐌼𐌽𐌹𐌸𐌰𐌹𐌼 𐌱𐍂𐌿𐌺𐌾𐌰𐌽𐌳𐌰𐌼 𐌹𐌽 𐌰𐌽𐌰𐍅𐌰𐌹𐍂𐌸𐌰.",
+ "anontalkpagetext": "----\n<em>ð\90\8c¸ð\90\8c°ð\90\8d\84ð\90\8c° ð\90\8c¹ð\90\8d\83ð\90\8d\84 ð\90\8c²ð\90\8c°ð\90\8d\85ð\90\8c°ð\90\8c¿ð\90\8d\82ð\90\8c³ð\90\8c¾ð\90\8c°ð\90\8c»ð\90\8c°ð\90\8c¿ð\90\8d\86ð\90\8d\83 ð\90\8c¿ð\90\8c½ð\90\8c½ð\90\8c°ð\90\8c¼ð\90\8c½ð\90\8c¹ð\90\8c¸ð\90\8c°ð\90\8c¼ð\90\8c¼ð\90\8c° ð\90\8c±ð\90\8d\82ð\90\8c¿ð\90\8cºð\90\8c¾ð\90\8c°ð\90\8c½ð\90\8c³ ð\90\8d\83ð\90\8c°ð\90\8c´ð\90\8c¹ ð\90\8c½ð\90\8c°ð\90\8c¿ð\90\8c· ð\90\8c½ð\90\8c¹ ð\90\8c²ð\90\8c°ð\90\8d\83ð\90\8cºð\90\8d\89ð\90\8d\80 ð\90\8d\82ð\90\8c°ð\90\8c·ð\90\8c½ð\90\8c´ð\90\8c¹ð\90\8c½, ð\90\8c¸ð\90\8c°ð\90\8c¿ ð\90\8d\83ð\90\8c°ð\90\8c´ð\90\8c¹ ð\90\8c½ð\90\8c¹ ð\90\8c±ð\90\8d\82ð\90\8c¿ð\90\8cºð\90\8c¾ð\90\8c¹ð\90\8c¸ ð\90\8c¸ð\90\8c¹ð\90\8d\83.<em>\nð\90\8c¹ð\90\8c½ ð\90\8c¸ð\90\8c¹ð\90\8d\83 ð\90\8d\83ð\90\8cºð\90\8c¿ð\90\8c»ð\90\8c³ ð\90\8c¹ð\90\8d\83ð\90\8d\84 ð\90\8c¿ð\90\8c½ð\90\8d\83 ð\90\8c³ð\90\8c¿ ð\90\8c±ð\90\8d\82ð\90\8c¿ð\90\8cºð\90\8c¾ð\90\8c°ð\90\8c½ ð\90\8d\82ð\90\8c°ð\90\8c¸ð\90\8c¾ð\90\8d\89ð\90\8c½ð\90\8d\83 IP ð\90\8c³ð\90\8c¿ ð\90\8cºð\90\8c¿ð\90\8c½ð\90\8c½ð\90\8c°ð\90\8c½ ð\90\8c¹ð\90\8c½ð\90\8c°/ð\90\8c¹ð\90\8c¾ð\90\8c°.\nð\90\8c¾ð\90\8c°ð\90\8c±ð\90\8c°ð\90\8c¹ ð\90\8c¸ð\90\8c¿ ð\90\8c¹ð\90\8d\83 ð\90\8c¿ð\90\8c½ð\90\8c½ð\90\8c°ð\90\8c¼ð\90\8c½ð\90\8c¹ð\90\8c¸ð\90\8d\83 ð\90\8c±ð\90\8d\82ð\90\8c¿ð\90\8cºð\90\8c¾ð\90\8c°ð\90\8c½ð\90\8c³ð\90\8d\83 ð\90\8c¾ð\90\8c°ð\90\8c· ð\90\8c¹ð\90\8d\83ð\90\8d\84 ð\90\8c¸ð\90\8c¿ð\90\8d\83 ð\90\8d\83ð\90\8d\85ð\90\8c°ð\90\8d\83ð\90\8d\85ð\90\8c´ ð\90\8c¿ð\90\8c½ð\90\8c²ð\90\8c°ð\90\8c·ð\90\8c°ð\90\8c·ð\90\8c¾ð\90\8d\89 ð\90\8d\85ð\90\8c°ð\90\8c¿ð\90\8d\82ð\90\8c³ð\90\8c° ð\90\8d\85ð\90\8c´ð\90\8d\83ð\90\8c¿ð\90\8c½ ð\90\8c¸ð\90\8c¿ð\90\8d\83, ð\90\8c±ð\90\8c¹ð\90\8c³ð\90\8c¾ð\90\8c°ð\90\8c¼ ð\90\8c¸ð\90\8c¿ð\90\8cº, [[Special:CreateAccount|ð\90\8d\83ð\90\8cºð\90\8c°ð\90\8d\80ð\90\8c´ð\90\8c¹ ð\90\8d\82ð\90\8c°ð\90\8c·ð\90\8c½ð\90\8c´ð\90\8c¹ð\90\8c½]] 𐌸𐌰𐌿 [[Special:UserLogin|𐌰𐍄𐌲𐌰𐌲𐌲]] 𐌴𐌹 𐌽𐌹 𐍃𐌹𐌾𐌰𐌹 𐌰𐌹𐍂𐌶𐌴𐌹𐌽𐍃 𐌼𐌹𐌸 𐌰𐌽𐌸𐌰𐍂𐌰𐌹𐌼 𐌿𐌽𐌽𐌰𐌼𐌽𐌹𐌸𐌰𐌹𐌼 𐌱𐍂𐌿𐌺𐌾𐌰𐌽𐌳𐌰𐌼 𐌹𐌽 𐌰𐌽𐌰𐍅𐌰𐌹𐍂𐌸𐌰.",
"noarticletext": "𐌽𐌿 𐌽𐌹 𐍃𐌹𐌽𐌳 𐌱𐍉𐌺𐍉𐍃 𐌹𐌽 𐌸𐌰𐌼𐌼𐌰 𐌻𐌰𐌿𐌱𐌰.\n𐌼𐌰𐌲𐍄 [[Special:Search/{{PAGENAME}}|𐍃𐍉𐌺𐌾𐌰𐌽 𐌸𐌰𐍄𐌰 𐌻𐌰𐌿𐌱𐌰-𐌿𐍆𐌰𐍂𐌼𐌴𐌻𐌹]] 𐌹𐌽 𐌰𐌽𐌸𐌰𐍂𐌰𐌹𐌼 𐌻𐌰𐌿𐌱𐌰𐌼, <span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} 𐍃𐍉𐌺𐌾𐌰𐌽 𐌲𐌰𐌷𐌰𐌷𐌾𐍉 𐌲𐌰𐍆𐌰𐍃𐍄𐍉𐍃], 𐌰𐌹𐌸𐌸𐌰𐌿 [{{fullurl:{{FULLPAGENAME}}|action=edit}} 𐍃𐌺𐌰𐍀𐌾𐌰𐌽 𐌸𐌰𐌽𐌰 𐌻𐌰𐌿𐍆.]</ span>",
"noarticletext-nopermission": "𐌽𐌿 𐌽𐌹 𐍃𐌹𐌽𐌳 𐌱𐍉𐌺𐍉𐍃 𐌹𐌽 𐌸𐌰𐌼𐌼𐌰 𐌻𐌰𐌿𐌱𐌰.\n𐌼𐌰𐌲𐍄 [[Special:Search/{{PAGENAME}}|𐍃𐍉𐌺𐌾𐌰𐌽 𐌸𐌰𐍄𐌰 𐌻𐌰𐌿𐌱𐌰-𐌿𐍆𐌰𐍂𐌼𐌴𐌻𐌹]] 𐌹𐌽 𐌰𐌽𐌸𐌰𐍂𐌰𐌹𐌼 𐌻𐌰𐌿𐌱𐌰𐌼, 𐌸𐌰𐌿 <span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} 𐍃𐍉𐌺𐌾𐌰𐌽 𐌲𐌰𐌷𐌰𐌷𐌾𐍉 𐌲𐌰𐍆𐌰𐍃𐍄𐍉𐍃]</span>, 𐌹𐌸 𐌽𐌹 𐌷𐌰𐌱𐌰𐌹𐍃 𐌰𐌽𐌳𐌻𐌴𐍄 𐍃𐌺𐌰𐍀𐌾𐌰𐌽 𐌸𐌰𐌽𐌰 𐌻𐌰𐌿𐍆.",
- "userpage-userdoesnotexist-view": "ð\90\8c±ð\90\8d\82ð\90\8c¿ð\90\8cºð\90\8c¾ð\90\8c°ð\90\8cºð\90\8c°ð\90\8d\85ð\90\8d\84ð\90\8d\83ð\90\8c¾ð\90\8d\89 \"$1\" 𐌽𐌹𐍃𐍄 𐌰𐌽𐌰𐌼𐌴𐌻𐌹𐌳𐌰.",
+ "userpage-userdoesnotexist-view": "ð\90\8c±ð\90\8d\82ð\90\8c¿ð\90\8cºð\90\8c¾ð\90\8c°ð\90\8d\82ð\90\8c°ð\90\8c·ð\90\8c½ð\90\8c´ð\90\8c¹ð\90\8c½ \"$1\" 𐌽𐌹𐍃𐍄 𐌰𐌽𐌰𐌼𐌴𐌻𐌹𐌳𐌰.",
"updated": "(𐌰𐌽𐌰𐌽𐌹𐍅𐌹𐌸)",
"previewnote": "<strong>𐌲𐌰𐌼𐌹𐌽𐌸𐌴𐌹 𐌸𐌰𐍄𐌴𐌹 𐌸𐌰𐍄𐌰 𐌹𐍃𐍄 𐌸𐌰𐍄𐌰𐌹𐌽𐌴𐌹 𐍆𐌰𐌿𐍂𐌰𐍃𐌹𐌿𐌽𐍃.</strong>\n𐌸𐌴𐌹𐌽𐍉𐍃 𐌹𐌽𐌼𐌰𐌹𐌳𐌴𐌹𐌽𐍉𐍃 𐌽𐌰𐌿𐌷 𐌽𐌹 𐌲𐌰𐍆𐌰𐍃𐍄𐌰𐌽𐍉𐍃 𐍃𐌹𐌽𐌳!",
"continue-editing": "𐌲𐌰𐌲𐌲 𐌳𐌿 𐌹𐌽𐌼𐌰𐌹𐌳𐌴𐌹𐌽𐌹𐍃𐍄𐌰𐌳𐌰",
"shown-title": "𐌱𐌰𐌽𐌳𐍅𐌴𐌹 $1 {{PLURAL:$1|𐍄𐌰𐌿𐌹|𐍄𐍉𐌾𐌰}} 𐍈𐌰𐍂𐌾𐌰𐌼𐌼𐌴𐌷 𐌻𐌰𐌿𐌱𐌰.",
"viewprevnext": "𐍃𐌹𐌿𐌽𐌴𐌹𐍃 ($1 {{int:pipe-separator}} $2) ($3)",
"searchmenu-exists": "<strong>𐌹𐍃𐍄 𐌻𐌰𐌿𐍆𐍃 𐌷𐌰𐌹𐍄𐌰𐌽𐍃 \"[[:$1]]\" 𐌰𐌽𐌰 𐌸𐌰𐌼𐌼𐌰 𐍅𐌹𐌺𐌾𐌰.</strong> {{PLURAL:$2|0=|𐍃𐌰𐌹𐍈 𐌾𐌰𐌷 𐌰𐌽𐌸𐌰𐍂𐌰 𐍄𐍉𐌾𐌰 𐍃𐍉𐌺𐌴𐌹𐌽𐌰𐌹𐍃 𐌸𐍉𐌴𐌹 𐌱𐌹𐌲𐌹𐍄𐌰𐌽𐌰 𐍃𐌹𐌽𐌳.}}",
- "searchmenu-new": "<strong>ð\90\8d\83ð\90\8cºð\90\8c°ð\90\8d\80ð\90\8c´ð\90\8c¹ ð\90\8c»ð\90\8c°ð\90\8c¿ð\90\8d\86 \"[[:$1]]\" ð\90\8c°ð\90\8c½ð\90\8c° ð\90\8c¸ð\90\8c¹ð\90\8c¶ð\90\8c°ð\90\8c¹ ð\90\8d\85ð\90\8c¹ð\90\8cºð\90\8c¹!</strong> {{{{PLURAL:$2|0=|𐍃𐌰𐌹 𐌾𐌰𐌷 𐌻𐌰𐌿𐍆 𐌱𐌹𐌲𐌹𐍄𐌰𐌽𐌰 𐌸𐌴𐌹𐌽𐌰𐌹 𐍃𐍉𐌺𐌴𐌹𐌽𐌰𐌹.|𐍃𐌰𐌹 𐌾𐌰𐌷 𐍄𐍉𐌾𐌰 𐍃𐍉𐌺𐌴𐌹𐌽𐌰𐌹𐍃 𐌱𐌹𐌲𐌹𐍄𐌰𐌽𐌰.}}",
+ "searchmenu-new": "<strong>ð\90\8d\83ð\90\8cºð\90\8c°ð\90\8d\80ð\90\8c´ð\90\8c¹ ð\90\8c»ð\90\8c°ð\90\8c¿ð\90\8d\86 \"[[:$1]]\" ð\90\8c°ð\90\8c½ð\90\8c° ð\90\8c¸ð\90\8c°ð\90\8c¼ð\90\8c¼ð\90\8c° ð\90\8d\85ð\90\8c¹ð\90\8cºð\90\8c¾ð\90\8c°!</strong> {{{{PLURAL:$2|0=|𐍃𐌰𐌹 𐌾𐌰𐌷 𐌻𐌰𐌿𐍆 𐌱𐌹𐌲𐌹𐍄𐌰𐌽𐌰 𐌸𐌴𐌹𐌽𐌰𐌹 𐍃𐍉𐌺𐌴𐌹𐌽𐌰𐌹.|𐍃𐌰𐌹 𐌾𐌰𐌷 𐍄𐍉𐌾𐌰 𐍃𐍉𐌺𐌴𐌹𐌽𐌰𐌹𐍃 𐌱𐌹𐌲𐌹𐍄𐌰𐌽𐌰.}}",
"searchprofile-articles": "𐌷𐌰𐌱𐌰𐌽𐌳𐌰𐌽𐍃 𐌻𐌰𐌿𐌱𐍉𐍃",
"searchprofile-images": "𐌼𐌰𐌽𐌰𐌲𐌼𐌰𐌹𐌳𐌾𐌰",
"searchprofile-everything": "𐌰𐌻𐌻",
"searchprofile-advanced-tooltip": "𐍃𐍉𐌺𐌴𐌹 𐌹𐌽 𐌱𐌹𐌿𐌷𐍄𐌰𐌹𐌼 𐌽𐌰𐌼𐌰𐍂𐌿𐌼𐌰𐌼",
"search-result-size": "$1 ({{PLURAL:$2|•𐌰• 𐍅𐌰𐌿𐍂𐌳|•$2• 𐍅𐌰𐌿𐍂𐌳𐌰}})",
"search-result-category-size": "{{PLURAL:$1|1 𐌲𐌰𐌳𐌰𐌹𐌻𐌰|$1 𐌲𐌰𐌳𐌰𐌹𐌻𐌰𐌽𐍃}} ({{PLURAL:$2|1 𐌼𐌹𐌽𐌽𐌹𐌶𐍉 𐌺𐌿𐌽𐌹|$2 𐌼𐌹𐌽𐌽𐌹𐌶𐍉𐌽𐌰 𐌺𐌿𐌽𐌾𐌰}}, {{PLURAL:$3|1 𐍆𐌰𐌾𐌻|$3 𐍆𐌰𐌾𐌻𐌰}})",
- "search-redirect": "(ð\90\8c°ð\90\8d\86ð\90\8d\84ð\90\8d\82ð\90\8c°ð\90\8d\85ð\90\8c´ð\90\8c¹ð\90\8d\84ð\90\8d\83 ð\90\8d\86ð\90\8d\82ð\90\8c°ð\90\8c¼ ð\90\8c¸ð\90\8c°ð\90\8c¼ð\90\8c¼ð\90\8c° $1)",
+ "search-redirect": "(ð\90\8c°ð\90\8d\86ð\90\8d\84ð\90\8d\82ð\90\8c°ð\90\8d\84ð\90\8c°ð\90\8c¿ð\90\8c·ð\90\8d\84ð\90\8d\83 ð\90\8d\86ð\90\8d\82ð\90\8c°ð\90\8c¼ $1)",
"search-section": "(𐍆𐌴𐍂𐌰 $1)",
"search-suggest": "𐌲𐌰𐌼𐌰𐌽𐍄: $1",
"searchall": "𐌰𐌻𐌻𐍃",
- "search-showingresults": "{{ZPLURAL:$4|𐍄𐌰𐌿𐌹 <strong>$1 𐍅𐌰𐌹𐌷𐍄𐌰𐌹𐍃 <strong>$3|𐍄𐍉𐌾𐌰 <strong>$1 - $2 𐍅𐌰𐌹𐌷𐍄𐌰𐌹𐍃 <strong>$3}}",
+ "search-showingresults": "{{PLURAL:$4|𐍄𐌰𐌿𐌹 <strong>$1 𐍅𐌰𐌹𐌷𐍄𐌰𐌹𐍃 <strong>$3|𐍄𐍉𐌾𐌰 <strong>$1 - $2 𐍅𐌰𐌹𐌷𐍄𐌰𐌹𐍃 <strong>$3}}",
"search-nonefound": "𐌽𐌹 𐍄𐌰𐌿𐌹 𐍅𐌰𐍃 𐍃𐌰𐌼𐌰𐌽𐌰 𐍃𐍅𐌰 𐍃𐍉𐌺𐌴𐌹𐌽.",
"powersearch-legend": "𐍃𐍉𐌺𐌴𐌹",
"preferences": "𐌼𐌴𐌹𐌽𐍉𐍃 𐍆𐍂𐌹𐌾𐍉𐌽𐍉𐍃 𐍅𐌰𐌹𐌷𐍄𐍃",
"recentchanges-label-unpatrolled": "𐍃𐍉 𐌹𐌽𐌼𐌰𐌹𐌳𐌴𐌹𐌽𐍃 𐌽𐌰𐌿𐌷 𐌽𐌹𐍃𐍄 𐍅𐌰𐍂𐌳𐌹𐌳𐌰",
"recentchanges-label-plusminus": "𐌻𐌰𐌿𐌱𐌰𐌼𐌹𐌺𐌹𐌻𐌴𐌹 𐌹𐌽𐌼𐌰𐌹𐌳𐌹𐌳𐌰 𐌼𐌹𐌸 𐌸𐌹𐌶𐌰𐌹 𐍂𐌰𐌸𐌾𐍉𐌽 𐌱𐌹𐍄𐍉",
"recentchanges-legend-newpage": "{{int:recentchanges-label-newpage}} (𐍃𐌰𐌹𐍈 𐌾𐌰𐌷[[Special:NewPages|𐍅𐌹𐌺𐍉 𐌽𐌹𐌿𐌾𐌰𐌹𐌶𐌴 𐌻𐌰𐌿𐌱𐌴]])",
- "rcfilters-limit-title": "ð\90\8c¹ð\90\8c½ð\90\8c¼ð\90\8c°ð\90\8c¹ð\90\8c³ð\90\8c´ð\90\8c¹ð\90\8c½ð\90\8d\89ð\90\8d\83 𐌳𐌿 𐌱𐌰𐌽𐌳𐍅𐌾𐌰𐌽",
+ "rcfilters-limit-title": "ð\90\8d\84ð\90\8d\89ð\90\8c¾ð\90\8c° 𐌳𐌿 𐌱𐌰𐌽𐌳𐍅𐌾𐌰𐌽",
"rcfilters-show-new-changes": "𐌱𐌰𐌽𐌳𐍅𐌴𐌹 𐌽𐌹𐌿𐌾𐌰𐍃𐍄𐍉𐍃 𐌹𐌽𐌼𐌰𐌹𐌳𐌴𐌹𐌽𐌹𐌽𐍃",
"rcfilters-filter-editsbyself-label": "𐌹𐌽𐌼𐌰𐌹𐌳𐌴𐌹𐌽𐍉𐍃 𐍆𐍂𐌰𐌼 𐌸𐌿𐍃",
"rcfilters-filter-editsbyother-label": "𐌹𐌽𐌼𐌰𐌹𐌳𐌴𐌹𐌽𐍉𐍃 𐍆𐍂𐌰𐌼 𐌰𐌽𐌸𐌰𐍂𐌰𐌹𐌼",
"uploadbtn": "𐌰𐍄𐌱𐌰𐌹𐍂 𐍆𐌰𐌾𐌻",
"uploadlogpage": "𐌰𐍄𐌱𐌰𐌹𐍂 𐌻𐌰𐌿𐌲",
"filedesc": "𐌼𐌰𐌿𐍂𐌲𐌿𐍃 𐍃𐌺𐌴𐌹𐍂𐌴𐌹𐌽𐍃",
- "watchthisupload": "Witan so seido",
+ "watchthisupload": "𐌰𐍄𐍅𐌹𐍄 𐌸𐌰𐌼𐌼𐌰 𐍆𐌰𐌾𐌻𐌰",
"imgfile": "𐍆𐌰𐌾𐌻",
"listfiles": "𐍆𐌰𐌾𐌻𐌰𐍅𐌹𐌺𐍉",
"file-anchor-link": "𐍆𐌰𐌾𐌻",
"uctop": "(𐌷𐌰𐌿𐌱𐌹𐌸)",
"month": "𐍆𐍂𐌰𐌼 𐌼𐌴𐌽𐍉𐌸 (𐌾𐌰𐌷 𐌰𐌹𐍂𐌹𐍃):",
"year": "𐍆𐍂𐌰𐌼 𐌾𐌴𐍂𐌰 (𐌾𐌰𐌷 𐌰𐌹𐍂𐌹𐍃):",
- "sp-contributions-newbies-sub": "ð\90\8c½ð\90\8c¹ð\90\8c¿ð\90\8c¾ð\90\8c°ð\90\8c¹ð\90\8c¼ ð\90\8cºð\90\8c°ð\90\8d\85ð\90\8d\84ð\90\8d\83ð\90\8c¾ð\90\8d\89𐌼",
+ "sp-contributions-newbies-sub": "ð\90\8c½ð\90\8c¹ð\90\8c¿ð\90\8c¾ð\90\8c°ð\90\8c¹ð\90\8c¼ ð\90\8d\82ð\90\8c°ð\90\8c·ð\90\8c½ð\90\8c´ð\90\8c¹ð\90\8c½ð\90\8c¹𐌼",
"sp-contributions-blocklog": "𐍆𐌰𐌿𐍂𐌳𐌰𐌼𐌼𐌴𐌹𐌽𐌰𐌹𐍃 𐌲𐌰𐍆𐌰𐍃𐍄𐌰𐌹𐌽𐍃.",
"sp-contributions-uploads": "𐌰𐍄𐌱𐌰𐌹𐍂𐌹𐌳𐍉𐍃 𐍅𐌰𐌹𐌷𐍄𐍃",
"sp-contributions-logs": "𐌻𐌰𐌿𐌲𐌰",
"tooltip-pt-mycontris": "A list of {{GENDER:|your}} 𐌱𐌹𐌰𐌿𐌺𐌰𐌹𐌽𐌴𐌹𐍃 𐌱𐍂𐌿𐌺𐌾𐌰𐌽𐌳𐌹𐍃",
"tooltip-pt-login": "𐍄𐌹𐌼𐍂𐌾𐌰𐌶𐌰 𐌳𐌿 𐌰𐍄𐌲𐌰𐌲𐌲𐌰𐌽, 𐌹𐌸 𐌽𐌹𐍃𐍄 𐍃𐌺𐌿𐌻𐌳 𐌸𐌿𐍃",
"tooltip-pt-logout": "𐌰𐍆𐌻𐌴𐌹𐌸",
- "tooltip-pt-createaccount": "ð\90\8c±ð\90\8c°ð\90\8d\84ð\90\8c¹ð\90\8c¶ð\90\8d\89 ð\90\8c¹ð\90\8d\83ð\90\8d\84 ð\90\8c¸ð\90\8c¿ð\90\8d\83 ð\90\8d\83ð\90\8cºð\90\8c°ð\90\8d\80ð\90\8c¾ð\90\8c°ð\90\8c½ ð\90\8cºð\90\8c°ð\90\8d\85ð\90\8d\84ð\90\8d\83ð\90\8c¾ð\90\8d\89𐌽, 𐌹𐌸 𐍃𐌺𐌿𐌻𐌳 𐌽𐌹𐍃𐍄",
+ "tooltip-pt-createaccount": "ð\90\8c±ð\90\8c°ð\90\8d\84ð\90\8c¹ð\90\8c¶ð\90\8d\89 ð\90\8c¹ð\90\8d\83ð\90\8d\84 ð\90\8c¸ð\90\8c¿ð\90\8d\83 ð\90\8d\83ð\90\8cºð\90\8c°ð\90\8d\80ð\90\8c¾ð\90\8c°ð\90\8c½ ð\90\8d\82ð\90\8c°ð\90\8c·ð\90\8c½ð\90\8c´ð\90\8c¹𐌽, 𐌹𐌸 𐍃𐌺𐌿𐌻𐌳 𐌽𐌹𐍃𐍄",
"tooltip-ca-talk": "𐌲𐌰𐍅𐌰𐌿𐍂𐌳𐌹 𐌱𐌹 𐌷𐌰𐌱𐌰𐌽𐌳𐌰𐌽 𐌻𐌰𐌿𐍆",
"tooltip-ca-edit": "𐌹𐌽𐌼𐌰𐌹𐌳𐌴𐌹 𐌸𐌰𐌽𐌰 𐌻𐌰𐌿𐍆",
"tooltip-ca-addsection": "𐌰𐌽𐌰𐍃𐍄𐍉𐌳𐌴𐌹 𐌽𐌹𐌿𐌾𐌰 𐌳𐌰𐌹𐌻",
"tags-deactivate-submit": "𐌿𐌽𐌲𐌰𐌵𐌹𐌿𐌴𐌹",
"logentry-delete-delete": "$1 {{GENDER:$2|𐌿𐍃𐌽𐌰𐌼}} 𐌻𐌰𐌿𐍆 $3",
"logentry-move-move": "$1 {{GENDER:$2|𐌼𐌹𐌸𐍃𐌰𐍄𐌹𐌳𐌰}} 𐌻𐌰𐌿𐍆 $3 𐌳𐌿 $4",
- "logentry-newusers-create": "ð\90\8c±ð\90\8d\82ð\90\8c¿ð\90\8cºð\90\8c¾ð\90\8c°ð\90\8cºð\90\8c°ð\90\8d\85ð\90\8d\84ð\90\8d\83ð\90\8c¾ð\90\8d\89 $1 𐍅𐌰𐍃 𐌲𐌰𐍃𐌺𐌰𐍀𐌰𐌽𐌰",
+ "logentry-newusers-create": "ð\90\8c±ð\90\8d\82ð\90\8c¿ð\90\8cºð\90\8c¾ð\90\8c°ð\90\8d\82ð\90\8c°ð\90\8c·ð\90\8c½ð\90\8c´ð\90\8c¹ð\90\8c½ð\90\8d\83 $1 𐍅𐌰𐍃 𐌲𐌰𐍃𐌺𐌰𐍀𐌰𐌽𐌰",
"logentry-upload-upload": "$1 {{GENDER:$2|𐌿𐍆𐌰𐍂𐌱𐌰𐍂}} $3",
"rightsnone": "(𐌽𐌹)",
"searchsuggest-search": "𐍃𐍉𐌺𐌴𐌹",
"resetpass-submit-loggedin": "שינוי הסיסמה",
"resetpass-submit-cancel": "ביטול",
"resetpass-wrong-oldpass": "הסיסמה הזמנית או הנוכחית אינה תקינה.\nייתכן שכבר שינית את סיסמתך או שכבר ביקשת סיסמה זמנית חדשה.",
- "resetpass-recycled": "×\99ש ×\9c×\90פס ×\90ת ×\94ס×\99ס×\9e×\94 ×\9cס×\99ס×\9e×\94 ×\94ש×\95× ×\94 ×\9eס×\99×\9eסתך הנוכחית.",
+ "resetpass-recycled": "×\99ש ×\9c×©× ×\95ת ×\90ת ×\94ס×\99ס×\9e×\94 ×\9cס×\99ס×\9e×\94 ×\94ש×\95× ×\94 ×\9eס×\99ס×\9eתך הנוכחית.",
"resetpass-temp-emailed": "נכנסת באמצעות סיסמה זמנית שנשלחה {{GENDER:|אליך|אלייך}} בדוא\"ל.\nכדי לסיים את הכניסה, יש להגדיר כאן סיסמה חדשה:",
"resetpass-temp-password": "סיסמה זמנית:",
"resetpass-abort-generic": "שינוי הסיסמה בוטל על־ידי הרחבה.",
"resetpass-expired": "סיסמתך פקעה. נא להגדיר סיסמה חדשה כדי להיכנס.",
- "resetpass-expired-soft": "×\94ס×\99ס×\9e×\94 ש×\9c×\9a פקע×\94, ×\95צר×\99×\9a ×\9c×\90פס ×\90×\95ת×\94. ×\99ש ×\9c×\91×\97×\95ר ס×\99ס×\9e×\94 ×\97×\93ש×\94 ×\9bעת, ×\90×\95 ×\9c×\9c×\97×\95×¥ ×¢×\9c \"{{int:authprovider-resetpass-skip-label}}\" ×\9b×\93×\99 ×\9c×\90פס אותה מאוחר יותר.",
- "resetpass-validity-soft": "×\94ס×\99ס×\9e×\94 ש×\9c×\9a ×\90×\99× ×\94 תק×\99× ×\94: $1\n\n×\99ש ×\9c×\91×\97×\95ר ס×\99ס×\9e×\94 ×\97×\93ש×\94 ×\9bעת ×\90×\95 ×\9c×\9c×\97×\95×¥ ×¢×\9c \"{{int:authprovider-resetpass-skip-label}}\" ×\9b×\93×\99 ×\9c×\90פס את הסיסמה מאוחר יותר.",
+ "resetpass-expired-soft": "×\94ס×\99ס×\9e×\94 ש×\9c×\9a פקע×\94, ×\95צר×\99×\9a ×\9c×©× ×\95ת ×\90×\95ת×\94. ×\99ש ×\9c×\91×\97×\95ר ס×\99ס×\9e×\94 ×\97×\93ש×\94 ×\9bעת, ×\90×\95 ×\9c×\9c×\97×\95×¥ ×¢×\9c \"{{int:authprovider-resetpass-skip-label}}\" ×\9b×\93×\99 ×\9c×©× ×\95ת אותה מאוחר יותר.",
+ "resetpass-validity-soft": "×\94ס×\99ס×\9e×\94 ש×\9c×\9a ×\90×\99× ×\94 תק×\99× ×\94: $1\n\n×\99ש ×\9c×\91×\97×\95ר ס×\99ס×\9e×\94 ×\97×\93ש×\94 ×\9bעת ×\90×\95 ×\9c×\9c×\97×\95×¥ ×¢×\9c \"{{int:authprovider-resetpass-skip-label}}\" ×\9b×\93×\99 ×\9c×©× ×\95ת את הסיסמה מאוחר יותר.",
"passwordreset": "איפוס סיסמה",
"passwordreset-text-one": "יש למלא טופס זה כדי לקבל סיסמה זמנית בדוא\"ל.",
"passwordreset-text-many": "{{PLURAL:$1||יש למלא אחד מהשדות הבאים כדי לקבל סיסמה זמנית בדוא\"ל.}}",
"rcfilters-savedqueries-rename": "שינוי שם",
"rcfilters-savedqueries-setdefault": "הגדרה כברירת מחדל",
"rcfilters-savedqueries-unsetdefault": "ביטול הגדרה כברירת מחדל",
- "rcfilters-savedqueries-remove": "×\94סרה",
+ "rcfilters-savedqueries-remove": "×\9e×\97×\99קה",
"rcfilters-savedqueries-new-name-label": "שם",
"rcfilters-savedqueries-new-name-placeholder": "תיאור מטרת המסנן",
"rcfilters-savedqueries-apply-label": "יצירת המסנן",
"rcfilters-empty-filter": "אין מסננים פעילים. כל התרומות מוצגות.",
"rcfilters-filterlist-title": "מסננים",
"rcfilters-filterlist-whatsthis": "איך הם עובדים?",
- "rcfilters-filterlist-feedbacklink": "באפשרותך לספר לנו מה דעתך על כלי הסינון (החדשים) האלה",
+ "rcfilters-filterlist-feedbacklink": "באפשרותך לספר לנו מה דעתך על כלי הסינון האלה",
"rcfilters-highlightbutton-title": "הבלטת התוצאות",
"rcfilters-highlightmenu-title": "בחירת צבע",
"rcfilters-highlightmenu-help": "בחירת צבע להבלטת מאפיין זה",
"tooltip-pt-preferences": "ההעדפות שלך",
"tooltip-pt-watchlist": "רשימת הדפים {{GENDER:|שאתה עוקב|שאת עוקבת}} אחרי השינויים בהם",
"tooltip-pt-mycontris": "רשימת העריכות שביצעת",
- "tooltip-pt-anoncontribs": "רשימת העריכות שנעשו מכתובת ה־IP הזאת",
+ "tooltip-pt-anoncontribs": "רשימת העריכות שנעשו מכתובת IP זו",
"tooltip-pt-login": "מומלץ להיכנס לחשבון, אך אין חובה לעשות זאת",
"tooltip-pt-login-private": "יש להיכנס לחשבון כדי להשתמש באתר הוויקי הזה",
"tooltip-pt-logout": "יציאה מהחשבון",
"tooltip-summary": "תיאור קצר של השינויים שביצעת",
"common.css": "/* הסגנונות הנכתבים כאן ישפיעו על כל העיצובים */",
"print.css": "/* הסגנונות הנכתבים כאן ישפיעו על הפלט בהדפסה בלבד */",
- "noscript.css": "/* הסגנונות הנכתבים כאן ישפיעו על משתמשים עם ג'אווה-סקריפט מבוטל */",
+ "noscript.css": "/* הסגנונות הנכתבים כאן ישפיעו על משתמשים עם ג'אווה־סקריפט מבוטל */",
"group-autoconfirmed.css": "/* הסגנונות הנכתבים כאן ישפיעו על משתמשים ותיקים בלבד */",
"group-user.css": "/* הסגנונות הנכתבים כאן ישפיעו על משתמשים רשומים בלבד */",
"group-bot.css": "/* הסגנונות הנכתבים כאן ישפיעו על בוטים בלבד */",
"group-sysop.css": "/* הסגנונות הנכתבים כאן ישפיעו על מפעילי מערכת בלבד */",
"group-bureaucrat.css": "/* הסגנונות הנכתבים כאן ישפיעו על ביורוקרטים בלבד */",
"common.json": "/* כל ה־JSON שנכתב כאן ייטען עבור כל המשתמשים בכל טעינת עמוד. */",
- "common.js": "/* ×\9b×\9c ×\94סקר×\99פ×\98×\99×\9d שנכתבים כאן ייטענו עבור כל המשתמשים בכל טעינת עמוד. */",
+ "common.js": "/* ×\9b×\9c ×\94סקר×\99פ×\98×\99×\9d ×\94נכתבים כאן ייטענו עבור כל המשתמשים בכל טעינת עמוד. */",
"group-autoconfirmed.js": "/* כל הסקריפטים הנכתבים כאן ייטענו עבור משתמשים ותיקים בלבד */",
"group-user.js": "/* כל הסקריפטים הנכתבים כאן ייטענו עבור משתמשים רשומים בלבד */",
"group-bot.js": "/* כל הסקריפטים הנכתבים כאן ייטענו עבור בוטים בלבד */",
"spam_reverting": "שחזור לגרסה אחרונה שלא כוללת קישורים ל־$1",
"spam_blanking": "כל הגרסאות כוללות קישורים ל־$1, מרוקן את הדף",
"spam_deleting": "כל הגרסאות כוללות קישורים ל־$1, מוחק את הדף",
- "simpleantispam-label": "×\91×\93×\99קת ×\90× ×\98×\99־ספ×\90×\9d.\n<strong>×\90×\9c</strong> ת×\9e×\9c×\90×\95 שדה זה!",
+ "simpleantispam-label": "×\91×\93×\99קת ×\90× ×\98×\99־ספ×\90×\9d.\n<strong>×\90×\99×\9f</strong> ×\9c×\9e×\9c×\90 שדה זה!",
"pageinfo-title": "מידע על הדף \"$1\"",
"pageinfo-not-current": "מצטערים, לא ניתן להציג את המידע הזה לגרסאות ישנות.",
"pageinfo-header-basic": "מידע בסיסי",
"pageinfo-magic-words": "{{PLURAL:$1|מילת קסם|מילות קסם}} ($1)",
"pageinfo-hidden-categories": "{{PLURAL:$1|קטגוריה מוסתרת|קטגוריות מוסתרות}} ($1)",
"pageinfo-templates": "{{PLURAL:$1|תבנית מוכללת|תבניות מוכללות}} ($1)",
- "pageinfo-transclusions": "{{PLURAL:$1|דף|דפים}} שמוכלל בהם ($1)",
+ "pageinfo-transclusions": "{{PLURAL:$1|דף|דפים}} שמוכלל {{PLURAL:$1|בו|בהם}} ($1)",
"pageinfo-toolboxlink": "מידע על הדף",
"pageinfo-redirectsto": "מפנה אל",
"pageinfo-redirectsto-info": "מידע",
"rcfilters-savedqueries-rename": "Átnevezés",
"rcfilters-savedqueries-setdefault": "Beállítás alapértelmezettként",
"rcfilters-savedqueries-unsetdefault": "Eltávolítás, mint alapértelmezés",
- "rcfilters-savedqueries-remove": "Eltávolítás",
+ "rcfilters-savedqueries-remove": "Törlés",
"rcfilters-savedqueries-new-name-label": "Név",
"rcfilters-savedqueries-new-name-placeholder": "Írd le a szűrő célját.",
"rcfilters-savedqueries-apply-label": "Gyors hivatkozás létrehozása",
"rcfilters-empty-filter": "Nincs aktív szűrő. Minden közreműködés látható.",
"rcfilters-filterlist-title": "Szűrők",
"rcfilters-filterlist-whatsthis": "Hogy működnek?",
- "rcfilters-filterlist-feedbacklink": "Mond el nekünk, mit gondolsz az (új) szűrőkről",
+ "rcfilters-filterlist-feedbacklink": "Mond el nekünk, mit gondolsz ezekről a szűrőkről",
"rcfilters-highlightbutton-title": "Kiemelt találatok",
"rcfilters-highlightmenu-title": "Válassz egy színt",
"rcfilters-highlightmenu-help": "Válassz színt ennek a tulajdonságnak kijelöléséhez",
"preview": "Хьалххе бIаргтохар",
"showpreview": "Хьалххе бIаргтохар",
"showdiff": "Даь дола хувцамаш",
- "anoneditwarning": "<strong>Теркам бе!</strong> Хьо автор хинна система чуваьннавац. Нагахьа санна Iа моллагIа хувцам бой, Хьа IP-адрес дийла массанен бIаргагуш хургда. Нагахьа санна Хьо <strong>[$1 хьачувоале]</strong> е <strong>[$2 дагара йоазув хьакхолле]</strong>, нийсдараш (хувцамаш) бувзам болаш хургда Хьа доакъашхой цIерца, иштта кхыдола толажагIи гIойленагIи дола дикаьш хургда Хьона.",
+ "anoneditwarning": "<strong>Теркам бе!</strong> Хьо ражача дIаязвеннавац. Нагахьа санна Iа моллагIа хувцам бой, Хьа IP-цIай хьадийла массанена бIаргагуш хургда. Нагахьа санна Хьо <strong>[$1 ражача хьачувоале]</strong> е Iа <strong>[$2 дагара йоазув хьакхолле]</strong>, хьа тоадар (хувцамаш) бувзам болаш хургда Хьа цIерца, иштта кхыдола толашагIеи гIойленагIеи дола дикаьш хургда Хьона.",
"summary-preview": "Хувцама сурт оттадарах хьалхе бӀаргтохар:",
"subject-preview": "Темах/кепакертах хьалххе бӀаргтохар:",
"blockedtitle": "Доакъашхочун чIега техаб",
"monthsall": "omna",
"confirmemail": "Konfirmez adreso di e-posto",
"confirmemail_needlogin": "Vu mustas $1 pro konfirmar vua adreso di e-posto.",
+ "confirmemail_loggedin": "Vua adreso di e-posto konfirmesis nun.",
"notificationemail_subject_changed": "L'adreso di e-posto en {{SITENAME}} modifikesis",
"scarytranscludetoolong": "[URL es tro longa]",
"deletedwhileediting": "'''Averto''': Ta pagino efacesis pos ke vu redakteskis!",
"Manvydasz",
"S4b1nuz E.656",
"Daimona Eaytoy",
- "Sarah Bernabei"
+ "Sarah Bernabei",
+ "Wedhro"
]
},
"tog-underline": "Sottolinea i collegamenti:",
"default": "predefinito",
"prefs-files": "File",
"prefs-custom-css": "CSS personalizzato",
+ "prefs-custom-json": "JSON personalizzato",
"prefs-custom-js": "JavaScript personalizzato",
"prefs-common-config": "CSS/JSON/JavaScript condiviso per tutti i temi:",
"prefs-reset-intro": "È possibile usare questa pagina per reimpostare le proprie preferenze a quelle predefinite del sito.\nL'operazione non può essere annullata.",
"right-editcontentmodel": "Modifica il modello di contenuto di una pagina",
"right-editinterface": "Modifica l'interfaccia utente",
"right-editusercss": "Modifica i file CSS di altri utenti",
+ "right-edituserjson": "Modifica i file JSON di altri utenti",
"right-edituserjs": "Modifica i file JS di altri utenti",
"right-editmyusercss": "Modifica il file CSS del proprio utente",
"right-editmyuserjson": "Modifica il file JSON del proprio utente",
"rcfilters-savedqueries-rename": "Rinomina",
"rcfilters-savedqueries-setdefault": "Imposta come predefinito",
"rcfilters-savedqueries-unsetdefault": "Rimuovi come predefinito",
- "rcfilters-savedqueries-remove": "Rimuovi",
+ "rcfilters-savedqueries-remove": "Cancella",
"rcfilters-savedqueries-new-name-label": "Nome",
"rcfilters-savedqueries-new-name-placeholder": "Descrivi lo scopo del filtro",
"rcfilters-savedqueries-apply-label": "Crea filtro",
"rcfilters-empty-filter": "Nessun filtro attivo. Sono mostrati tutti i contributi.",
"rcfilters-filterlist-title": "Filtri",
"rcfilters-filterlist-whatsthis": "Come funzionano?",
- "rcfilters-filterlist-feedbacklink": "Dicci cosa ne pensi su questi (nuovi) strumenti di filtraggio",
+ "rcfilters-filterlist-feedbacklink": "Dicci cosa ne pensi su questi strumenti di filtraggio",
"rcfilters-highlightbutton-title": "Evidenzia risultati",
"rcfilters-highlightmenu-title": "Seleziona un colore",
"rcfilters-highlightmenu-help": "Seleziona un colore per evidenziare questa proprietà",
"uploadstash-bad-path": "Il percorso non esiste.",
"uploadstash-bad-path-invalid": "Il percorso non è valido.",
"uploadstash-bad-path-unknown-type": "Tipo sconosciuto \"$1\".",
+ "uploadstash-file-not-found-no-thumb": "Non è stato possibile ottenere la miniatura.",
+ "uploadstash-file-not-found-no-remote-thumb": "Recupero della miniatura fallito: $1\nURL = $2",
"uploadstash-zero-length": "Il file ha lunghezza zero.",
"invalid-chunk-offset": "Offset della parte non valido.",
"img-auth-accessdenied": "Accesso negato",
"uncategorizedcategories": "Categorie prive di categorie",
"uncategorizedimages": "File privi di categorie",
"uncategorizedtemplates": "Template privi di categorie",
+ "uncategorized-categories-exceptionlist": "# Contiene un elenco di categorie che non dovrebbero figurare in Special:UncategorizedCategories. Una categoria per linea, a cominciare da \"*\". Le linee che iniziano per un altro carattere (spazi bianchi inclusi) saranno ignorate. Usa «#» per aggiungere commenti.",
"unusedcategories": "Categorie vuote",
"unusedimages": "File non utilizzati",
"wantedcategories": "Categorie richieste",
"ipb_blocked_as_range": "Errore: L'indirizzo IP $1 non è soggetto a blocco individuale e non può essere sbloccato. Il blocco è invece attivo a livello dell'intervallo $2, che può essere sbloccato.",
"ip_range_invalid": "Intervallo di indirizzi IP non valido.",
"ip_range_toolarge": "Non è possibile bloccare range superiori al /$1",
+ "ip_range_exceeded": "L'intervallo di IP supera il limite massimo. Intervallo contentito: /$1.",
+ "ip_range_toolow": "Gli intervalli di IP non sono consentiti, di fatto.",
"proxyblocker": "Blocco dei proxy aperti",
"proxyblockreason": "Questo indirizzo IP è stato bloccato perché risulta essere un proxy aperto. Si prega di contattare il proprio fornitore di accesso a Internet o il supporto tecnico e informarli di questo grave problema di sicurezza.",
"sorbsreason": "Questo indirizzo IP è elencato come proxy aperto nella lista DNSBL utilizzata da {{SITENAME}}.",
"unlinkaccounts-success": "L'utenza è stata scollegata.",
"authenticationdatachange-ignored": "Il cambiamento dei dati di autenticazione non è stato gestito. Forse non è stato configurato nessun provider?",
"userjsispublic": "Ricorda: le sottopagine JavaScript non devono contenere dati riservati poichè sono visualizzabili da altri utenti.",
+ "userjsonispublic": "Ricorda: le sottopagine JSON non dovrebbero contenere dati personali perché sono visibili agli altri utenti.",
"usercssispublic": "Ricorda: le sottopagine CSS non devono contenere dati riservati poichè sono visualizzabili da altri utenti.",
"restrictionsfield-badip": "Indirizzo IP o intervallo non valido: $1",
"restrictionsfield-label": "Intervalli IP consentiti:",
"resetpass-submit-loggedin": "비밀번호 바꾸기",
"resetpass-submit-cancel": "취소",
"resetpass-wrong-oldpass": "비밀번호가 잘못되었거나 현재의 비밀번호와 같습니다.\n이미 비밀번호를 바꾸었거나 새 임시 비밀번호를 요청했을 수 있습니다.",
- "resetpass-recycled": "현재 비밀번호와 다른 비밀번호로 재설정해주세요.",
+ "resetpass-recycled": "현재 비밀번호와 다른 비밀번호로 변경해주세요.",
"resetpass-temp-emailed": "임시 이메일 코드로 로그인되어 있습니다.\n로그인을 마치려면, 여기서 새 비밀번호를 설정해야 합니다:",
"resetpass-temp-password": "임시 비밀번호:",
"resetpass-abort-generic": "비밀번호 바꾸기가 확장 기능에 의해 중단되었습니다.",
"resetpass-expired": "비밀번호가 만료되었습니다. 로그인하려면 새 비밀번호를 설정해야 합니다.",
- "resetpass-expired-soft": "비밀번호가 만료되어 재설정해야 합니다. 지금 새로운 비밀번호를 선택하거나, \"{{int:authprovider-resetpass-skip-label}}\"를 클릭하고 나중에 재설정해주세요.",
- "resetpass-validity-soft": "귀하의 비밀번호는 유효하지 않습니다: $1\n\n새로운 비밀번호를 지금 설정하거나, \"{{int:authprovider-resetpass-skip-label}}\"을 눌러 나중에 재설정하세요.",
+ "resetpass-expired-soft": "비밀번호가 만료되어 변경해야 합니다. 지금 새로운 비밀번호를 선택하거나, \"{{int:authprovider-resetpass-skip-label}}\"를 클릭하고 나중에 재설정해주세요.",
+ "resetpass-validity-soft": "귀하의 비밀번호는 유효하지 않습니다: $1\n\n새로운 비밀번호를 지금 설정하거나, \"{{int:authprovider-resetpass-skip-label}}\"을 눌러 나중에 변경하세요.",
"passwordreset": "비밀번호 재설정",
"passwordreset-text-one": "이메일을 통해 임시 비밀번호를 받으려면 이 양식을 채우세요.",
"passwordreset-text-many": "{{PLURAL:$1|이메일을 통해 임시 비밀번호를 받으려면 필드 중 하나를 채우세요.}}",
"rcfilters-savedqueries-rename": "이름 바꾸기",
"rcfilters-savedqueries-setdefault": "기본값으로 설정",
"rcfilters-savedqueries-unsetdefault": "기본값으로 제거",
- "rcfilters-savedqueries-remove": "ì \9cê±°",
+ "rcfilters-savedqueries-remove": "ì\82ì \9c",
"rcfilters-savedqueries-new-name-label": "이름",
"rcfilters-savedqueries-new-name-placeholder": "필터의 목적을 설명하세요",
"rcfilters-savedqueries-apply-label": "필터 만들기",
"rcfilters-empty-filter": "활성화된 필터가 없습니다. 모든 기여가 표시됩니다.",
"rcfilters-filterlist-title": "필터",
"rcfilters-filterlist-whatsthis": "이것들이 어떻게 동작합니까?",
- "rcfilters-filterlist-feedbacklink": "이 (새로운) 필터 도구에 대한 의견을 주세요",
+ "rcfilters-filterlist-feedbacklink": "이 필터 도구에 대한 의견을 주세요",
"rcfilters-highlightbutton-title": "결과 강조",
"rcfilters-highlightmenu-title": "색 선택",
"rcfilters-highlightmenu-help": "이 속성을 강조할 색을 선택하십시오",
"Matma Rex",
"Stryn",
"Cûndûllah el-Kurdî",
- "Fitoschido"
+ "Fitoschido",
+ "Kur"
]
},
"tog-underline": "Xetekê di bin girêdanê de çêke:",
"resetpass_forbidden": "Şîfre nikarin werin guhertin",
"resetpass-submit-loggedin": "Şîfreyê biguherîne",
"resetpass-submit-cancel": "Betal bike",
+ "resetpass-recycled": "Ji kerema xwe şîfreya xwe ya mewcûd biguherîne şîfreyeke din.",
"resetpass-temp-password": "Şîfreya demkî:",
"resetpass-expired": "Wextê şîfreya te derbas bû. Ji kerema xwe re\nji bo têketinê şîfreyek nû çêke.",
- "resetpass-expired-soft": "Wextê şîfreya te derbas bû û hewceye ko be guhertin. Ji kerema xwe re şîfreyek nû çêke an jî ji bo paşî çêkî li \"{{int:authprovider-resetpass-skip-label}}\" bitikîne.",
- "resetpass-validity-soft": "Şîfreya te derbasdar nîne: $1\n\nJi kerema xwe re şîfreyek nû çêke an jî ji bo paşî çêkî li \"{{int:authprovider-resetpass-skip-label}}\" bitikîne.",
+ "resetpass-expired-soft": "Mideta şîfreya te xelas bûye û divê bê guhertin. Ji kerema xwe re şîfreyeke nû çêbike an jî ji bo ku paşiyê biguherîne li \"{{int:authprovider-resetpass-skip-label}}\" bitikîne.",
+ "resetpass-validity-soft": "Şîfreya te ne derbasdar e: $1\n\nJi kerema xwe re şîfreyek nû çêke an jî ji bo paşiyê biguherîne li \"{{int:authprovider-resetpass-skip-label}}\" bitikîne.",
"passwordreset": "Şîfreyê nû bike",
"passwordreset-text-one": "Ji bo ji nû ve sazkirina şîfreyê vê formê dagire.",
"passwordreset-username": "Navê bikarhêner:",
"rcfilters-savedqueries-new-name-label": "Nav",
"rcfilters-savedqueries-cancel-label": "Betal bike",
"rcfilters-filterlist-title": "Parzûn",
+ "rcfilters-filterlist-feedbacklink": "Ji me re biêje ka tu li ser van alavên fîltrekirinê çi difikirî.",
"rcfilters-filter-editsbyself-label": "Guhêrandinên ji aliyê te",
"rcfilters-filter-editsbyother-label": "Guherandinên ji aliyên kesên din",
"rcfilters-filter-bots-label": "Bot",
"whatlinkshere-title": "Rûpelên ku yê berve \"$1\" tên",
"whatlinkshere-page": "Rûpel:",
"linkshere": "Ev rûpel tên ser vê rûpelê '''$2''':",
- "nolinkshere": "Ne ji rûpelekê lînk tên ser '''$2'''.",
+ "nolinkshere": "Girêdan bo <strong>$2</strong> nehat dîtin.",
"nolinkshere-ns": "Ne lînkek berve '''$2''' di vê namespace'a da tê.",
"isredirect": "rûpelê beralî bike",
"istemplate": "tê bikaranîn",
"rcfilters-savedqueries-rename": "Ëmbenennen",
"rcfilters-savedqueries-setdefault": "Als Standard festleeën",
"rcfilters-savedqueries-unsetdefault": "Als Standard ewechhuelen",
- "rcfilters-savedqueries-remove": "Ewechhuelen",
+ "rcfilters-savedqueries-remove": "Läschen",
"rcfilters-savedqueries-new-name-label": "Numm",
"rcfilters-savedqueries-new-name-placeholder": "Den Zweck vum Filter beschreiwen",
"rcfilters-savedqueries-apply-label": "Filter uleeën",
"rcfilters-empty-filter": "Keen aktive Filter. All Kontributioune gi gewisen.",
"rcfilters-filterlist-title": "Filteren",
"rcfilters-filterlist-whatsthis": "Wéi geet dat?",
- "rcfilters-filterlist-feedbacklink": "Sot eis wat Dir vun dësen (neien) Filterméiglechkeeten haalt",
+ "rcfilters-filterlist-feedbacklink": "Sot eis wat Dir vun dësen Filterméiglechkeeten haalt",
"rcfilters-highlightbutton-title": "Resultater ervirhiewen",
"rcfilters-highlightmenu-title": "Eng Faarf eraussichen",
"rcfilters-highlightmenu-help": "Sicht eng Faarf eraus fir dës Eegenschaft ervirzehiewen.",
"subject-preview": "Temos peržiūra:",
"previewerrortext": "Įvyko klaida bandant peržiūrėti jūsų pakeitimus.",
"blockedtitle": "Naudotojas yra užblokuotas",
- "blockedtext": "'''Jūsų naudotojo vardas arba IP adresas yra užblokuotas.'''\n\nUžblokavo $1. Nurodyta priežastis yra ''$2''.\n\n* Blokavimo pradžia: $8\n* Blokavimo pabaiga: $6\n* Užblokuotasis: $7\n\nJūs galite susisiekti su $1 arba kuriuo nors kitu [[{{MediaWiki:Grouppage-sysop}}|administratoriumi]] ir aptarti neaiškumus dėl blokavimo.\nAtkreipkite dėmesį, kad negalėsite naudotis funkcija „Rašyti laišką šiam naudotojui“, jei nesate užsiregistravę ir pateikę galiojančio el. pašto adreso naudotojo paskyros [[Special:Preferences|nustatymuose]], arba, jei jums užblokuotas šios funkcijos naudojimas.\nJūsų IP adresas yra $3, o blokavimo ID yra #$5.\nPrašome nurodyti vieną iš jų ar abu, kai kreipiatės dėl blokavimo.",
+ "blockedtext": "'''Jūsų naudotojo vardas arba IP adresas yra užblokuotas.'''\n\nUžblokavo $1. Nurodyta priežastis yra ''$2''.\n\n* Blokavimo pradžia: $8\n* Blokavimo pabaiga: $6\n* Užblokuotasis: $7\n\nJūs galite susisiekti su $1 arba kuriuo nors kitu [[{{MediaWiki:Grouppage-sysop}}|administratoriumi]] ir aptarti neaiškumus dėl blokavimo.\nAtkreipkite dėmesį, kad negalėsite naudotis funkcija „{{int:emailuser}}“, jei nesate užsiregistravę ir pateikę galiojančio el. pašto adreso naudotojo paskyros [[Special:Preferences|nustatymuose]], arba, jei jums užblokuotas šios funkcijos naudojimas.\nJūsų IP adresas yra $3, o blokavimo ID yra #$5.\nPrašome nurodyti vieną iš jų ar abu, kai kreipiatės dėl blokavimo.",
"autoblockedtext": "Jūsų IP adresas buvo automatiškai užblokuotas, nes jį naudojo kitas naudotojas, kurį užblokavo $1.\nNurodyta priežastis yra ši:\n\n:''$2''\n\n* Blokavimo pradžia: $8\n* Blokavimo pabaiga: $6\n* Numatomas blokavimo laikas: $7\n\nJūs galite susisiekti su $1 arba kitu [[{{MediaWiki:Grouppage-sysop}}|administratoriumi]], kad aptartumėte neaiškumus dėl blokavimo.\n\nJūs negalite naudotis funkcija „Rašyti laišką šiam naudotojui“, jei nesate nurodę tikro el. pašto adreso savo [[Special:Preferences|naudotojo nustatymuose]]. Taip pat Jūs negalite naudotis šia funkcija, jei Jums užblokuotas jos naudojimas.\n\nJūsų IP adresas yra $3, blokavimo ID yra $5.\nPrašome nurodyti šiuos duomenis visais atvejais, kai kreipiatės dėl blokavimo.",
"blockednoreason": "priežastis nenurodyta",
"whitelistedittext": "Jūs turite $1, kad redaguotumėte puslapius.",
"botpasswords-created-title": "Bota parole izveidota",
"botpasswords-updated-title": "Bota parole atjaunināta",
"botpasswords-deleted-title": "Bota parole dzēsta",
+ "botpasswords-restriction-failed": "Botu paroles ierobežojumi liedz šo pieslēgšanos.",
"resetpass_forbidden": "Paroles nav iespējams nomainīt",
"resetpass_forbidden-reason": "Paroles nav iespējams nomainīt: $1",
"resetpass-no-info": "Jums ir nepieciešams ieiet, lai tūlīt piekļūtu šai lapai.",
"rcfilters-filter-minor-description": "Labojumi, kas atzīmēti kā maznozīmīgi.",
"rcfilters-filter-major-label": "Nozīmīgi labojumi",
"rcfilters-filter-major-description": "Labojumi, kas nav atzīmēti kā maznozīmīgi.",
+ "rcfilters-filtergroup-watchlist": "Uzraugāmie raksti",
"rcfilters-filter-watchlist-watchednew-description": "Izmaiņas uzraugāmajās lapās, kuras nav apmeklētas kopš izmaiņu veikšanas.",
"rcfilters-filter-watchlistactivity-unseen-label": "Neapskatītas izmaiņas",
"rcfilters-filter-watchlistactivity-unseen-description": "Izmaiņas lapās, kuras nav apmeklētas kopš izmaiņu veikšanas.",
"rollback-success": "Откажани уредувањата на {{GENDER:$3|$1}};; вратено на последната верзија на {{GENDER:$4|$2}}.",
"rollback-success-notify": "Откажани уредувањата на $1;\nвратено на последната преработка на $2. [$3 Пок. промени]",
"sessionfailure-title": "Седницата не успеа",
- "sessionfailure": "Ð\98ма пÑ\80облем Ñ\81о ваÑ\88аÑ\82а Ñ\81едниÑ\86а;\nоваа деÑ\98Ñ\81Ñ\82во е оÑ\82кажано како пÑ\80евенÑ\82ива пÑ\80оÑ\82ив пÑ\80еземаÑ\9aе Ñ\81едниÑ\86и.\nПоднесете го образецот повторно.",
+ "sessionfailure": "Се Ñ\98ави пÑ\80облем Ñ\81о наÑ\98авнаÑ\82а Ñ\81едниÑ\86а;\nова деÑ\98Ñ\81Ñ\82во е оÑ\82кажано за да Ñ\81е Ñ\81пÑ\80еÑ\87и неÑ\98зина кÑ\80ажба.\nПоднесете го образецот повторно.",
"changecontentmodel": "Промена на содржинскиот модел на страница",
"changecontentmodel-legend": "Промена на содржински модел",
"changecontentmodel-title-label": "Наслов на страницата",
"subject-preview": "Forhåndsvisning av overskrift:",
"previewerrortext": "En feil oppsto mens dine endringer skulle forhåndsvises.",
"blockedtitle": "Brukeren er blokkert",
- "blockedtext": "'''Ditt brukernavn eller din IP-adresse har blitt blokkert.'''\n\nBlokkeringen ble utført av $1. Grunnen som ble oppgitt var ''$2''.\n\n* Blokkeringen begynte: $8\n* Blokkeringen utgår: $6\n* Blokkering ment på: $7\n\nDu kan kontakte $1 eller en annen [[{{MediaWiki:Grouppage-sysop}}|administrator]] for å diskutere blokkeringen.\nDu kan ikke bruke «E-post til denne brukeren»-funksjonen med mindre du har oppgitt en gyldig e-postadresse i [[Special:Preferences|innstillingene dine]] og du ikke er blokkert fra å sende e-post.\nDin nåværende IP-adresse er $3, og blokkerings-ID-en er #$5.\nVennligst ta all denne informasjonen ved henvendelser.",
+ "blockedtext": "<strong>Ditt brukernavn eller din IP-adresse har blitt blokkert.</strong>\n\nBlokkeringen ble utført av $1. Grunnen som ble oppgitt var <em>$2</em>.\n\n* Blokkeringen begynte: $8\n* Blokkeringen opphører: $6\n* Blokkeringen ment for: $7\n\nDu kan kontakte $1 eller en annen [[{{MediaWiki:Grouppage-sysop}}|administrator]] for å diskutere blokkeringen.\nDu kan ikke bruke \"{{int:emailuser}}\"-funksjonen med mindre du har oppgitt en gyldig e-postadresse i [[Special:Preferences|innstillingene dine]] og du ikke har blitt blokkert fra å sende e-post.\nDin nåværende IP-adresse er $3, og blokkerings-ID-en er #$5.\nVennligst ta med all denne informasjonen ved henvendelser.",
"autoblockedtext": "Din IP-adresse har blitt automatisk blokkert fordi den ble brukt av en annen bruker som ble blokkert av $1.\nDen oppgitte grunnen var:\n\n:'''$2'''\n\n* Blokkeringen begynte: $8\n* Blokkeringen utgår: $6\n* Blokkeringen er ment for: $7\n\nDu kan kontakte $1 eller en av de andre [[{{MediaWiki:Grouppage-sysop}}|administratorene]] for å diskutere blokkeringen.\n\nMerk at du ikke kan bruke «E-post til denne brukeren»-funksjonen med mindre du har registrert en gyldig e-postadresse i [[Special:Preferences|innstillingene dine]].\n\nDin IP-adresse er $3, og blokkerings-ID-en er #$5.\nVennligst ta med all denne informasjonen ved henvendelser.",
"systemblockedtext": "Ditt brukernavn eller IP-adresse har blitt blokkert automatisk av MediaWiki.\n\nBlokkeringen grunnes:\n\n:<em>$2</em>\n\n* Blokkeringen startet: $8\n* Blokkeringen gjelder til: $6\n* Blokkeringen er ment for: $7\n\nDin nåværende IP-adresse er $3.\nVennligst inkluder informasjonen over i alle spørsmål du spør angående dette.",
"blockednoreason": "ingen grunn gitt",
"rcfilters-watchlist-showupdated": "Endringer til sider du ikke har besøkt siden endringene ble gjort vises med <strong>fet</strong> skrift.",
"rcfilters-preference-label": "Skjul den forbedrede versjonen av siste endringer",
"rcfilters-preference-help": "Fjerner grensesnittendringen fra 2017 og alle verktøyene som ble lagt fra og med da.",
+ "rcfilters-watchlist-preference-label": "Skjul den forbedrede versjonen av bevåkningslisten",
+ "rcfilters-watchlist-preference-help": "Ruller tilbake det omarbeidede grensesnittet fra 2017 og alle verktøy som ble lagt til da og etterpå.",
"rcfilters-filter-showlinkedfrom-label": "Vis endringer på sider som lenkes fra",
"rcfilters-filter-showlinkedfrom-option-label": "<strong>Sider som lenkes fra</strong> den valgte siden",
"rcfilters-filter-showlinkedto-label": "Vis endringer på sider som lenker til",
"apisandbox-dynamic-parameters-add-label": "Legg til parameter:",
"apisandbox-dynamic-parameters-add-placeholder": "Parameternavn",
"apisandbox-dynamic-error-exists": "En parameter med navn «$1» finnes fra før.",
+ "apisandbox-templated-parameter-reason": "Denne [[Special:ApiHelp/main#main/templatedparams|templated parameteren]] tilbys basert på {{PLURAL:$1|verdi|verdier}} av $2.",
"apisandbox-deprecated-parameters": "Utgåtte parametre",
"apisandbox-fetch-token": "Fyll inn nøkkelen automatisk",
"apisandbox-add-multi": "Legg til",
"dellogpage": "Slettelogg",
"dellogpagetext": "Under er ei liste over nylige slettinger.",
"deletionlog": "slettelogg",
+ "log-name-create": "Logg over nye sider",
+ "log-description-create": "Under er ei liste over nylige sideopprettelser.",
+ "logentry-create-create": "$1 {{GENDER:$2|opprettet}} side $3",
"reverted": "Gjenopprettet en tidligere versjon",
"deletecomment": "Årsak:",
"deleteotherreason": "Annen/utdypende grunn:",
"pagedata-text": "Denne siden gir et datagrensesnitt til sidene. Oppgi en sidetittel i URL-en, med undersidesyntaks.\n* Innholdsforhandlingen er basert på din klients Accept-header. Dette betyr at data for siden blir angitt på formatet som foretrekkes av din klient.",
"pagedata-not-acceptable": "Ingen passende format funnet. Støttede MIME-typer: $1",
"pagedata-bad-title": "Ugyldig tittel: $1.",
+ "unregistered-user-config": "For å ivareta sikkerhet kan JavaScript, CSS and JSON ikks brukes i underliggende sider for uregistrerte brukere.",
+ "passwordpolicies": "Passord-regler",
+ "passwordpolicies-summary": "Det finnes en liste over gode passord-regler for brukergrupper definert i denne wikien.",
"passwordpolicies-group": "Gruppe",
+ "passwordpolicies-policies": "Regler",
"passwordpolicies-policy-minimalpasswordlength": "Passordet må være på minst $1 {{PLURAL:$1|tegn}}.",
"passwordpolicies-policy-minimumpasswordlengthtologin": "Passordet må være på minst $1 {{PLURAL:$1|tegn}} for å kunne logge inn",
"passwordpolicies-policy-passwordcannotmatchusername": "Passordet kan ikke være det samme som brukernavnet",
"viewdeleted_short": "{{PLURAL:$1|Eén versie die vortedaon is|$1 versies die vortedaon bin}} bekieken",
"protect": "Beveiligen",
"protect_change": "wiezigen",
- "unprotect": "Beveyliging wysigen",
+ "unprotect": "Beveiliging wysigen",
"newpage": "Nieje zied",
"talkpagelinktext": "Oaverleg",
"specialpage": "Speciale syde",
"rcfilters-empty-filter": "Geen actieve filters. Alle bijdragen worden weergegeven.",
"rcfilters-filterlist-title": "Filters",
"rcfilters-filterlist-whatsthis": "Hoe werkt dit?",
- "rcfilters-filterlist-feedbacklink": "Laat ons weten wat u denkt over deze (nieuwe) filterhulpmiddelen",
+ "rcfilters-filterlist-feedbacklink": "Laat ons weten wat u denkt over deze filterhulpmiddelen",
"rcfilters-highlightbutton-title": "Resultaten markeren",
"rcfilters-highlightmenu-title": "Kies een kleur",
"rcfilters-highlightmenu-help": "Selecteer een kleur om deze eigenschap uit te lichten",
"page_first": "prima",
"page_last": "ùltima",
"histlegend": "Confront antra version diferente: che as selession-a le casele dle version che a veul e peui che a sgnaca ël boton për anandié ël process.<br />\nLegenda: (cor) = diferense con la version corenta,\n(prim) = diferense con la version prima, c = modìfica cita",
- "history-fieldset-title": "Varda la cronologìa",
+ "history-fieldset-title": "Serca për revision",
"history-show-deleted": "Mach ëscancelà",
"histfirst": "ij pì vej",
"histlast": "ij pì recent",
"searchprofile-advanced-tooltip": "Sërché ant jë spassi nominaj përsonalisà",
"search-result-size": "$1 ({{PLURAL:$2|un|$2}} mòt)",
"search-result-category-size": "{{PLURAL:$1|1 mèmber|$1 mèmber}} ({{PLURAL:$2|1 sot-categorìa|$2 sot-categorìe}}, {{PLURAL:$3|1 archivi|$3 archivi}})",
- "search-redirect": "(ridiression $1)",
+ "search-redirect": "(rediression da $1)",
"search-section": "(session $1)",
"search-category": "(categorìa $1)",
"search-file-match": "(a corëspond al contnù d'archivi)",
"recentchangeslinked-feed": "Modìfiche colegà",
"recentchangeslinked-toolbox": "Modìfiche colegà",
"recentchangeslinked-title": "Modìfiche ch'a-i intro con \"$1\"",
- "recentchangeslinked-summary": "Costa a l'é na lista ëd modìfiche fàite da pòch a pàgine colegà a cola spessificà (o a mèmber ëd na categorìa spessificà).\nLe pàgine dzora a [[Special:Watchlist|la lista ëd lòn ch'as ten sot-euj]] a resto marcà an '''grassèt'''.",
+ "recentchangeslinked-summary": "Scriv ël nòm ëd na pàgina për vëdde ij cangiament ans ëd pàgine colegà anvers o da cola pàgina. Ij cangiament a pàgine 'd [[Special:Watchlist|your Watchlist]] a son <strong>neirèt</strong>.",
"recentchangeslinked-page": "Nòm ëd la pàgina:",
"recentchangeslinked-to": "Mostré nopà le modìfiche a le pàgine colegà a cola dàita",
"upload": "Carié n'archivi",
"booksources-text": "Ambelessì sota a-i é na lista d'àotri sit che a vendo dij lìber neuv e dë sconda man, e che a peulo ëdcò smon-e dj'anformassion rësgoard ai test che a l'é antramentr che a sërca:",
"booksources-invalid-isbn": "L'ISBN dàit a smija che a sia pa vàlid; ch'a contròla s'a-i é n'eror an copiand da la sorgiss original.",
"specialloguserlabel": "Esecutor:",
- "speciallogtitlelabel": "Obietiv (tìtol o utent):",
+ "speciallogtitlelabel": "Obietiv (titol o {{ns:user}}:utent për l'utilisator):",
"log": "Registr",
"all-logs-page": "Tùit ij registr pùblich",
"alllogstext": "Visualisassion combinà ëd tùit ij registr ëd {{SITENAME}}.\nA peul arstrenze la visualisassion an selessionand la sòrt ëd registr, lë stranòm utent (sensìbil a majùscol/minùscol), e la pàgina anteressà (sensìbil a majùscol/minùscol).",
"unwatchthispage": "Chité-lì ëd ten-e sossì sot-euj",
"notanarticle": "Sòn a l'é pa n'artìcol",
"notvisiblerev": "La revision a l'é stàita scancelà",
- "watchlist-details": "A l'é dëmentrè ch'as ten sot-euj {{PLURAL:$1|$1 pàgina|$1 pàgine}}, nen contand an manera separà cole ëd discussion.",
+ "watchlist-details": "La lista 'd lòn ch'as ten sot-euj {{PLURAL:$1|$1 pàgina|$1 pàgine}}, nen contand an manera separà cole ëd discussion.",
"wlheader-enotif": "La notìfica për pòsta eletrònica a l'é abilità.",
"wlheader-showupdated": "Le pàgine che a son ëstàite modificà da quand che a l'é passaje ansima l'ùltima vira a resto marcà an '''grassèt'''",
"wlnote": "Ambelessì sota a-i {{PLURAL:$1|é l'ùltima modìfica|son j'ùltime <strong>$1</strong> modìfiche}} ant {{PLURAL:$2|l'ùltima ora|j'ùltime <strong>$2</strong> ore}}, a parte da $3, $4.",
"whatlinkshere-hideredirs": "$1 ridiression",
"whatlinkshere-hidetrans": "$1 anclusion",
"whatlinkshere-hidelinks": "$1 anliure",
- "whatlinkshere-hideimages": "$1 j'archivi lijà",
+ "whatlinkshere-hideimages": "$1 liure da archivi",
"whatlinkshere-filters": "Filtr",
"autoblockid": "Blocagi automàtich #$1",
"block": "Bloché l'utent",
"tooltip-feed-rss": "Fluss RSS për costa pàgina",
"tooltip-feed-atom": "Fluss Atom për costa pàgina.",
"tooltip-t-contributions": "Vardé la lista dle contribussion ëd {{GENDER:$1|cost utent}}",
- "tooltip-t-emailuser": "Mandeje un mëssagi ëd pòsta a st'utent",
+ "tooltip-t-emailuser": "Manda 'n mëssaggi email a {{GENDER:$1|cost|costa}} utent",
"tooltip-t-info": "Pi d'anformassio su costa pàgina",
"tooltip-t-upload": "Carié n'archivi ëd figure ò son.",
"tooltip-t-specialpages": "Lista ëd tute le pàgine speciaj.",
"version-libraries": "Biblioteche anstalà",
"version-libraries-library": "Biblioteca",
"version-libraries-version": "Version",
- "redirect": "Ridirigiù da archivi, utent, pàgina o ID ëd revision",
+ "redirect": "Readressament da archivi, utent, pàgina o ID ëd revision",
"redirect-summary": "Costa pàgina special a ponta a n'archivi (dàit ël nòm dl'archivi), na pàgina (dàit n'ID ëd revision o n'ID ëd pàgina) o na pàgina d'utent (dàit n'identificativ numérich a l'utent). Usagi: [[{{#Special:Redirect}}/file/Example.jpg]], [[{{#Special:Redirect}}/revision/328429]], o [[{{#Special:Redirect}}/user/101]].",
"redirect-submit": "Andé",
"redirect-lookup": "Arserca:",
"feedback-thanks": "Mersì! Sò coment a l'é stàit publicà an sla pàgina \"[$2 $1]\".",
"feedback-thanks-title": "Mersì!",
"feedback-useragent": "Agent d'utent:",
- "searchsuggest-search": "Arserché",
+ "searchsuggest-search": "Sërca an {{SITENAME}}",
"searchsuggest-containing": "contenent ...",
"api-error-badtoken": "Eror antern: sìmbol pa bon.",
"api-error-emptypage": "La creassion ëd pàgine neuve veujde a l'é nen përmëttùa.",
"rcfilters-savedqueries-rename": "Label for the menu option that edits a quick filter in [[Special:RecentChanges]]\n{{Identical|Rename}}",
"rcfilters-savedqueries-setdefault": "Label for the menu option that sets a quick filter as default in [[Special:RecentChanges]]",
"rcfilters-savedqueries-unsetdefault": "Label for the menu option that unsets a quick filter as default in [[Special:RecentChanges]]",
- "rcfilters-savedqueries-remove": "Label for the menu option that removes a quick filter as default in [[Special:RecentChanges]]\n{{Identical|Remove}}",
+ "rcfilters-savedqueries-remove": "Label for the menu option that removes a quick filter as default in [[Special:RecentChanges]]\n{{Identical|Delete}}",
"rcfilters-savedqueries-new-name-label": "Label for the input that holds the name of the new saved filters in [[Special:RecentChanges]]\n{{Identical|Name}}",
"rcfilters-savedqueries-new-name-placeholder": "Placeholder for the input that holds the name of the new saved filters in [[Special:RecentChanges]]",
"rcfilters-savedqueries-apply-label": "Label for the button to apply saving a new filter setting in [[Special:RecentChanges]]. This is for a small popup, please try to use a short string.",
"rcfilters-empty-filter": "Нет активных фильтров. Показываются все правки.",
"rcfilters-filterlist-title": "Фильтры",
"rcfilters-filterlist-whatsthis": "Как это работает?",
- "rcfilters-filterlist-feedbacklink": "Расскажите нам, что вы думаете об этих (новых) инструментах фильтрации",
+ "rcfilters-filterlist-feedbacklink": "Расскажите нам, что вы думаете об этих инструментах фильтрации",
"rcfilters-highlightbutton-title": "Выделить результаты",
"rcfilters-highlightmenu-title": "Выберите цвет",
"rcfilters-highlightmenu-help": "Выберите цвет, чтобы подсветить это свойство",
"resetpass-submit-loggedin": "Spremenite geslo",
"resetpass-submit-cancel": "Prekliči",
"resetpass-wrong-oldpass": "Neveljavno začasno ali trenutno geslo.\nMorda ste že spremenili geslo ali pa ste zahtevali novo začasno geslo.",
- "resetpass-recycled": "Prosimo, ponastavite svoje geslo na nekaj drugega kot svoje trenutno geslo.",
+ "resetpass-recycled": "Prosimo, spremenite svoje geslo na nekaj drugega kot svoje trenutno geslo.",
"resetpass-temp-emailed": "Trenutno ste prijavljeni z začasno kodo, poslano z e-pošto. Za zaključitev prijave morate tukaj nastaviti novo geslo:",
"resetpass-temp-password": "Začasno geslo:",
"resetpass-abort-generic": "Razširitev je prekinila spremembo gesla.",
"resetpass-expired": "Vaše geslo je poteklo. Prosimo, nastavite novo geslo za prijavo.",
- "resetpass-expired-soft": "Vaše geslo je poteklo in ga morate ponastaviti. Prosimo, izberite novo geslo zdaj ali kliknite »{{int:authprovider-resetpass-skip-label}}«, da ga ponastavite pozneje.",
- "resetpass-validity-soft": "Vaše geslo ni veljavno: $1\n\nProsimo, izberite novo geslo zdaj ali kliknite »{{int:authprovider-resetpass-skip-label}}«, da ga ponastavite pozneje.",
+ "resetpass-expired-soft": "Vaše geslo je poteklo in ga morate spremeniti. Prosimo, izberite novo geslo zdaj ali kliknite »{{int:authprovider-resetpass-skip-label}}«, da ga spremenite pozneje.",
+ "resetpass-validity-soft": "Vaše geslo ni veljavno: $1\n\nProsimo, izberite novo geslo zdaj ali kliknite »{{int:authprovider-resetpass-skip-label}}«, da ga spremenite pozneje.",
"passwordreset": "Ponastavitev gesla",
"passwordreset-text-one": "Da ponastavite svoje geslo, izpolnite naslednji obrazec.",
"passwordreset-text-many": "{{PLURAL:$1|Da po e-pošti prejmete začasno geslo, izpolnite eno od polj.}}",
"rcfilters-savedqueries-rename": "Preimenuj",
"rcfilters-savedqueries-setdefault": "Nastavi kot privzeto",
"rcfilters-savedqueries-unsetdefault": "Odstrani kot privzeto",
- "rcfilters-savedqueries-remove": "Odstrani",
+ "rcfilters-savedqueries-remove": "Izbriši",
"rcfilters-savedqueries-new-name-label": "Ime",
"rcfilters-savedqueries-new-name-placeholder": "Opišite namen filtra",
"rcfilters-savedqueries-apply-label": "Ustvari filter",
"rcfilters-empty-filter": "Ni dejavnih filtrov. Prikazani so vsi prispevki.",
"rcfilters-filterlist-title": "Filtri",
"rcfilters-filterlist-whatsthis": "Kako to deluje?",
- "rcfilters-filterlist-feedbacklink": "Povejte nam, kaj menite o teh (novih) orodjih za filtriranje",
+ "rcfilters-filterlist-feedbacklink": "Povejte nam, kaj menite o teh orodjih za filtriranje",
"rcfilters-highlightbutton-title": "Označi rezultate",
"rcfilters-highlightmenu-title": "Izberite barvo",
"rcfilters-highlightmenu-help": "Izberite barvo za označitev te lastnosti",
"subject-preview": "Преглед теме:",
"previewerrortext": "Догодила се грешка приликом приказивања ваших измена.",
"blockedtitle": "Корисник је блокиран",
- "blockedtext": "<strong>Ð\92аÑ\88е коÑ\80иÑ\81ниÑ\87ко име или IP адÑ\80еÑ\81а Ñ\98е блокиÑ\80ана.</strong>\n\nÐ\91локиÑ\80аÑ\9aе Ñ\98е {{GENDER:$4|извÑ\80Ñ\88ио|извÑ\80Ñ\88ила}} $1.\nРазлог Ñ\98е <em>$2</em>.\n\n* Ð\94аÑ\82Ñ\83м блокиÑ\80аÑ\9aа: $8\n* Ð\91локиÑ\80аÑ\9aе иÑ\81Ñ\82иÑ\87е: $6\n* Ð\98ме коÑ\80иÑ\81ника: $7\n\nÐ\9eбÑ\80аÑ\82иÑ\82е Ñ\81е {{GENDER:$4|коÑ\80иÑ\81никÑ\83|коÑ\80иÑ\81ниÑ\86и}} $1 или [[{{MediaWiki:Grouppage-sysop}}|админиÑ\81Ñ\82Ñ\80аÑ\82оÑ\80Ñ\83]] да Ñ\80азÑ\98аÑ\81ниÑ\82е Ñ\81Ñ\82ваÑ\80.\nÐ\9dе можеÑ\82е коÑ\80иÑ\81Ñ\82иÑ\82и могÑ\83Ñ\9bноÑ\81Ñ\82 â\80\9eÐ\9fоÑ\88аÑ\99и имеÑ\98л овом коÑ\80иÑ\81никÑ\83â\80\9c ако ниÑ\81Ñ\82е Ñ\83нели иÑ\81пÑ\80авнÑ\83 имеÑ\98л адÑ\80еÑ\81Ñ\83 Ñ\83 [[Special:Preferences|подеÑ\88аваÑ\9aима]].\nÐ\92аÑ\88а блокиÑ\80ана IP адÑ\80еÑ\81а Ñ\98е $3, а ID блокиÑ\80аÑ\9aа $5.\nÐ\9dаведиÑ\82е Ñ\81ве подаÑ\82ке изнад при стварању било каквих упита.",
+ "blockedtext": "<strong>Ð\92аÑ\88е коÑ\80иÑ\81ниÑ\87ко име или IP адÑ\80еÑ\81а Ñ\98е блокиÑ\80ана.</strong>\n\nÐ\91локиÑ\80аÑ\9aе Ñ\98е {{GENDER:$4|извÑ\80Ñ\88ио|извÑ\80Ñ\88ила}} $1.\nРазлог Ñ\98е <em>$2</em>.\n\n* Ð\9fоÑ\87еÑ\82ак блокиÑ\80аÑ\9aа: $8\n* Ð\98Ñ\81Ñ\82ек блокиÑ\80аÑ\9aа: $6\n* Ð\91локиÑ\80ани: $7\n\nÐ\9cожеÑ\82е да Ñ\81е обÑ\80аÑ\82иÑ\82е {{GENDER:$4|коÑ\80иÑ\81никÑ\83|коÑ\80иÑ\81ниÑ\86и}} $1 или [[{{MediaWiki:Grouppage-sysop}}|админиÑ\81Ñ\82Ñ\80аÑ\82оÑ\80Ñ\83]] Ñ\80ади диÑ\81кÑ\83Ñ\81иÑ\98е о блокиÑ\80аÑ\9aÑ\83.\nÐ\9dе можеÑ\82е да коÑ\80иÑ\81Ñ\82иÑ\82е могÑ\83Ñ\9bноÑ\81Ñ\82 â\80\9e{{int:emailuser}}â\80\9d оÑ\81им ако Ñ\81Ñ\82е Ñ\83нели валиднÑ\83 имеÑ\98л адÑ\80еÑ\81Ñ\83 Ñ\83 Ñ\81воÑ\98им [[Special:Preferences|подеÑ\88аваÑ\9aима]] налога и ниÑ\81Ñ\82е блокиÑ\80ани од коÑ\80иÑ\88Ñ\9bеÑ\9aа иÑ\81Ñ\82е.\nÐ\92аÑ\88а Ñ\82Ñ\80енÑ\83Ñ\82на IP адÑ\80еÑ\81а Ñ\98е $3, а ID блокиÑ\80аÑ\9aа #$5.\nÐ\9dаведиÑ\82е Ñ\81ве инÑ\84оÑ\80маÑ\86иÑ\98е одозго при стварању било каквих упита.",
"autoblockedtext": "Ваша IP адреса је блокирана јер ју је употребљавао други корисник, кога је {{GENDER:$4|блокирао|блокирала}} $1.\nРазлог:\n\n:<em>$2</em>\n\n* Датум блокирања: $8\n* Блокирање истиче: $6\n* Име корисника: $7\n\nОбратите се {{GENDER:$4|кориснику|корисници}} $1 или [[{{MediaWiki:Grouppage-sysop}}|администратору]] да разјасните ствар.\n\nНе можете користити могућност „Пошаљи имејл овом кориснику“ ако нисте унели исправну имејл адресу у [[Special:Preferences|подешавањима]].\n\nВаша блокирана IP адреса је $3, а ID $5.\nНаведите све податке изнад при стварању било каквих упита.",
"blockednoreason": "разлог није наведен",
"whitelistedittext": "За уређивање странице је потребно да будете $1.",
"recentchangeslinked-feed": "Сродне измене",
"recentchangeslinked-toolbox": "Сродне измене",
"recentchangeslinked-title": "Сродне измене са „$1“",
- "recentchangeslinked-summary": "УнеÑ\81иÑ\82е име Ñ\81Ñ\82Ñ\80аниÑ\86е да биÑ\81Ñ\82е видели пÑ\80омене на Ñ\81Ñ\82Ñ\80аниÑ\86ама коÑ\98е Ñ\81Ñ\83 повезане Ñ\81а или Ñ\81а Ñ\82е Ñ\81Ñ\82Ñ\80аниÑ\86е. (Ð\94а биÑ\81Ñ\82е видели Ñ\87ланове неке каÑ\82егоÑ\80иÑ\98е, Ñ\83неÑ\81иÑ\82е Ð\9aаÑ\82егоÑ\80иÑ\98а:Ð\9dазив каÑ\82егоÑ\80иÑ\98е). Ð\9fÑ\80омене на Ñ\81Ñ\82Ñ\80аниÑ\86ама коÑ\98е Ñ\81Ñ\83 на [[Special:Watchlist|ваÑ\88ем Ñ\81пиÑ\81кÑ\83 надгледаÑ\9aа]] Ñ\81Ñ\83 '''подебÑ\99ане'''.",
+ "recentchangeslinked-summary": "УнеÑ\81иÑ\82е име Ñ\81Ñ\82Ñ\80аниÑ\86е да биÑ\81Ñ\82е видели пÑ\80омене на Ñ\81Ñ\82Ñ\80аниÑ\86ама коÑ\98е Ñ\81Ñ\83 повезане Ñ\81а или Ñ\81а Ñ\82е Ñ\81Ñ\82Ñ\80аниÑ\86е. (Ð\94а биÑ\81Ñ\82е видели Ñ\87ланове каÑ\82егоÑ\80иÑ\98е, Ñ\83неÑ\81иÑ\82е {{ns:category}}:Ð\98ме каÑ\82егоÑ\80иÑ\98е). Ð\9fÑ\80омене на Ñ\81Ñ\82Ñ\80аниÑ\86ама коÑ\98е Ñ\81Ñ\83 на [[Special:Watchlist|Ð\92аÑ\88ем Ñ\81пиÑ\81кÑ\83 надгледаÑ\9aа]] Ñ\81Ñ\83 <strong>подебÑ\99ане</strong>.",
"recentchangeslinked-page": "Назив странице:",
"recentchangeslinked-to": "Прикажи измене страница које су повезане с датом страницом",
"recentchanges-page-added-to-category": "[[:$1]] је додата у категорију",
"rcfilters-savedqueries-rename": "Döp om",
"rcfilters-savedqueries-setdefault": "Ange som standard",
"rcfilters-savedqueries-unsetdefault": "Ta bort som standard",
- "rcfilters-savedqueries-remove": "Ta bort",
+ "rcfilters-savedqueries-remove": "Radera",
"rcfilters-savedqueries-new-name-label": "Namn",
"rcfilters-savedqueries-new-name-placeholder": "Beskriv syftet med filtret",
"rcfilters-savedqueries-apply-label": "Skapa filter",
"rcfilters-empty-filter": "Inga aktiva filter. Alla bidrag visas.",
"rcfilters-filterlist-title": "Filter",
"rcfilters-filterlist-whatsthis": "Hur fungerar dessa?",
- "rcfilters-filterlist-feedbacklink": "Berätta vad du tycker om dessa (nya) filtreringsverktyg",
+ "rcfilters-filterlist-feedbacklink": "Berätta vad du tycker om dessa filtreringsverktyg",
"rcfilters-highlightbutton-title": "Markera resultat",
"rcfilters-highlightmenu-title": "Välj en färg",
"rcfilters-highlightmenu-help": "Välj en färg att markera denna egenskap",
"exif-copyrighted-false": "Upphovsrättsstatus inte angivet",
"exif-photometricinterpretation-0": "Svartvitt (vit är 0)",
"exif-photometricinterpretation-1": "Svart och vit (svart är 0)",
+ "exif-photometricinterpretation-3": "Palett",
+ "exif-photometricinterpretation-4": "Genomskinlighetsmask",
+ "exif-photometricinterpretation-5": "Separerad (förmodligen CMYK)",
"exif-unknowndate": "Okänt datum",
"exif-orientation-1": "Normal",
"exif-orientation-2": "Spegelvänd horisontellt",
"cascadeprotected": "Bu sayfa değişiklik yapılması engellenmiştir, çünkü \"kademeli\" seçeneği aktif hale getirilerek koruma altına alınan {{PLURAL:$1|sayfada|sayfalarda}} kullanılmaktadır:\n$2",
"namespaceprotected": "'''$1''' alandındaki sayfaları düzenlemeye izniniz bulunmamaktadır.",
"customcssprotected": "Bu sayfayı değiştirmeye yetkiniz bulunmamaktadır, çünkü bu sayfa başka bir kullanıcının kişisel ayarlarını içermektedir.",
+ "customjsonprotected": "Başka bir kullanıcının kişisel ayarlarını içerdiği için bu JSON sayfasını düzenleme izniniz yok.",
"customjsprotected": "Bu Java Script sayfasını değiştirmeye yetkiniz bulunmamaktadır, çünkü bu sayfa başka bir kullanıcının kişisel ayarlarını içermektedir.",
"mycustomcssprotected": "Bu CSS sayfasını değiştirmeye yetkiniz yok.",
"mycustomjsonprotected": "Bu JSON sayfasını düzenleme izniniz yok.",
"botpasswords-label-delete": "Sil",
"botpasswords-label-resetpassword": "Parolayı sıfırla",
"botpasswords-label-grants": "Geçerli ayrıcalıklar:",
+ "botpasswords-help-grants": "Hibeler, kullanıcı hesabınızın sahip olduğu haklara erişim izni verir. Burada bir hibe sağlamak, kullanıcı hesabınızın sahip olamayacağı herhangi bir haklara erişim sağlamaz. Daha fazla bilgi için [[Special:ListGrants|hibe tablosuna]] bakınız.",
"botpasswords-label-grants-column": "Verilen",
"botpasswords-bad-appid": "Bot ismi \"$1\" geçerli değil.",
"botpasswords-insert-failed": "Bot adı \"$1\" eklenemedi. Zaten eklenmiş olmalı?",
"botpasswords-updated-title": "Bot parolası güncellendi",
"botpasswords-updated-body": "\"$2\" adlı {{GENDER:$2|kullanıcının}} \"$1\" adlı botunun bot parolası güncellendi.",
"botpasswords-deleted-title": "Bot parolası silindi",
- "botpasswords-deleted-body": "\"$2\" adlı kullanıcının \"$1\" adlı botunun bot parolası silindi.",
- "botpasswords-newpassword": "<strong>$1</strong> ile oturum açmak için yeni şifre: <strong>$2</strong>. <em>İlerde başvurmak için lütfen kaydedin.</em>",
+ "botpasswords-deleted-body": "\"$2\" adlı {{GENDER:$2|kullanıcının}} \"$1\" adlı botunun bot parolası silindi.",
+ "botpasswords-newpassword": "<strong>$1</strong> ile giriş yapılan yeni parola: <strong>$2</strong>. <em>Lütfen bunu ileride başvurmak için kaydedin.<em> <br> (Kullanıcı adının, nihai kullanıcı adıyla aynı olmasını gerektiren eski botlar için kullanıcı adı olarak <strong>$3</strong> ve şifre olarak da <strong>$4</strong> kullanabilirsiniz.)",
"botpasswords-no-provider": "BotPasswordsSessionProvider kullanılamaz.",
"botpasswords-restriction-failed": "Bot parolası kısıtlamaları bu oturum açma işlemini önlemektedir.",
"botpasswords-invalid-name": "Belirtilen kullanıcı adı bot parolası ayırıcısı içermiyor (\"$1\").",
+ "botpasswords-not-exist": "\"$1\" kullanıcısının \"$2\" adında bir bot şifresine sahip değil.",
"botpasswords-needs-reset": "\"$1\" {{GENDER:$1|kullanıcısına}} ait \"$2\" adlı bot için bot parolası sıfırlanmalı.",
"resetpass_forbidden": "Parolalar değiştirilememektedir",
"resetpass_forbidden-reason": "Parolalar değiştirilemez: $1",
"passwordreset-emailelement": "Kullanıcı adı: \n$1\n\nGeçici şifre: \n$2",
"passwordreset-emailsentemail": "Eğer bu e-posta adresi hesabınızın bağlı olduğu adres ise, bir parola sıfırlama e-postası gönderilecektir.",
"passwordreset-emailsentusername": "Eğer bu e-posta adresi hesabınızın bağlı olduğu adres ise, bir parola sıfırlama e-postası gönderilecektir.",
+ "passwordreset-nocaller": "Bir ziyaretçi sağlanmalıdır",
+ "passwordreset-nosuchcaller": "Ziyaretçi mevcut değil: $1",
+ "passwordreset-ignored": "Şifre sıfırlama işlenmedi. Belki de herhangi bir sağlayıcı yapılandırılmadı?",
"passwordreset-invalidemail": "Geçersiz e-posta adresi",
"passwordreset-nodata": "Ne bir kullanıcı adı ne de bir e-posta adresi verildi.",
"changeemail": "E-posta adresini değiştir veya çıkar",
"previewerrortext": "Yaptığınız değişikliklerin önizlemesi sırasında bir hata oluştu.",
"blockedtitle": "Kullanıcı erişimi engellendi.",
"blockedtext": "<strong>Kullanıcı adı veya IP adresiniz engellenmiştir.</strong>\n\nSizi engelleyen hizmetli: $1.<br />\nEngelleme sebebi: <em>$2</em>.\n\n* Engellenmenin başlangıcı: $8\n* Engellenmenin bitişi: $6\n* Engellenme süresi: $7\n\nBelirtilen nedene göre engellenmenizin uygun olmadığını düşünüyorsanız, $1 ya da başka bir [[{{MediaWiki:Grouppage-sysop}}|hizmetli]] ile bu durumu görüşebilirsiniz. [[Special:Preferences|Tercihlerim]] kısmında geçerli bir e-posta adresi girmediyseniz \"Kullanıcıya e-posta gönder\" özelliğini kullanamazsınız, tercihlerinize e-posta adresinizi eklediğinizde e-posta gönderme hakkına sahip olacaksınız.\n<br />Şu anki IP adresiniz $3, engellenme numaranız #$5.\n<br />Bir hizmetliden durumunuz hakkında bilgi almak istediğinizde veya herhangi bir sorguda bu bilgiler gerekecektir, lütfen not ediniz.",
- "autoblockedtext": "IP adresiniz otomatik olarak engellendi, çünkü $1 tarafından engellenmiş başka bir kullanıcı tarafından kullanılmaktaydı.\nBelirtilen sebep şudur:\n\n:''$2''\n\n* Engellemenin başlangıcı: $8\n* Engellemenin bitişi: $6\n* Bloke edilmesi istenen: $7\n\nEngelleme hakkında tartışmak için $1 ile veya diğer [[{{MediaWiki:Grouppage-sysop}}|hizmetlilerden]] biriyle irtibata geçebilirsiniz.\n\nNot, [[Special:Preferences|kullanıcı tercihlerinize]] geçerli bir e-posta adresi kaydetmediyseniz \"kullanıcıya e-posta gönder\" özelliğinden faydalanamayabilirsiniz ve bu özelliği kullanmaktan engellenmediniz.\n\nŞu anki IP numaranız $3 ve engellenme ID'niz #$5.\nLütfen yapacağınız herhangi bir sorguda yukarıdaki bütün detayları bulundurun.",
+ "autoblockedtext": "IP adresiniz otomatik olarak engellendi, çünkü $1 tarafından engellenmiş başka bir kullanıcı tarafından kullanılmaktaydı.\nBelirtilen sebep şudur:\n\n:<em>$2</em>\n\n* Engellemenin başlangıcı: $8\n* Engellemenin bitişi: $6\n* Bloke edilmesi istenen: $7\n\nEngelleme hakkında tartışmak için $1 ile veya diğer [[{{MediaWiki:Grouppage-sysop}}|hizmetlilerden]] biriyle irtibata geçebilirsiniz.\n\nNot, [[Special:Preferences|kullanıcı tercihlerinize]] geçerli bir e-posta adresi kaydetmediyseniz \"{{int:emailuser}}\" özelliğinden faydalanamayabilirsiniz ve bu özelliği kullanmaktan engellenmediniz.\n\nŞu anki IP numaranız $3 ve engellenme ID'niz #$5.\nLütfen yapacağınız herhangi bir sorguda yukarıdaki bütün detayları bulundurun.",
+ "systemblockedtext": "Kullanıcı adınız veya IP adresiniz MediaWiki tarafından otomatik olarak engellendi.\nSebebi:\n\n:<em>$2</em>\n\n* Engelin başlangıcı: $8\n* Engelin süresi: $6\n* Engellenmesi istenen: $7\n\nMevcut IP adresiniz $3.\nLütfen yukarıdaki tüm ayrıntıları, yaptığınız sorgularda belirtin.",
"blockednoreason": "sebep verilmedi",
"whitelistedittext": "Değişiklik yapabilmek için $1.",
"confirmedittext": "Sayfa değiştirmeden önce e-posta adresinizi onaylamalısınız. Lütfen [[Special:Preferences|tercihler]] kısmından e-postanızı ekleyin ve onaylayın.",
"blocked-notice-logextract": "Bu kullanıcı şuanda engellenmiş.\nSon engelleme günlüğü girdisi referans için aşağıda sağlanmıştır:",
"clearyourcache": "<strong>Note:</strong> Kaydettikten sonra değişiklikleri görmek için tarayıcınızın önbelleğini temizlemeniz gerekebilir.\n* <strong>Firefox / Safari:</strong> <em>Shift</em> tuşuna basılıyken <em>Yeniden yükle</em>'ye tıklayın ya da <em>Ctrl-F5</em> ya da <em>Ctrl-R</em> yapın (Mac için <em>⌘-R</em>).\n* <strong>Google Chrome:</strong> <em>Ctrl-Shift-R</em>'ye basın. (Mac için <em>⌘-Shift-R</em>)\n* <strong>Internet Explorer:</strong> <em>Ctrl</em> basılıyken <em>Yenile</em>'ye tıklayın ya da <em>Ctrl-F5</em> yapın.\n* <strong>Opera:</strong> <em>Araçlar → Tercihler</em>'den önbelliği temizleyin.",
"usercssyoucanpreview": "'''İpucu:''' Kaydetmeden önce \"{{int:showpreview}}\"e tıklayarak yeni CSSinizi deneyin.",
+ "userjsonyoucanpreview": "<strong>İpucu:</strong> kaydetmeden önce yeni JSON'unuzu test etmek için \"{{int:showpreview}}\" butonunu kullanın.",
"userjsyoucanpreview": "'''İpucu:''' Kaydetmeden önce \"{{int:showpreview}}\"e tıklayarak yeni JavaScript'inizi test edin.",
"usercsspreview": "'''Sadece kullanıcı CSS dosyanızın önizlemesini görüyorsun.''' '''Kullanıcı CSS dosyası henüz kaydolmadı!'''",
+ "userjsonpreview": "<strong>Sadece kullanıcı JSON yapılandırmanızı test ettiğiniz/önizleme yaptığınızı unutmayın.\nHenüz kaydedilmedi!</strong>",
"userjspreview": "'''Sadece test ediyorsun ya da önizleme görüyorsun - kullanıcı JavaScript'i henüz kaydolmadı.'''",
"sitecsspreview": "'''Sadece kullanıcı CSS dosyanızın önizlemesini görüyorsunuz.''' \n'''Henüz kaydedilmedi!'''",
+ "sitejsonpreview": "<strong>Sadece bu JSON yapılandırmasını önizlediğinizi unutmayın.\nHenüz kaydedilmedi!</strong>",
"sitejspreview": "'''Sadece kullanıcı JavaScript kod dosyanızın önizlemesini görüyorsunuz.''' \n'''Henüz kaydedilmedi!'''",
- "userinvalidconfigtitle": "'''Uyarı:''' \"$1\" adında bir tema yoktur. Özel .css ve .js sayfalarının adlarını küçük harf ile yazın, örneğin; \"{{ns:user}}:Örnek/Vector.css\" yerine \"{{ns:user}}:Örnek/vector.css\" yazın.",
+ "userinvalidconfigtitle": "<strong>Uyarı:</strong> \"$1\" adında bir tema yoktur. Özel .css, .json ve .js sayfalarının adlarını küçük harf ile yazın, örneğin; \"{{ns:user}}:Örnek/Vector.css\" yerine \"{{ns:user}}:Örnek/vector.css\" yazın.",
"updated": "(Güncellendi)",
"note": "'''Not: '''",
"previewnote": "'''Bunun yalnızca bir ön izleme olduğunu unutmayın.'''\nYaptığınız değişiklikler henüz kaydedilmedi!",
"continue-editing": "Düzenlemeye devam et",
"previewconflict": "Bu önizleme metin düzenleme kutucuğunun üstünde, maddenin eğer değişikliklerinizi kaydetmeyi seçerseniz nasıl görüneceğini yansıtır.",
"session_fail_preview": "Özür dileriz. Oturum verisi kaybından dolayı değişikliğinizi kaydedemedik.\n\nOturumunuzu kapatmış olabilirsiniz. <strong>Lütfen oturumunuzun açık olduğunu doğrulayıp tekrar deneyiniz</strong>.\nEğer sorun devam ederse, [[Special:UserLogout|oturumu kapatıp]] tekrar giriş yapmayı deneyin ve tarayıcınızın bu siteden çerezlere izin verip vermediğini kontrol edin.",
- "session_fail_preview_html": "'''Üzgünüz! Oturum verisinin kaybolmasından dolayı düzenlemenizi işleme geçiremeyeceğiz.'''\n\n''Çünkü {{SITENAME}} sitesinde raw HTML etkindir, önizleme JavaScript saldırılarına önlem olarak gizlenmiştir.''\n\n'''Eğer bu haklı bir düzenleme girişimiyse, lütfen yeniden deneyin. Eğer hala çalışmazsa, [[Special:UserLogout|çıkış yapıp]] yeniden oturum açmayı deneyin.'''",
+ "session_fail_preview_html": "Üzgünüz! Düzenleme işleminiz, oturum verisi kaybı nedeniyle işlenemedi.\n\n<em>{{SITENAME}} sayfasında ham HTML özelliği etkin olduğundan önizleme, JavaScript saldırılarına karşı bir önlem olarak gizlenir.</em>\n\n<strong>Bu yasal bir düzenleme girişimi ise lütfen tekrar deneyin.</strong>\nHâlâ çalışmıyorsa [[Special:UserLogout|çıkış yapmayı]] ve ardından tekrar giriş yapmayı deneyin ve tarayıcınızın bu sitedeki çerezlere izin verdiğini kontrol edin.",
"token_suffix_mismatch": "'''Değişikliğiniz geri çevrildi çünkü alıcınız düzenleme kutucuğundaki noktalama işaretlerini bozdu.\nDeğişikliğiniz, sayfa metninde bozulmayı önlemek için geri çevrildi.\nEğer sorunlu bir web-tabanlı anonim proksi servisi kullanıyorsanız bu olay bazen gerçekleşebilir.'''",
"edit_form_incomplete": "'''Değişiklik formu için bazı sunuculara erişilemedi; yaptığınız değişiklikler bozulmamıştır, gözden geçirip tekrar deneyiniz.'''",
"editing": "\"$1\" sayfasını değiştirmektesiniz",
"yourtext": "Sizin metniniz",
"storedversion": "Kaydedilmiş metin",
"editingold": "'''Uyarı: Sayfanın eski bir sürümünde değişiklik yapmaktasınız.'''\nKaydettiğinizde bu tarihli sürümden günümüze kadar olan değişiklikler yok olacaktır.",
+ "unicode-support-fail": "Görünüşe göre tarayıcınız Unicode'u desteklemiyor. Sayfaları düzenlemek için bu desteğe ihtiyacınız var. Bundan ötürü düzenlemeniz kaydedilmedi.",
"yourdiff": "Karşılaştırma",
"copyrightwarning": "'''Lütfen dikkat:''' {{SITENAME}} sitesine yapılan bütün katkılar $2 sözleşmesi kapsamındadır (ayrıntılar için $1'a bakınız).\nYaptığınız katkının başka katılımcılar tarafından acımasızca değiştirilmesini ve sınırsızca başka yerlere dağıtılmasını istemiyorsanız, katkıda bulunmayınız.<br />\nAyrıca buraya katkıda bulunarak, bu katkının kendiniz tarafından yazıldığına ya da kamuya açık bir kaynaktan ya da başka bir özgür/ücretsiz kaynaktan kopyalandığına güvence vermiş oluyorsunuz. '''Buraya, telif sahibinin izni olmadan telif hakkı ile korunan eserleri eklemeyiz! '''",
"copyrightwarning2": "Lütfen, {{SITENAME}} sitesine bulunacağınız tüm katkıların diğer üyeler tarafından düzenlenebileceğini, değiştirilebileceğini ya da silinebileceğini hatırlayın. Yazılarınızın merhametsizce değiştirilebilmesine rıza göstermiyorsanız buraya katkıda bulunmayın. <br />\nAyrıca bu ekleyeceğiniz yazıyı sizin yazdığınızı ya da serbest kopyalama izni veren bir kaynaktan kopyaladığınızı bize taahhüt etmektesiniz (ayrıntılar için referans: $1).",
"editpage-cannot-use-custom-model": "Bu sayfanın içerik modeli değiştirilemez.",
"longpageerror": "'''Hata: Girdiğiniz metnin uzunluğu kabul edilebilir en fazla uzunluk olan {{PLURAL:$2|bir kilobayt|$2 kilobayt}}tan fazladır ve {{PLURAL:$1|bir kilobayt|$1 kilobayt}} büyüklüğündedir.'''\nDeğişikliğiniz kaydedilemez.",
- "readonlywarning": "'''Uyarı: Bakım nedeniyle veritabanı şu anda kilitlenmiştir. Bu yüzden şu anda düzenlemelerinizi kaydetmek mümkün değildir.''' \nYaptığınız düzenlemeleri daha sonra kaydetmek isterseniz, yaptığınız düzenlemeleri bir metin dosyasına ya da herhangi bir şeye kopyala yapıştır yaparak saklayınız.\n\nKilitlemeyi yapan yetkili şu açıklamayı eklemiştir: $1",
+ "readonlywarning": "<strong>Uyarı: Bakım nedeniyle veritabanı şu anda kilitlenmiştir. Bu yüzden şu anda düzenlemelerinizi kaydetmek mümkün değildir.</strong> \nYaptığınız düzenlemeleri daha sonra kaydetmek isterseniz, yaptığınız düzenlemeleri bir metin dosyasına ya da herhangi bir şeye kopyala yapıştır yaparak saklayınız.\n\nKilitlemeyi yapan sistem yetkilisi şu açıklamayı eklemiştir: $1",
"protectedpagewarning": "'''Uyarı: Bu sayfa koruma altına alınmıştır ve yalnızca hizmetli olanlar tarafından değiştirilebilir.'''\nSon günlük girdisi referans amaçlı aşağıda verilmiştir:",
- "semiprotectedpagewarning": "'''Not:''' Bu sayfa sadece kayıtlı kullanıcı olanlar tarafından değiştirilebilir.\nSon günlük girdisi referans amaçlı aşağıda verilmiştir:",
- "cascadeprotectedwarning": "'''UYARI:''' Bu sayfa sadece hizmetlilik yetkileri olan kullanıcıların değişiklik yapabileceği şekilde koruma altına alınmıştır. Çünkü \"kademeli\" seçeneği aktif hale getirilerek koruma altına alınan {{PLURAL:$1|sayfada|sayfada}} kullanılmaktadır:",
+ "semiprotectedpagewarning": "<strong>Not:</strong> Bu sayfa sadece otomatik onaylanmış kullanıcılar tarafından değiştirilebilir.\nSon günlük girdisi referans amaçlı aşağıda verilmiştir:",
+ "cascadeprotectedwarning": "<strong>Uyarı:</strong> Bu sayfa, yalnızca [[Special:ListGroupRights|belirli haklara]] sahip kullanıcıların bunu düzenleyebilmesi için korunmuştur çünkü \"kademeli\" seçeneği aktif hale getirilerek koruma altına alınan {{PLURAL:$1|sayfada|sayfada}} kullanılmaktadır:",
"titleprotectedwarning": "'''Uyarı: Bu sayfa [[Special:ListGroupRights|özel hakları]] olanların oluşturabilmeleri için kilitlenmiştir.'''\nSon günlük girdisi referans amaçlı aşağıda verilmiştir:",
"templatesused": "Bu sayfada kullanılan {{PLURAL:$1|şablon|şablonlar}}:",
"templatesusedpreview": "Bu önizlemede kullanılan {{PLURAL:$1|şablon|şablonlar}}:",
"permissionserrors": "İzin hatası",
"permissionserrorstext": "Aşağıdaki {{PLURAL:$1|sebep|sebepler}}den dolayı, bunu yapmaya yetkiniz yok:",
"permissionserrorstext-withaction": "Aşağıdaki {{PLURAL:$1|neden|nedenler}}den dolayı $2 yetkiniz yok:",
+ "contentmodelediterror": "Bu revizyonu, içerik modeli <code>$1</code> olduğu için düzenleyemezsiniz. <code>$2</code> sayfasının güncel içerik modelinden farklıdır",
"recreate-moveddeleted-warn": "<strong>Uyarı: Daha önceden silinmiş bir sayfayı tekrar oluşturuyorsunuz.</strong>\n\nBu sayfayı düzenlemeye devam etmenin uygun olup olmadığını düşünmelisiniz.\nBu sayfanın silme ve taşıma günlüğü kolaylık için burada verilmiştir:",
"moveddeleted-notice": "Bu sayfa silinmiş.\nSayfanın silme, koruma ve taşıma kaydı referans için aşağıda verilmiştir.",
+ "moveddeleted-notice-recent": "Üzgünüz, bu sayfa yakın zamanda silinmiştir (son 24 saat içinde).\nSayfa için silme, koruma ve taşıma kaydı referans amacıyla aşağıda verilmiştir.",
"log-fulllog": "Tam günlüğü gör",
"edit-hook-aborted": "Değişiklik çengelle durduruldu.\nBir açıklama verilmedi.",
"edit-gone-missing": "Sayfa güncellenemiyor.\nSilinmiş görünüyor.",
"content-json-empty-object": "Boş nesne",
"content-json-empty-array": "Boş dizi",
"deprecated-self-close-category": "Kendiliğinden geçersiz HTML etiketlerini kullanan sayfalar",
+ "deprecated-self-close-category-desc": "Sayfa, <code><b/></code> veya <code><span/></code> gibi kendiliğinden geçersiz HTML etiketleri içeriyor. Bunların davranışları yakında HTML5 belirtimiyle tutarlı olacak şekilde değişecektir, bu nedenle wikitext'deki kullanımları, kullanımdan kaldırılır.",
"duplicate-args-warning": "<strong>Uyarı:</strong>[[:$1]] [[:$2]] şablonunu \"$3\" parametresi için birden fazla değerle çağırıyor. Sadece sağlanan son değer kullanılacak.",
"duplicate-args-category": "Yinelenen şablon değişkenleri kullanan sayfalar",
"duplicate-args-category-desc": "Sayfada içeren şablonları çağırmak için bu terimler kullanılır <code><nowiki>{{foo|bar=1|bar=2}}</nowiki></code> or <code><nowiki>{{foo|bar|1=baz}}</nowiki></code>.",
"post-expand-template-argument-category": "Geçersiz şablon değiştirgenleri içeren sayfalar",
"parser-template-loop-warning": "Şablon düğümü tespit edildi: [[$1]]",
"template-loop-category": "Şablon döngülü sayfalar",
+ "template-loop-category-desc": "Sayfa bir şablon döngüsü içermektedir. Örneğin, kendini sürekli olarak çağıran bir şablon.",
+ "template-loop-warning": "<strong>Uyarı:</strong> Bu sayfa, bir şablon döngüsüne (sonsuz yinelemeli çağrı) neden olan [[:$1]] şablonunu çağırmaktadır.",
"parser-template-recursion-depth-warning": "Şablon özyineleme yoğunluğu sınırı aşıldı ($1)",
"language-converter-depth-warning": "Dil çevirici derinlik sınırı aşıldı ($1)",
"node-count-exceeded-category": "Düğüm sayısı aşılan sayfalar",
"expansion-depth-exceeded-warning": "Sayfa genişletme derinliği aşıldı",
"parser-unstrip-loop-warning": "Yineleme döngüsü algılandı",
"unstrip-depth-warning": "($1) yineleme sınırı aşıldı",
+ "unstrip-depth-category": "Sızıntı derinliği sınırının aşıldığı sayfalar",
"converter-manual-rule-error": "Elle yapılandırma dil dönüşüm kuralı hatası tespit edildi",
"undo-success": "Bu değişiklik geri alınabilir. Lütfen aşağıdaki karşılaştırmayı kontrol edin, gerçekten bu değişikliği yapmak istediğinizden emin olun ve sayfayı kaydederek bir önceki değişikliği geriye alın.",
"undo-failure": "Değişikliklerin çakışması nedeniyle geri alma işlemi başarısız oldu.",
"mw-widgets-titleinput-description-new-page": "sayfa henüz mevcut değil",
"mw-widgets-titleinput-description-redirect": "$1'e yönlendirildi",
"mw-widgets-categoryselector-add-category-placeholder": "Bir kategori ekle...",
+ "mw-widgets-usersmultiselect-placeholder": "Daha fazla ekle...",
"date-range-from": "Şu tarihten:",
"date-range-to": "Bu güne kadar:",
"sessionprovider-mediawiki-session-cookiesessionprovider": "çerez tabanlı oturumlar",
"tog-enotifwatchlistpages": "Күзәтү исемлегемдәге бит яки файл үзгәртелү турында электрон почтага хәбәр җибәрелсен",
"tog-enotifusertalkpages": "Бәхәс битем үзгәртелү турында электрон почтага хәбәр җибәрелсен",
"tog-enotifminoredits": "Кече үзгәртүләр турында да электрон почтага хәбәр җибәрелсен",
- "tog-enotifrevealaddr": "Ð¥Ó\99бÓ\99Ñ\80лÓ\99Ñ\80дÓ\99 e-mail адресым күрсәтелсен",
+ "tog-enotifrevealaddr": "Ð¥Ó\99бÓ\99Ñ\80намÓ\99дÓ\99 минем поÑ\87Ñ\82а адресым күрсәтелсен",
"tog-shownumberswatching": "Битне күзәтү исемлекләренә өстәгән кулланучылар санын күрсәтелсен",
"tog-oldsig": "Хәзерге имзагыз:",
"tog-fancysig": "Имзаның шәхси вики-билгеләмәсе (автоматик сылтамасыз)",
"Zainab Meher",
"Sayam Asjad",
"Abdulq",
- "Fitoschido"
+ "Fitoschido",
+ "Dcljr"
]
},
"tog-underline": "ربط کی خط کشیدگی:",
"enotif_subject_moved": "{{SITENAME}} میں صفحہ $1 کو $2 نے {{GENDER:$2|منتقل کیا}}",
"enotif_subject_restored": "{{SITENAME}} میں صفحہ $1 کو $2 نے {{GENDER:$2|بحال کیا}}",
"enotif_subject_changed": "{{SITENAME}} کے صفحہ $1 میں $2 نے {{GENDER:$2|تبدیلی کی}}",
- "enotif_body_intro_deleted": "{{SITENAME}} میں صفحہ $1 کو بتاریخ $PAGEEDITDATE صارف $2 نے {{GENDER:$2|حذف کر دیا}} ہے، ملاحظہ فرمائیں $3۔",
+ "enotif_body_intro_deleted": "{{SITENAME}} میں صفحہ $1 کو بتاریخ $PAGEEDITDATE صارف $2 نے {{GENDER:$2|حذف کر دیا}} ہے، ملاحظہ فرمائیں $3 ۔",
"enotif_body_intro_created": "{{SITENAME}} میں صفحہ $1 کو بتاریخ $PAGEEDITDATE صارف $2 نے {{GENDER:$2|تخلیق کیا}}، موجودہ نسخہ دیکھنے کے لیے $3 ملاحظہ فرمائیں۔",
"enotif_body_intro_moved": "{{SITENAME}} میں صفحہ $1 کو بتاریخ $PAGEEDITDATE صارف $2 نے {{GENDER:$2|منتقل کر دیا}} ہے، موجودہ نسخہ دیکھنے کے لیے $3 ملاحظہ فرمائیں۔",
"enotif_body_intro_restored": "{{SITENAME}} میں صفحہ $1 کو بتاریخ $PAGEEDITDATE صارف $2 نے {{GENDER:$2|بحال کیا}}، موجودہ نسخہ دیکھنے کے لیے $3 ملاحظہ فرمائیں۔",
"resetpass-submit-loggedin": "טוישן פאסווארט",
"resetpass-submit-cancel": "אַנולירן",
"resetpass-wrong-oldpass": "אומגילטיג צײַטווײַליק אדער לויפֿיק פאַסווארט.\nאיר האט מעגלעך שוין געטוישט אייער פאַסווארט אדער געבעטן א נײַ צײַטווײַליק פאַסווארט.",
- "resetpass-recycled": "×\96ײַ×\98 ×\90×\96×\95×\99 ×\92×\95×\98 ש×\98×¢×\98×\9c ×\90×\99רע פ×\90ס×\95×\95×\90ר×\98 צ×\95 עפעס ×\90× ×\93ערש פ×\95× ×¢×\9d ×\9c×\95×\99פ×\99ק×\9f פ×\90ס×\95×\95×\90רט.",
+ "resetpass-recycled": "×\96×\99×\99×\98 ×\90Ö·×\96×\95×\99 ×\92×\95×\98, ×\91×\99×\99×\98 ×\90×\99×\99ער פּ×\90ַס×\95×\95×\90ָר×\98 ×\90×\95×\99×£ עפּעס ×\90Ö·× ×\93ערש פ×\95×\9f ×\9c×\95×\99פ×\99ק×\9f פּ×\90ַס×\95×\95×\90Ö¸רט.",
"resetpass-temp-emailed": "איר האט זיך ארי לאגירת מיט א פראוויזארישן קאד געשיקט דורכן ע־פאסט. כדי שליסן דאס ארײַנלאגירן, דארט איר שטעלן א נײַ פאסווארט דא.",
"resetpass-temp-password": "צײַטווייליק פאַסווארט:",
"resetpass-abort-generic": "פאסווארט ענדערונג איז מבוטל געווארן דורך א פארברייטערונג.",
"resetpass-expired": "אירע פאסווארט איז אויסגעגאנגען. זײַט אזוי גוט שטעלט א נײַ פאסווארט כדי ארײַנלאגירן.",
- "resetpass-expired-soft": "×\90ײַער פ×\90ס×\95×\95×\90ר×\98 ×\90×\99×\96 ×\90×\95×\99ס×\92×¢×\92×\90× ×\92×¢×\9f ×\90×\95×\9f ×\93×\90רף ×\95×\95ער×\9f צ×\95ר×\99ק×\92עש×\98×¢×\9c×\98. ×\96ײַ×\98 ×\90×\96×\95×\99 ×\92×\95×\98 ק×\9c×\95×\99×\91×\98 ×\90 × ×²Ö· פ×\90ס×\95×\95×\90ר×\98 ×\90צ×\99× ×\93, ×\90×\93ער ק×\9c×\99ק×\98 \"{{int:authprovider-resetpass-skip-label}}\" ×\9b×\93×\99 עס צ×\95ר×\99קש×\98×¢×\9c×\9f שפעטער.",
- "resetpass-validity-soft": "×\90ײַער פ×\90ס×\95×\95×\90ר×\98 ×\90×\99×\96 × ×\99ש×\98 ×\92×\99×\9c×\98×\99ק: $1\n\n×\96ײַ×\98 ×\90×\96×\95×\99 ×\92×\95×\98 ק×\9c×\95×\99×\91×\98 ×\90 × ×²Ö· פ×\90ס×\95×\95×\90ר×\98 ×\90צ×\99× ×\93, ×\90×\93ער ק×\9c×\99ק×\98 \"{{int:authprovider-resetpass-skip-label}}\" ×\9b×\93×\99 עס צ×\95ר×\99קש×\98×¢×\9c×\9f שפעטער.",
+ "resetpass-expired-soft": "×\90×\99×\99ער פּ×\90ַס×\95×\95×\90ָר×\98 ×\90×\99×\96 ×\90×\95×\99ס×\92×¢×\92×\90Ö·× ×\92×¢×\9f ×\90×\95×\9f ×\93×\90ַרף צ×\95ר×\99ק×\92עש×\98×¢×\9c×\98 ×\95×\95ער×\9f. ×\96×\99×\99×\98 ×\90Ö·×\96×\95×\99 ×\92×\95×\98, ק×\9c×\99×\99×\91×\98 ×\90Ö· × ×\99×\99 פּ×\90ַס×\95×\95×\90ָר×\98 ×\90ַצ×\99× ×\93, ×\90Ö¸×\93ער ק×\9c×\99ק×\98 \"{{int:authprovider-resetpass-skip-label}}\" ×\9b×\93×\99 צ×\95 ×\91×\99×\99×\98×\9f עס שפּעטער.",
+ "resetpass-validity-soft": "×\90×\99×\99ער פּ×\90ַס×\95×\95×\90ָר×\98 ×\90×\99×\96 × ×\99×\98 ×\92×\99×\9c×\98×\99ק: $1\n\n×\96×\99×\99×\98 ×\90Ö·×\96×\95×\99 ×\92×\95×\98 ק×\9c×\99×\99×\91×\98 ×\90Ö· × ×\99×\99 פּ×\90ַס×\95×\95×\90ָר×\98 ×\90ַצ×\99× ×\93, ×\90Ö¸×\93ער ק×\9c×\99ק×\98 \"{{int:authprovider-resetpass-skip-label}}\" ×\9b×\93×\99 צ×\95 ×\91×\99×\99×\98×\9f עס שפּעטער.",
"passwordreset": "צוריקשטעלן פאַסווארט",
"passwordreset-text-one": "דערגאַנצט די פאָרעם צוריקצושטעלן אײַער פאַסווארט.",
"passwordreset-text-many": "{{PLURAL:$1|דערגאַנצט איינע פֿון די פֿעלדער כדי צו באקומען א פראוויזאריש פאַסווארט דור כן פאסט.}}",
"rcfilters-savedqueries-defaultlabel": "אױפֿגעהיטענע פֿילטערס",
"rcfilters-savedqueries-rename": "ענדערן נאמען",
"rcfilters-savedqueries-setdefault": "האלטן ווי גרונט",
- "rcfilters-savedqueries-remove": "×\90ר×\90×¤× ×¢×\9e×¢ן",
+ "rcfilters-savedqueries-remove": "×\90×\95×\99ס×\9eעקן",
"rcfilters-savedqueries-new-name-label": "נאָמען",
"rcfilters-savedqueries-apply-label": "שאפן פילטער",
"rcfilters-savedqueries-cancel-label": "אַנולירן",
"rcfilters-savedqueries-rename": "重命名",
"rcfilters-savedqueries-setdefault": "设置为默认",
"rcfilters-savedqueries-unsetdefault": "移除为默认",
- "rcfilters-savedqueries-remove": "移除",
+ "rcfilters-savedqueries-remove": "删除",
"rcfilters-savedqueries-new-name-label": "名称",
"rcfilters-savedqueries-new-name-placeholder": "描述过滤器目的",
"rcfilters-savedqueries-apply-label": "创建过滤器",
"rcfilters-empty-filter": "没有应用的过滤器。所有贡献都已显示。",
"rcfilters-filterlist-title": "过滤器",
"rcfilters-filterlist-whatsthis": "这些是怎样工作的?",
- "rcfilters-filterlist-feedbacklink": "告诉我们您对这些(新)过滤工具有什么想法",
+ "rcfilters-filterlist-feedbacklink": "告诉我们您对这些过滤工具有什么想法",
"rcfilters-highlightbutton-title": "高亮结果",
"rcfilters-highlightmenu-title": "选择颜色",
"rcfilters-highlightmenu-help": "选择颜色来高亮该属性",
* @ingroup Maintenance
*/
+use MediaWiki\MediaWikiServices;
use Wikimedia\Rdbms\IDatabase;
require_once __DIR__ . '/Maintenance.php';
public static function fixLinksFromArticle( $id, $ns = false ) {
$page = WikiPage::newFromID( $id );
- LinkCache::singleton()->clear();
+ MediaWikiServices::getInstance()->getLinkCache()->clear();
if ( $page === null ) {
return;
# Copy file(s)
rsync --force ./node_modules/oojs/dist/oojs.jquery.js "$REPO_DIR/$TARGET_DIR"
-rsync --force ./node_modules/oojs/dist/AUTHORS.txt "$REPO_DIR/$TARGET_DIR"
-rsync --force ./node_modules/oojs/dist/LICENSE-MIT "$REPO_DIR/$TARGET_DIR"
-rsync --force ./node_modules/oojs/dist/README.md "$REPO_DIR/$TARGET_DIR"
+rsync --force ./node_modules/oojs/AUTHORS.txt "$REPO_DIR/$TARGET_DIR"
+rsync --force ./node_modules/oojs/LICENSE-MIT "$REPO_DIR/$TARGET_DIR"
+rsync --force ./node_modules/oojs/README.md "$REPO_DIR/$TARGET_DIR"
# Clean up temporary area
rm -rf "$NPM_DIR"
$ git checkout -B release -t origin/master
# Ensure tests pass
-$ npm install && npm test
+$ npm install-test
# Avoid using "npm version patch" because that creates
# both a commit and a tag, and we shouldn't tag until after
/*!
- * OOjs v2.2.0 optimised for jQuery
+ * OOjs v2.2.2 optimised for jQuery
* https://www.mediawiki.org/wiki/OOjs
*
* Copyright 2011-2018 OOjs Team and other contributors.
* Released under the MIT license
* https://oojs.mit-license.org
*
- * Date: 2018-04-03T19:45:13Z
+ * Date: 2018-06-14T20:13:14Z
*/
( function ( global ) {
'use strict';
+/* exported toString */
var
/**
* Namespace for all classes, static methods and static properties.
oo = {},
// Optimisation: Local reference to Object.prototype.hasOwnProperty
hasOwn = oo.hasOwnProperty,
- // Marking this as "exported" doesn't work when parserOptions.sourceType is module
- // eslint-disable-next-line no-unused-vars
toString = oo.toString;
/* Class Methods */
* e.g.
*
* $.fn.msg = mediaWiki.jqueryMsg.getPlugin( options );
- * var userlink = $( '<a>' ).click( function () { alert( "hello!!" ) } );
- * $( 'p#headline' ).msg( 'hello-user', userlink );
+ * var $userlink = $( '<a>' ).click( function () { alert( "hello!!" ) } );
+ * $( 'p#headline' ).msg( 'hello-user', $userlink );
*
* N.B. replacements are variadic arguments or an array in second parameter. In other words:
* somefunction( a, b, c, d )
* @return {jQuery}
*/
mw.Message.prototype.parseDom = ( function () {
- var reusableParent = $( '<div>' );
+ var $wrapper = $( '<div>' );
return function () {
- return reusableParent.msg( this.key, this.parameters ).contents().detach();
+ return $wrapper.msg( this.key, this.parameters ).contents().detach();
};
}() );
@rcfilters-spinner-size: 12px;
@rcfilters-head-min-height: 210px;
@rcfilters-head-margin-bottom: 20px;
-@rcfilters-wl-head-min-height: 300px;
+@rcfilters-wl-head-min-height: 270px;
// Corrections for the standard special page
.client-js {
} ),
new OO.ui.MenuOptionWidget( {
data: 'delete',
- icon: 'close',
+ icon: 'trash',
label: mw.msg( 'rcfilters-savedqueries-remove' )
} ),
new OO.ui.MenuOptionWidget( {
// <button class="mw-ui-button mw-ui-progressive">Submit</button>
//
// Styleguide 1.2.
-input[ type='number' ],
.mw-ui-input-inline {
display: inline-block;
width: auto;
( function () {
'use strict';
- var slice = Array.prototype.slice;
+ var slice = Array.prototype.slice,
+ mwLoaderTrack = mw.track,
+ trackCallbacks = $.Callbacks( 'memory' ),
+ trackHandlers = [];
/**
* Object constructor for messages.
mw.msg = function () {
return mw.message.apply( mw.message, arguments ).toString();
};
+
+ /**
+ * Track an analytic event.
+ *
+ * This method provides a generic means for MediaWiki JavaScript code to capture state
+ * information for analysis. Each logged event specifies a string topic name that describes
+ * the kind of event that it is. Topic names consist of dot-separated path components,
+ * arranged from most general to most specific. Each path component should have a clear and
+ * well-defined purpose.
+ *
+ * Data handlers are registered via `mw.trackSubscribe`, and receive the full set of
+ * events that match their subcription, including those that fired before the handler was
+ * bound.
+ *
+ * @param {string} topic Topic name
+ * @param {Object} [data] Data describing the event, encoded as an object
+ */
+ mw.track = function ( topic, data ) {
+ mwLoaderTrack( topic, data );
+ trackCallbacks.fire( mw.trackQueue );
+ };
+
+ /**
+ * Register a handler for subset of analytic events, specified by topic.
+ *
+ * Handlers will be called once for each tracked event, including any events that fired before the
+ * handler was registered; 'this' is set to a plain object with a 'timeStamp' property indicating
+ * the exact time at which the event fired, a string 'topic' property naming the event, and a
+ * 'data' property which is an object of event-specific data. The event topic and event data are
+ * also passed to the callback as the first and second arguments, respectively.
+ *
+ * @param {string} topic Handle events whose name starts with this string prefix
+ * @param {Function} callback Handler to call for each matching tracked event
+ * @param {string} callback.topic
+ * @param {Object} [callback.data]
+ */
+ mw.trackSubscribe = function ( topic, callback ) {
+ var seen = 0;
+ function handler( trackQueue ) {
+ var event;
+ for ( ; seen < trackQueue.length; seen++ ) {
+ event = trackQueue[ seen ];
+ if ( event.topic.indexOf( topic ) === 0 ) {
+ callback.call( event, event.topic, event.data );
+ }
+ }
+ }
+
+ trackHandlers.push( [ handler, callback ] );
+
+ trackCallbacks.add( handler );
+ };
+
+ /**
+ * Stop handling events for a particular handler
+ *
+ * @param {Function} callback
+ */
+ mw.trackUnsubscribe = function ( callback ) {
+ trackHandlers = trackHandlers.filter( function ( fns ) {
+ if ( fns[ 1 ] === callback ) {
+ trackCallbacks.remove( fns[ 0 ] );
+ // Ensure the tuple is removed to avoid holding on to closures
+ return false;
+ }
+ return true;
+ } );
+ };
+
+ // Fire events from before track() triggred fire()
+ trackCallbacks.fire( mw.trackQueue );
}() );
var mw, StringSet, log,
hasOwn = Object.prototype.hasOwnProperty,
slice = Array.prototype.slice,
- trackCallbacks = $.Callbacks( 'memory' ),
- trackHandlers = [],
trackQueue = [];
/**
);
}
+ /**
+ * Log a message to window.console, if possible.
+ *
+ * Useful to force logging of some errors that are otherwise hard to detect (i.e., this logs
+ * also in production mode). Gets console references in each invocation instead of caching the
+ * reference, so that debugging tools loaded later are supported (e.g. Firebug Lite in IE).
+ *
+ * @private
+ * @param {string} topic Stream name passed by mw.track
+ * @param {Object} data Data passed by mw.track
+ * @param {Error} [data.exception]
+ * @param {string} data.source Error source
+ * @param {string} [data.module] Name of module which caused the error
+ */
+ function logError( topic, data ) {
+ /* eslint-disable no-console */
+ var msg,
+ e = data.exception,
+ source = data.source,
+ module = data.module,
+ console = window.console;
+
+ if ( console && console.log ) {
+ msg = ( e ? 'Exception' : 'Error' ) + ' in ' + source;
+ if ( module ) {
+ msg += ' in module ' + module;
+ }
+ msg += ( e ? ':' : '.' );
+ console.log( msg );
+
+ // 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( e );
+ }
+ }
+ /* eslint-enable no-console */
+ }
+
/**
* Create an object that can be read from or written to via methods that allow
* interaction both with single and multiple properties at once.
}() ),
/**
- * Track an analytic event.
+ * List of all analytic events emitted so far.
*
- * This method provides a generic means for MediaWiki JavaScript code to capture state
- * information for analysis. Each logged event specifies a string topic name that describes
- * the kind of event that it is. Topic names consist of dot-separated path components,
- * arranged from most general to most specific. Each path component should have a clear and
- * well-defined purpose.
- *
- * Data handlers are registered via `mw.trackSubscribe`, and receive the full set of
- * events that match their subcription, including those that fired before the handler was
- * bound.
- *
- * @param {string} topic Topic name
- * @param {Object} [data] Data describing the event, encoded as an object
+ * @private
+ * @property {Array}
*/
+ trackQueue: trackQueue,
+
track: function ( topic, data ) {
trackQueue.push( { topic: topic, timeStamp: mw.now(), data: data } );
- trackCallbacks.fire( trackQueue );
+ // The base module extends this method to fire events here
},
/**
- * Register a handler for subset of analytic events, specified by topic.
+ * Track an early error event via mw.track and send it to the window console.
*
- * Handlers will be called once for each tracked event, including any events that fired before the
- * handler was registered; 'this' is set to a plain object with a 'timeStamp' property indicating
- * the exact time at which the event fired, a string 'topic' property naming the event, and a
- * 'data' property which is an object of event-specific data. The event topic and event data are
- * also passed to the callback as the first and second arguments, respectively.
- *
- * @param {string} topic Handle events whose name starts with this string prefix
- * @param {Function} callback Handler to call for each matching tracked event
- * @param {string} callback.topic
- * @param {Object} [callback.data]
- */
- trackSubscribe: function ( topic, callback ) {
- var seen = 0;
- function handler( trackQueue ) {
- var event;
- for ( ; seen < trackQueue.length; seen++ ) {
- event = trackQueue[ seen ];
- if ( event.topic.indexOf( topic ) === 0 ) {
- callback.call( event, event.topic, event.data );
- }
- }
- }
-
- trackHandlers.push( [ handler, callback ] );
-
- trackCallbacks.add( handler );
- },
-
- /**
- * Stop handling events for a particular handler
- *
- * @param {Function} callback
+ * @private
+ * @param {string} topic Topic name
+ * @param {Object} data Data describing the event, encoded as an object; see mw#logError
*/
- trackUnsubscribe: function ( callback ) {
- trackHandlers = trackHandlers.filter( function ( fns ) {
- if ( fns[ 1 ] === callback ) {
- trackCallbacks.remove( fns[ 0 ] );
- // Ensure the tuple is removed to avoid holding on to closures
- return false;
- }
- return true;
- } );
+ trackError: function ( topic, data ) {
+ mw.track( topic, data );
+ logError( topic, data );
},
// Expose Map constructor
} catch ( e ) {
// A user-defined callback raised an exception.
// Swallow it to protect our state machine!
- mw.track( 'resourceloader.exception', { exception: e, module: module, source: 'load-callback' } );
+ mw.trackError( 'resourceloader.exception', {
+ exception: e,
+ module: module,
+ source: 'load-callback'
+ } );
}
}
}
// This module is unknown or has unknown dependencies.
// Undo any incomplete resolutions made and keep going.
resolved = saved;
- mw.track( 'resourceloader.exception', {
+ mw.trackError( 'resourceloader.exception', {
exception: err,
source: 'resolve'
} );
// Use mw.track instead of mw.log because these errors are common in production mode
// (e.g. undefined variable), and mw.log is only enabled in debug mode.
registry[ module ].state = 'error';
- mw.track( 'resourceloader.exception', { exception: e, module: module, source: 'module-execute' } );
+ mw.trackError( 'resourceloader.exception', {
+ exception: e, module:
+ module, source: 'module-execute'
+ } );
handlePending( module );
}
};
// risks and clear everything in this cache.
mw.loader.store.clear();
- mw.track( 'resourceloader.exception', { exception: err, source: 'store-eval' } );
+ mw.trackError( 'resourceloader.exception', {
+ exception: err,
+ source: 'store-eval'
+ } );
// Re-add the failed ones that are still pending back to the batch
failed = sourceModules.filter( function ( module ) {
return registry[ module ].state === 'loading';
return;
}
} catch ( e ) {
- mw.track( 'resourceloader.exception', { exception: e, source: 'store-localstorage-init' } );
+ mw.trackError( 'resourceloader.exception', {
+ exception: e,
+ source: 'store-localstorage-init'
+ } );
}
if ( raw === undefined ) {
// This regex should never match under sane conditions.
if ( /^\s*\(/.test( args[ 1 ] ) ) {
args[ 1 ] = 'function' + args[ 1 ];
- mw.track( 'resourceloader.assert', { source: 'bug-T59567' } );
+ mw.trackError( 'resourceloader.assert', { source: 'bug-T59567' } );
}
} catch ( e ) {
- mw.track( 'resourceloader.exception', { exception: e, source: 'store-localstorage-json' } );
+ mw.trackError( 'resourceloader.exception', {
+ exception: e,
+ source: 'store-localstorage-json'
+ } );
return false;
}
data = JSON.stringify( mw.loader.store );
localStorage.setItem( key, data );
} catch ( e ) {
- mw.track( 'resourceloader.exception', { exception: e, source: 'store-localstorage-update' } );
+ mw.trackError( 'resourceloader.exception', {
+ exception: e,
+ source: 'store-localstorage-update'
+ } );
}
hasPendingWrite = false;
// @deprecated since 1.23 Use $ or jQuery instead
mw.log.deprecate( window, '$j', $, 'Use $ or jQuery instead.' );
- /**
- * Log a message to window.console, if possible.
- *
- * Useful to force logging of some errors that are otherwise hard to detect (i.e., this logs
- * also in production mode). Gets console references in each invocation instead of caching the
- * reference, so that debugging tools loaded later are supported (e.g. Firebug Lite in IE).
- *
- * @private
- * @param {string} topic Stream name passed by mw.track
- * @param {Object} data Data passed by mw.track
- * @param {Error} [data.exception]
- * @param {string} data.source Error source
- * @param {string} [data.module] Name of module which caused the error
- */
- function logError( topic, data ) {
- /* eslint-disable no-console */
- var msg,
- e = data.exception,
- source = data.source,
- module = data.module,
- console = window.console;
-
- if ( console && console.log ) {
- msg = ( e ? 'Exception' : 'Error' ) + ' in ' + source;
- if ( module ) {
- msg += ' in module ' + module;
- }
- msg += ( e ? ':' : '.' );
- console.log( msg );
-
- // 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( e );
- }
- }
- /* eslint-enable no-console */
- }
-
- // Subscribe to error streams
- mw.trackSubscribe( 'resourceloader.exception', logError );
- mw.trackSubscribe( 'resourceloader.assert', logError );
-
// Attach to window and globally alias
window.mw = window.mediaWiki = mw;
}( jQuery ) );
# tests/phpunit/includes/Storage
'MediaWiki\Tests\Storage\McrSchemaDetection' => "$testDir/phpunit/includes/Storage/McrSchemaDetection.php",
+ 'MediaWiki\Tests\Storage\McrSchemaOverride' => "$testDir/phpunit/includes/Storage/McrSchemaOverride.php",
+ 'MediaWiki\Tests\Storage\McrWriteBothSchemaOverride' => "$testDir/phpunit/includes/Storage/McrWriteBothSchemaOverride.php",
'MediaWiki\Tests\Storage\RevisionSlotsTest' => "$testDir/phpunit/includes/Storage/RevisionSlotsTest.php",
'MediaWiki\Tests\Storage\RevisionRecordTests' => "$testDir/phpunit/includes/Storage/RevisionRecordTests.php",
'MediaWiki\Tests\Storage\RevisionStoreDbTestBase' => "$testDir/phpunit/includes/Storage/RevisionStoreDbTestBase.php",
=> "$testDir/phpunit/mocks/session/DummySessionBackend.php",
'DummySessionProvider' => "$testDir/phpunit/mocks/session/DummySessionProvider.php",
'MockMessageLocalizer' => "$testDir/phpunit/mocks/MockMessageLocalizer.php",
+ 'MockCompletionSearchEngine' => "$testDir/phpunit/mocks/search/MockCompletionSearchEngine.php",
'MockSearchEngine' => "$testDir/phpunit/mocks/search/MockSearchEngine.php",
'MockSearchResultSet' => "$testDir/phpunit/mocks/search/MockSearchResultSet.php",
'MockSearchResult' => "$testDir/phpunit/mocks/search/MockSearchResult.php",
// Wipe some DB query result caches on setup and teardown
$reset = function () {
- LinkCache::singleton()->clear();
+ MediaWikiServices::getInstance()->getLinkCache()->clear();
// Clear the message cache
MessageCache::singleton()->clear();
return self::getTestUser( [ 'sysop', 'bureaucrat' ] );
}
+ /**
+ * Returns a WikiPage representing an existing page.
+ *
+ * @since 1.32
+ *
+ * @param Title|string|null $title
+ * @return WikiPage
+ * @throws MWException
+ */
+ protected function getExistingTestPage( $title = null ) {
+ $title = ( $title === null ) ? 'UTPage' : $title;
+ $title = is_string( $title ) ? Title::newFromText( $title ) : $title;
+ $page = WikiPage::factory( $title );
+
+ if ( !$page->exists() ) {
+ $user = self::getTestSysop()->getUser();
+ $page->doEditContent(
+ new WikitextContent( 'UTContent' ),
+ 'UTPageSummary',
+ EDIT_NEW | EDIT_SUPPRESS_RC,
+ false,
+ $user
+ );
+ }
+
+ return $page;
+ }
+
+ /**
+ * Returns a WikiPage representing a non-existing page.
+ *
+ * @since 1.32
+ *
+ * @param Title|string|null $title
+ * @return WikiPage
+ * @throws MWException
+ */
+ protected function getNonexistingTestPage( $title = null ) {
+ $title = ( $title === null ) ? 'UTPage-' . rand( 0, 100000 ) : $title;
+ $title = is_string( $title ) ? Title::newFromText( $title ) : $title;
+ $page = WikiPage::factory( $title );
+
+ if ( $page->exists() ) {
+ $page->doDeleteArticle( 'Testing' );
+ }
+
+ return $page;
+ }
+
/**
* Prepare service configuration for unit testing.
*
public function addDBData() {
}
- private function addCoreDBData() {
+ /**
+ * @since 1.32
+ */
+ protected function addCoreDBData() {
if ( $this->db->getType() == 'oracle' ) {
# Insert 0 user to prevent FK violations
# Anonymous user
/**
* @throws LogicException if the given database connection is not a set up to use
* mock tables.
+ *
+ * @since 1.31 this is no longer private.
*/
- private function ensureMockDatabaseConnection( IDatabase $db ) {
+ protected function ensureMockDatabaseConnection( IDatabase $db ) {
if ( $db->tablePrefix() !== $this->dbPrefix() ) {
throw new LogicException(
'Trying to delete mock tables, but table prefix does not indicate a mock database.'
if ( $tbl === 'page' ) {
// Forget about the pages since they don't
// exist in the DB.
- LinkCache::singleton()->clear();
+ MediaWikiServices::getInstance()->getLinkCache()->clear();
}
}
}
private function resetDB( $db, $tablesUsed ) {
if ( $db ) {
$userTables = [ 'user', 'user_groups', 'user_properties', 'actor' ];
- $pageTables = [ 'page', 'revision', 'ip_changes', 'revision_comment_temp',
- 'revision_actor_temp', 'comment', 'archive' ];
+ $pageTables = [
+ 'page', 'revision', 'ip_changes', 'revision_comment_temp', 'comment', 'archive',
+ 'revision_actor_temp', 'slots', 'content', 'content_models', 'slot_roles',
+ ];
$coreDBDataTables = array_merge( $userTables, $pageTables );
// If any of the user or page tables were marked as used, we should clear all of them.
if ( $tbl === 'page' ) {
// Forget about the pages since they don't
// exist in the DB.
- LinkCache::singleton()->clear();
+ MediaWikiServices::getInstance()->getLinkCache()->clear();
}
}
<?php
-use MediaWiki\MediaWikiServices;
/**
* @group Database
* @dataProvider provideLinkBeginHook
*/
public function testLinkBeginHook( $callback, $expected ) {
+ $this->hideDeprecated( 'LinkBegin hook (used in hook-LinkBegin-closure)' );
$this->setMwGlobals( [
'wgArticlePath' => '/wiki/$1',
'wgServer' => '//example.org',
* @dataProvider provideLinkEndHook
*/
public function testLinkEndHook( $callback, $expected ) {
+ $this->hideDeprecated( 'LinkEnd hook (used in hook-LinkEnd-closure)' );
$this->setMwGlobals( [
'wgArticlePath' => '/wiki/$1',
] );
$out = Linker::link( $title );
$this->assertEquals( $expected, $out );
}
-
- /**
- * @covers Linker::getLinkColour
- */
- public function testGetLinkColour() {
- $this->hideDeprecated( 'Linker::getLinkColour' );
- $linkCache = MediaWikiServices::getInstance()->getLinkCache();
- $foobarTitle = Title::makeTitle( NS_MAIN, 'FooBar' );
- $redirectTitle = Title::makeTitle( NS_MAIN, 'Redirect' );
- $userTitle = Title::makeTitle( NS_USER, 'Someuser' );
- $linkCache->addGoodLinkObj(
- 1, // id
- $foobarTitle,
- 10, // len
- 0 // redir
- );
- $linkCache->addGoodLinkObj(
- 2, // id
- $redirectTitle,
- 10, // len
- 1 // redir
- );
-
- $linkCache->addGoodLinkObj(
- 3, // id
- $userTitle,
- 10, // len
- 0 // redir
- );
-
- $this->assertEquals(
- '',
- Linker::getLinkColour( $foobarTitle, 0 )
- );
-
- $this->assertEquals(
- 'stub',
- Linker::getLinkColour( $foobarTitle, 20 )
- );
-
- $this->assertEquals(
- 'mw-redirect',
- Linker::getLinkColour( $redirectTitle, 0 )
- );
-
- $this->assertEquals(
- '',
- Linker::getLinkColour( $userTitle, 20 )
- );
- }
}
$context->getOutput()->getRedirect()
);
}
+
+ /**
+ * Test a post-send job can not set cookies (T191537).
+ */
+ public function testPostSendJobDoesNotSetCookie() {
+ // Prevent updates from running
+ $this->setMwGlobals( 'wgCommandLineMode', false );
+
+ $response = new WebResponse;
+
+ // A job that attempts to set a cookie
+ $jobHasRun = false;
+ DeferredUpdates::addCallableUpdate( function () use ( $response, &$jobHasRun ) {
+ $jobHasRun = true;
+ $response->setCookie( 'JobCookie', 'yes' );
+ $response->header( 'Foo: baz' );
+ } );
+
+ $hookWasRun = false;
+ $this->setTemporaryHook( 'WebResponseSetCookie', function () use ( &$hookWasRun ) {
+ $hookWasRun = true;
+ return true;
+ } );
+
+ $logger = new TestLogger();
+ $logger->setCollect( true );
+ $this->setLogger( 'cookie', $logger );
+ $this->setLogger( 'header', $logger );
+
+ $mw = new MediaWiki();
+ $mw->doPostOutputShutdown();
+ // restInPeace() might have been registered to a callback of
+ // register_postsend_function() and thus can not be triggered from
+ // PHPUnit.
+ if ( $jobHasRun === false ) {
+ $mw->restInPeace();
+ }
+
+ $this->assertTrue( $jobHasRun, 'post-send job has run' );
+ $this->assertFalse( $hookWasRun,
+ 'post-send job must not trigger WebResponseSetCookie hook' );
+ $this->assertEquals(
+ [
+ [ 'info', 'ignored post-send cookie {cookie}' ],
+ [ 'info', 'ignored post-send header {header}' ],
+ ],
+ $logger->getBuffer()
+ );
+ }
}
);
}
+ protected function addCoreDBData() {
+ // Blank out. This would fail with a modified schema, and we don't need it.
+ }
+
/**
* @return int
*/
[
'rev_id',
'rev_page',
- 'rev_text_id',
'rev_minor_edit',
'rev_deleted',
'rev_len',
[ [
strval( $rev->getId() ),
strval( $this->testPage->getId() ),
- strval( $textId ),
'0',
'0',
'13',
);
}
+ public function provideInsertOn_exceptionOnIncomplete() {
+ $content = new TextContent( '' );
+ $user = User::newFromName( 'Foo' );
+
+ yield 'no parent' => [
+ [
+ 'content' => $content,
+ 'comment' => 'test',
+ 'user' => $user,
+ ],
+ IncompleteRevisionException::class,
+ "rev_page field must not be 0!"
+ ];
+
+ yield 'no comment' => [
+ [
+ 'content' => $content,
+ 'page' => 7,
+ 'user' => $user,
+ ],
+ IncompleteRevisionException::class,
+ "comment must not be NULL!"
+ ];
+
+ yield 'no content' => [
+ [
+ 'comment' => 'test',
+ 'page' => 7,
+ 'user' => $user,
+ ],
+ IncompleteRevisionException::class,
+ "Uninitialized field: content_address" // XXX: message may change
+ ];
+ }
+
/**
+ * @dataProvider provideInsertOn_exceptionOnIncomplete
* @covers Revision::insertOn
*/
- public function testInsertOn_exceptionOnNoPage() {
+ public function testInsertOn_exceptionOnIncomplete( $array, $expException, $expMessage ) {
// If an ExternalStore is set don't use it.
$this->setMwGlobals( 'wgDefaultExternalStore', false );
- $this->setExpectedException(
- IncompleteRevisionException::class,
- "rev_page field must not be 0!"
- );
+ $this->setExpectedException( $expException, $expMessage );
$title = Title::newFromText( 'Nonexistant-' . __METHOD__ );
- $rev = new Revision( [], 0, $title );
+ $rev = new Revision( $array, 0, $title );
$rev->insertOn( wfGetDB( DB_MASTER ) );
}
$services->getService( '_SqlBlobStore' ),
$services->getMainWANObjectCache(),
$services->getCommentStore(),
+ $services->getContentModelStore(),
+ $services->getSlotRoleStore(),
+ $this->getMcrMigrationStage(),
$services->getActorMigration()
);
$rev = new Revision( [
'page' => $this->testPage->getId(),
'content_model' => $this->testPage->getContentModel(),
- 'text_id' => 123456789, // not in the test DB
+ 'id' => 123456789, // not in the test DB
] );
Wikimedia\suppressWarnings(); // bad text_id will trigger a warning.
$rev = $this->testPage->getRevision();
// Clear any previous cache for the revision during creation
- $key = $cache->makeGlobalKey( 'revision-row-1.29',
+ $key = $cache->makeGlobalKey( RevisionStore::ROW_CACHE_KEY,
$db->getDomainID(),
$rev->getPage(),
$rev->getId()
--- /dev/null
+<?php
+use MediaWiki\Tests\Storage\McrSchemaOverride;
+
+/**
+ * Tests Revision against the MCR DB schema after schema migration.
+ *
+ * @covers Revision
+ *
+ * @group Revision
+ * @group Storage
+ * @group ContentHandler
+ * @group Database
+ * @group medium
+ */
+class RevisionMcrDbTest extends RevisionDbTestBase {
+
+ use McrSchemaOverride;
+
+ public function setUp() {
+ parent::setUp();
+ }
+
+ protected function getContentHandlerUseDB() {
+ return true;
+ }
+
+}
--- /dev/null
+<?php
+use MediaWiki\Tests\Storage\McrWriteBothSchemaOverride;
+
+/**
+ * Tests Revision against the intermediate MCR DB schema for use during schema migration.
+ *
+ * @covers Revision
+ *
+ * @group Revision
+ * @group Storage
+ * @group ContentHandler
+ * @group Database
+ * @group medium
+ */
+class RevisionMcrWriteBothDbTest extends RevisionDbTestBase {
+
+ use McrWriteBothSchemaOverride;
+
+ protected function getContentHandlerUseDB() {
+ return true;
+ }
+
+}
*/
class RevisionTest extends MediaWikiTestCase {
+ public function setUp() {
+ parent::setUp();
+ $this->setMwGlobals( 'wgMultiContentRevisionSchemaMigrationStage', MIGRATION_OLD );
+ }
+
public function provideConstructFromArray() {
yield 'with text' => [
[
'content' => new WikitextContent( 'GOAT' ),
'text_id' => 'someid',
],
- new MWException( "Text already stored in external store (id someid), " .
- "can't serialize content object" )
+ new MWException( 'Text already stored in external store (id someid),' )
];
yield 'with bad content object (class)' => [
[ 'content' => new stdClass() ],
- new MWException( 'content field must contain a Content object.' )
+ new MWException( 'content field must contain a Content object' )
];
yield 'with bad content object (string)' => [
[ 'content' => 'ImAGoat' ],
- new MWException( 'content field must contain a Content object.' )
+ new MWException( 'content field must contain a Content object' )
];
yield 'bad row format' => [
'imastring, not a row',
$this->getBlobStore(),
$cache,
MediaWikiServices::getInstance()->getCommentStore(),
+ MediaWikiServices::getInstance()->getContentModelStore(),
+ MediaWikiServices::getInstance()->getSlotRoleStore(),
+ MIGRATION_OLD,
MediaWikiServices::getInstance()->getActorMigration()
);
return $blobStore;
$revisionStore = $this->getRevisionStore();
$revisionStore->setContentHandlerUseDB( $globals['wgContentHandlerUseDB'] );
$this->setService( 'RevisionStore', $revisionStore );
- $this->assertEquals(
- $expected,
- Revision::getArchiveQueryInfo()
+
+ $queryInfo = Revision::getArchiveQueryInfo();
+
+ $this->assertArrayEqualsIgnoringIntKeyOrder(
+ $expected['tables'],
+ $queryInfo['tables']
);
+ $this->assertArrayEqualsIgnoringIntKeyOrder(
+ $expected['fields'],
+ $queryInfo['fields']
+ );
+ $this->assertArrayEqualsIgnoringIntKeyOrder(
+ $expected['joins'],
+ $queryInfo['joins']
+ );
+ }
+
+ /**
+ * Assert that the two arrays passed are equal, ignoring the order of the values that integer
+ * keys.
+ *
+ * Note: Failures of this assertion can be slightly confusing as the arrays are actually
+ * split into a string key array and an int key array before assertions occur.
+ *
+ * @param array $expected
+ * @param array $actual
+ */
+ private function assertArrayEqualsIgnoringIntKeyOrder( array $expected, array $actual ) {
+ $this->objectAssociativeSort( $expected );
+ $this->objectAssociativeSort( $actual );
+
+ // Separate the int key values from the string key values so that assertion failures are
+ // easier to understand.
+ $expectedIntKeyValues = [];
+ $actualIntKeyValues = [];
+
+ // Remove all int keys and re add them at the end after sorting by value
+ // This will result in all int keys being in the same order with same ints at the end of
+ // the array
+ foreach ( $expected as $key => $value ) {
+ if ( is_int( $key ) ) {
+ unset( $expected[$key] );
+ $expectedIntKeyValues[] = $value;
+ }
+ }
+ foreach ( $actual as $key => $value ) {
+ if ( is_int( $key ) ) {
+ unset( $actual[$key] );
+ $actualIntKeyValues[] = $value;
+ }
+ }
+
+ $this->assertArrayEquals( $expected, $actual, false, true );
+ $this->assertArrayEquals( $expectedIntKeyValues, $actualIntKeyValues, false, true );
}
public function provideGetQueryInfo() {
$revisionStore->setContentHandlerUseDB( $globals['wgContentHandlerUseDB'] );
$this->setService( 'RevisionStore', $revisionStore );
- $this->assertEquals(
- $expected,
- Revision::getQueryInfo( $options )
+ $queryInfo = Revision::getQueryInfo( $options );
+
+ $this->assertArrayEqualsIgnoringIntKeyOrder(
+ $expected['tables'],
+ $queryInfo['tables']
+ );
+ $this->assertArrayEqualsIgnoringIntKeyOrder(
+ $expected['fields'],
+ $queryInfo['fields']
+ );
+ $this->assertArrayEqualsIgnoringIntKeyOrder(
+ $expected['joins'],
+ $queryInfo['joins']
);
}
--- /dev/null
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use CommentStoreComment;
+use Content;
+use LinksUpdate;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Storage\DerivedPageDataUpdater;
+use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\MutableRevisionSlots;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\RevisionSlotsUpdate;
+use MediaWiki\Storage\SlotRecord;
+use MediaWikiTestCase;
+use Title;
+use User;
+use Wikimedia\TestingAccessWrapper;
+use WikiPage;
+use WikitextContent;
+
+/**
+ * @group Database
+ *
+ * @covers MediaWiki\Storage\DerivedPageDataUpdater
+ */
+class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
+
+ /**
+ * @param string $title
+ *
+ * @return Title
+ */
+ private function getTitle( $title ) {
+ return Title::makeTitleSafe( $this->getDefaultWikitextNS(), $title );
+ }
+
+ /**
+ * @param string|Title $title
+ *
+ * @return WikiPage
+ */
+ private function getPage( $title ) {
+ $title = ( $title instanceof Title ) ? $title : $this->getTitle( $title );
+
+ return WikiPage::factory( $title );
+ }
+
+ /**
+ * @param string|Title|WikiPage $page
+ *
+ * @return DerivedPageDataUpdater
+ */
+ private function getDerivedPageDataUpdater( $page, RevisionRecord $rec = null ) {
+ if ( is_string( $page ) || $page instanceof Title ) {
+ $page = $this->getPage( $page );
+ }
+
+ $page = TestingAccessWrapper::newFromObject( $page );
+ return $page->getDerivedDataUpdater( null, $rec );
+ }
+
+ /**
+ * Creates a revision in the database.
+ *
+ * @param WikiPage $page
+ * @param $summary
+ * @param null|string|Content $content
+ *
+ * @return RevisionRecord|null
+ */
+ private function createRevision( WikiPage $page, $summary, $content = null ) {
+ $user = $this->getTestUser()->getUser();
+ $comment = CommentStoreComment::newUnsavedComment( $summary );
+
+ if ( !$content instanceof Content ) {
+ $content = new WikitextContent( $content === null ? $summary : $content );
+ }
+
+ $this->getDerivedPageDataUpdater( $page ); // flush cached instance before.
+
+ $updater = $page->newPageUpdater( $user );
+ $updater->setContent( 'main', $content );
+ $rev = $updater->saveRevision( $comment );
+
+ $this->getDerivedPageDataUpdater( $page ); // flush cached instance after.
+ return $rev;
+ }
+
+ // TODO: test setArticleCountMethod() and isCountable();
+ // TODO: test isRedirect() and wasRedirect()
+
+ /**
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOptions()
+ */
+ public function testGetCanonicalParserOptions() {
+ global $wgContLang;
+
+ $user = $this->getTestUser()->getUser();
+ $page = $this->getPage( __METHOD__ );
+
+ $parentRev = $this->createRevision( $page, 'first' );
+
+ $mainContent = new WikitextContent( 'Lorem ipsum' );
+
+ $update = new RevisionSlotsUpdate();
+ $update->modifyContent( 'main', $mainContent );
+ $updater = $this->getDerivedPageDataUpdater( $page );
+ $updater->prepareContent( $user, $update, false );
+
+ $options1 = $updater->getCanonicalParserOptions();
+ $this->assertSame( $wgContLang, $options1->getUserLangObj() );
+
+ $speculativeId = call_user_func( $options1->getSpeculativeRevIdCallback(), $page->getTitle() );
+ $this->assertSame( $parentRev->getId() + 1, $speculativeId );
+
+ $rev = $this->makeRevision(
+ $page->getTitle(),
+ $update,
+ $user,
+ $parentRev->getId() + 7,
+ $parentRev->getId()
+ );
+ $updater->prepareUpdate( $rev );
+
+ $options2 = $updater->getCanonicalParserOptions();
+ $this->assertNotSame( $options1, $options2 );
+
+ $currentRev = call_user_func( $options2->getCurrentRevisionCallback(), $page->getTitle() );
+ $this->assertSame( $rev->getId(), $currentRev->getId() );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::grabCurrentRevision()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::pageExisted()
+ */
+ public function testGrabCurrentRevision() {
+ $page = $this->getPage( __METHOD__ );
+
+ $updater0 = $this->getDerivedPageDataUpdater( $page );
+ $this->assertNull( $updater0->grabCurrentRevision() );
+ $this->assertFalse( $updater0->pageExisted() );
+
+ $rev1 = $this->createRevision( $page, 'first' );
+ $updater1 = $this->getDerivedPageDataUpdater( $page );
+ $this->assertSame( $rev1->getId(), $updater1->grabCurrentRevision()->getId() );
+ $this->assertFalse( $updater0->pageExisted() );
+ $this->assertTrue( $updater1->pageExisted() );
+
+ $rev2 = $this->createRevision( $page, 'second' );
+ $updater2 = $this->getDerivedPageDataUpdater( $page );
+ $this->assertSame( $rev1->getId(), $updater1->grabCurrentRevision()->getId() );
+ $this->assertSame( $rev2->getId(), $updater2->grabCurrentRevision()->getId() );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareContent()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isContentPrepared()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::pageExisted()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCreation()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isChange()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlots()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawSlot()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawContent()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getModifiedSlotRoles()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getTouchedSlotRoles()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlotParserOutput()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOutput()
+ */
+ public function testPrepareContent() {
+ $user = $this->getTestUser()->getUser();
+ $updater = $this->getDerivedPageDataUpdater( __METHOD__ );
+
+ $this->assertFalse( $updater->isContentPrepared() );
+
+ // TODO: test stash
+ // TODO: MCR: Test multiple slots. Test slot removal.
+ $mainContent = new WikitextContent( 'first [[main]] ~~~' );
+ $auxContent = new WikitextContent( 'inherited ~~~ content' );
+ $auxSlot = SlotRecord::newSaved(
+ 10, 7, 'tt:7',
+ SlotRecord::newUnsaved( 'aux', $auxContent )
+ );
+
+ $update = new RevisionSlotsUpdate();
+ $update->modifyContent( 'main', $mainContent );
+ $update->modifySlot( SlotRecord::newInherited( $auxSlot ) );
+ // TODO: MCR: test removing slots!
+
+ $updater->prepareContent( $user, $update, false );
+
+ // second be ok to call again with the same params
+ $updater->prepareContent( $user, $update, false );
+
+ $this->assertNull( $updater->grabCurrentRevision() );
+ $this->assertTrue( $updater->isContentPrepared() );
+ $this->assertFalse( $updater->isUpdatePrepared() );
+ $this->assertFalse( $updater->pageExisted() );
+ $this->assertTrue( $updater->isCreation() );
+ $this->assertTrue( $updater->isChange() );
+ $this->assertTrue( $updater->isContentPublic() );
+
+ $this->assertEquals( [ 'main', 'aux' ], $updater->getSlots()->getSlotRoles() );
+ $this->assertEquals( [ 'main' ], array_keys( $updater->getSlots()->getOriginalSlots() ) );
+ $this->assertEquals( [ 'aux' ], array_keys( $updater->getSlots()->getInheritedSlots() ) );
+ $this->assertEquals( [ 'main', 'aux' ], $updater->getModifiedSlotRoles() );
+ $this->assertEquals( [ 'main', 'aux' ], $updater->getTouchedSlotRoles() );
+
+ $mainSlot = $updater->getRawSlot( 'main' );
+ $this->assertInstanceOf( SlotRecord::class, $mainSlot );
+ $this->assertNotContains( '~~~', $mainSlot->getContent()->serialize(), 'PST should apply.' );
+ $this->assertContains( $user->getName(), $mainSlot->getContent()->serialize() );
+
+ $auxSlot = $updater->getRawSlot( 'aux' );
+ $this->assertInstanceOf( SlotRecord::class, $auxSlot );
+ $this->assertContains( '~~~', $auxSlot->getContent()->serialize(), 'No PST should apply.' );
+
+ $mainOutput = $updater->getCanonicalParserOutput();
+ $this->assertContains( 'first', $mainOutput->getText() );
+ $this->assertContains( '<a ', $mainOutput->getText() );
+ $this->assertNotEmpty( $mainOutput->getLinks() );
+
+ $canonicalOutput = $updater->getCanonicalParserOutput();
+ $this->assertContains( 'first', $canonicalOutput->getText() );
+ $this->assertContains( '<a ', $canonicalOutput->getText() );
+ $this->assertNotEmpty( $canonicalOutput->getLinks() );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareContent()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::pageExisted()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCreation()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isChange()
+ */
+ public function testPrepareContentInherit() {
+ $user = $this->getTestUser()->getUser();
+ $page = $this->getPage( __METHOD__ );
+
+ $mainContent1 = new WikitextContent( 'first [[main]] ~~~' );
+ $mainContent2 = new WikitextContent( 'second' );
+
+ $this->createRevision( $page, 'first', $mainContent1 );
+
+ $update = new RevisionSlotsUpdate();
+ $update->modifyContent( 'main', $mainContent1 );
+ $updater1 = $this->getDerivedPageDataUpdater( $page );
+ $updater1->prepareContent( $user, $update, false );
+
+ $this->assertNotNull( $updater1->grabCurrentRevision() );
+ $this->assertTrue( $updater1->isContentPrepared() );
+ $this->assertTrue( $updater1->pageExisted() );
+ $this->assertFalse( $updater1->isCreation() );
+ $this->assertFalse( $updater1->isChange() );
+
+ // TODO: MCR: test inheritance from parent
+ $update = new RevisionSlotsUpdate();
+ $update->modifyContent( 'main', $mainContent2 );
+ $updater2 = $this->getDerivedPageDataUpdater( $page );
+ $updater2->prepareContent( $user, $update, false );
+
+ $this->assertFalse( $updater2->isCreation() );
+ $this->assertTrue( $updater2->isChange() );
+ }
+
+ // TODO: test failure of prepareContent() when called again...
+ // - with different user
+ // - with different update
+ // - after calling prepareUpdate()
+
+ /**
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareUpdate()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isUpdatePrepared()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCreation()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlots()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawSlot()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawContent()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getModifiedSlotRoles()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getTouchedSlotRoles()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlotParserOutput()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOutput()
+ */
+ public function testPrepareUpdate() {
+ $page = $this->getPage( __METHOD__ );
+
+ $mainContent1 = new WikitextContent( 'first [[main]] ~~~' );
+ $rev1 = $this->createRevision( $page, 'first', $mainContent1 );
+ $updater1 = $this->getDerivedPageDataUpdater( $page, $rev1 );
+
+ $options = []; // TODO: test *all* the options...
+ $updater1->prepareUpdate( $rev1, $options );
+
+ $this->assertTrue( $updater1->isUpdatePrepared() );
+ $this->assertTrue( $updater1->isContentPrepared() );
+ $this->assertTrue( $updater1->isCreation() );
+ $this->assertTrue( $updater1->isChange() );
+ $this->assertTrue( $updater1->isContentPublic() );
+
+ $this->assertEquals( [ 'main' ], $updater1->getSlots()->getSlotRoles() );
+ $this->assertEquals( [ 'main' ], array_keys( $updater1->getSlots()->getOriginalSlots() ) );
+ $this->assertEquals( [], array_keys( $updater1->getSlots()->getInheritedSlots() ) );
+ $this->assertEquals( [ 'main' ], $updater1->getModifiedSlotRoles() );
+ $this->assertEquals( [ 'main' ], $updater1->getTouchedSlotRoles() );
+
+ // TODO: MCR: test multiple slots, test slot removal!
+
+ $this->assertInstanceOf( SlotRecord::class, $updater1->getRawSlot( 'main' ) );
+ $this->assertNotContains( '~~~~', $updater1->getRawContent( 'main' )->serialize() );
+
+ $mainOutput = $updater1->getCanonicalParserOutput();
+ $this->assertContains( 'first', $mainOutput->getText() );
+ $this->assertContains( '<a ', $mainOutput->getText() );
+ $this->assertNotEmpty( $mainOutput->getLinks() );
+
+ $canonicalOutput = $updater1->getCanonicalParserOutput();
+ $this->assertContains( 'first', $canonicalOutput->getText() );
+ $this->assertContains( '<a ', $canonicalOutput->getText() );
+ $this->assertNotEmpty( $canonicalOutput->getLinks() );
+
+ $mainContent2 = new WikitextContent( 'second' );
+ $rev2 = $this->createRevision( $page, 'second', $mainContent2 );
+ $updater2 = $this->getDerivedPageDataUpdater( $page, $rev2 );
+
+ $options = []; // TODO: test *all* the options...
+ $updater2->prepareUpdate( $rev2, $options );
+
+ $this->assertFalse( $updater2->isCreation() );
+ $this->assertTrue( $updater2->isChange() );
+
+ $canonicalOutput = $updater2->getCanonicalParserOutput();
+ $this->assertContains( 'second', $canonicalOutput->getText() );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareUpdate()
+ */
+ public function testPrepareUpdateReusesParserOutput() {
+ $user = $this->getTestUser()->getUser();
+ $page = $this->getPage( __METHOD__ );
+
+ $mainContent1 = new WikitextContent( 'first [[main]] ~~~' );
+
+ $update = new RevisionSlotsUpdate();
+ $update->modifyContent( 'main', $mainContent1 );
+ $updater = $this->getDerivedPageDataUpdater( $page );
+ $updater->prepareContent( $user, $update, false );
+
+ $mainOutput = $updater->getSlotParserOutput( 'main' );
+ $canonicalOutput = $updater->getCanonicalParserOutput();
+
+ $rev = $this->createRevision( $page, 'first', $mainContent1 );
+
+ $options = []; // TODO: test *all* the options...
+ $updater->prepareUpdate( $rev, $options );
+
+ $this->assertTrue( $updater->isUpdatePrepared() );
+ $this->assertTrue( $updater->isContentPrepared() );
+
+ $this->assertSame( $mainOutput, $updater->getSlotParserOutput( 'main' ) );
+ $this->assertSame( $canonicalOutput, $updater->getCanonicalParserOutput() );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareUpdate()
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlotParserOutput()
+ */
+ public function testPrepareUpdateOutputReset() {
+ $user = $this->getTestUser()->getUser();
+ $page = $this->getPage( __METHOD__ );
+
+ $mainContent1 = new WikitextContent( 'first --{{REVISIONID}}--' );
+
+ $update = new RevisionSlotsUpdate();
+ $update->modifyContent( 'main', $mainContent1 );
+ $updater = $this->getDerivedPageDataUpdater( $page );
+ $updater->prepareContent( $user, $update, false );
+
+ $mainOutput = $updater->getSlotParserOutput( 'main' );
+ $canonicalOutput = $updater->getCanonicalParserOutput();
+
+ // prevent optimization on matching speculative ID
+ $mainOutput->setSpeculativeRevIdUsed( 0 );
+ $canonicalOutput->setSpeculativeRevIdUsed( 0 );
+
+ $rev = $this->createRevision( $page, 'first', $mainContent1 );
+
+ $options = []; // TODO: test *all* the options...
+ $updater->prepareUpdate( $rev, $options );
+
+ $this->assertTrue( $updater->isUpdatePrepared() );
+ $this->assertTrue( $updater->isContentPrepared() );
+
+ // ParserOutput objects should have been flushed.
+ $this->assertNotSame( $mainOutput, $updater->getSlotParserOutput( 'main' ) );
+ $this->assertNotSame( $canonicalOutput, $updater->getCanonicalParserOutput() );
+
+ $html = $updater->getCanonicalParserOutput()->getText();
+ $this->assertContains( '--' . $rev->getId() . '--', $html );
+
+ // TODO: MCR: ensure that when the main slot uses {{REVISIONID}} but another slot is
+ // updated, the main slot is still re-rendered!
+ }
+
+ // TODO: test failure of prepareUpdate() when called again with a different revision
+ // TODO: test failure of prepareUpdate() on inconsistency with prepareContent.
+
+ /**
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getPreparedEdit()
+ */
+ public function testGetPreparedEditAfterPrepareContent() {
+ $user = $this->getTestUser()->getUser();
+
+ $mainContent = new WikitextContent( 'first [[main]] ~~~' );
+ $update = new RevisionSlotsUpdate();
+ $update->modifyContent( 'main', $mainContent );
+
+ $updater = $this->getDerivedPageDataUpdater( __METHOD__ );
+ $updater->prepareContent( $user, $update, false );
+
+ $canonicalOutput = $updater->getCanonicalParserOutput();
+
+ $preparedEdit = $updater->getPreparedEdit();
+ $this->assertSame( $canonicalOutput->getCacheTime(), $preparedEdit->timestamp );
+ $this->assertSame( $canonicalOutput, $preparedEdit->output );
+ $this->assertSame( $mainContent, $preparedEdit->newContent );
+ $this->assertSame( $updater->getRawContent( 'main' ), $preparedEdit->pstContent );
+ $this->assertSame( $updater->getCanonicalParserOptions(), $preparedEdit->popts );
+ $this->assertSame( null, $preparedEdit->revid );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getPreparedEdit()
+ */
+ public function testGetPreparedEditAfterPrepareUpdate() {
+ $page = $this->getPage( __METHOD__ );
+
+ $mainContent = new WikitextContent( 'first [[main]] ~~~' );
+ $update = new MutableRevisionSlots();
+ $update->setContent( 'main', $mainContent );
+
+ $rev = $this->createRevision( $page, __METHOD__ );
+
+ $updater = $this->getDerivedPageDataUpdater( $page );
+ $updater->prepareUpdate( $rev );
+
+ $canonicalOutput = $updater->getCanonicalParserOutput();
+
+ $preparedEdit = $updater->getPreparedEdit();
+ $this->assertSame( $canonicalOutput->getCacheTime(), $preparedEdit->timestamp );
+ $this->assertSame( $canonicalOutput, $preparedEdit->output );
+ $this->assertSame( $updater->getRawContent( 'main' ), $preparedEdit->pstContent );
+ $this->assertSame( $updater->getCanonicalParserOptions(), $preparedEdit->popts );
+ $this->assertSame( $rev->getId(), $preparedEdit->revid );
+ }
+
+ public function testGetSecondaryDataUpdatesAfterPrepareContent() {
+ $user = $this->getTestUser()->getUser();
+ $page = $this->getPage( __METHOD__ );
+ $this->createRevision( $page, __METHOD__ );
+
+ $mainContent1 = new WikitextContent( 'first' );
+
+ $update = new RevisionSlotsUpdate();
+ $update->modifyContent( 'main', $mainContent1 );
+ $updater = $this->getDerivedPageDataUpdater( $page );
+ $updater->prepareContent( $user, $update, false );
+
+ $dataUpdates = $updater->getSecondaryDataUpdates();
+
+ // TODO: MCR: assert updates from all slots!
+ $this->assertNotEmpty( $dataUpdates );
+
+ $linksUpdates = array_filter( $dataUpdates, function ( $du ) {
+ return $du instanceof LinksUpdate;
+ } );
+ $this->assertCount( 1, $linksUpdates );
+ }
+
+ /**
+ * Creates a dummy revision object without touching the database.
+ *
+ * @param Title $title
+ * @param RevisionSlotsUpdate $update
+ * @param User $user
+ * @param string $comment
+ * @param int $id
+ * @param int $parentId
+ *
+ * @return MutableRevisionRecord
+ */
+ private function makeRevision(
+ Title $title,
+ RevisionSlotsUpdate $update,
+ User $user,
+ $comment,
+ $id,
+ $parentId = 0
+ ) {
+ $rev = new MutableRevisionRecord( $title );
+
+ $rev->applyUpdate( $update );
+ $rev->setUser( $user );
+ $rev->setComment( CommentStoreComment::newUnsavedComment( $comment ) );
+ $rev->setId( $id );
+ $rev->setPageId( $title->getArticleID() );
+ $rev->setParentId( $parentId );
+
+ return $rev;
+ }
+
+ public function provideIsReusableFor() {
+ $title = Title::makeTitleSafe( NS_MAIN, __METHOD__ );
+
+ $user1 = User::newFromName( 'Alice' );
+ $user2 = User::newFromName( 'Bob' );
+
+ $content1 = new WikitextContent( 'one' );
+ $content2 = new WikitextContent( 'two' );
+
+ $update1 = new RevisionSlotsUpdate();
+ $update1->modifyContent( 'main', $content1 );
+
+ $update1b = new RevisionSlotsUpdate();
+ $update1b->modifyContent( 'xyz', $content1 );
+
+ $update2 = new RevisionSlotsUpdate();
+ $update2->modifyContent( 'main', $content2 );
+
+ $rev1 = $this->makeRevision( $title, $update1, $user1, 'rev1', 11 );
+ $rev1b = $this->makeRevision( $title, $update1b, $user1, 'rev1', 11 );
+
+ $rev2 = $this->makeRevision( $title, $update2, $user1, 'rev2', 12 );
+ $rev2x = $this->makeRevision( $title, $update2, $user2, 'rev2', 12 );
+ $rev2y = $this->makeRevision( $title, $update2, $user1, 'rev2', 122 );
+
+ yield 'any' => [
+ '$prepUser' => null,
+ '$prepRevision' => null,
+ '$prepUpdate' => null,
+ '$forUser' => null,
+ '$forRevision' => null,
+ '$forUpdate' => null,
+ '$forParent' => null,
+ '$isReusable' => true,
+ ];
+ yield 'for any' => [
+ '$prepUser' => $user1,
+ '$prepRevision' => $rev1,
+ '$prepUpdate' => $update1,
+ '$forUser' => null,
+ '$forRevision' => null,
+ '$forUpdate' => null,
+ '$forParent' => null,
+ '$isReusable' => true,
+ ];
+ yield 'unprepared' => [
+ '$prepUser' => null,
+ '$prepRevision' => null,
+ '$prepUpdate' => null,
+ '$forUser' => $user1,
+ '$forRevision' => $rev1,
+ '$forUpdate' => $update1,
+ '$forParent' => 0,
+ '$isReusable' => true,
+ ];
+ yield 'match prepareContent' => [
+ '$prepUser' => $user1,
+ '$prepRevision' => null,
+ '$prepUpdate' => $update1,
+ '$forUser' => $user1,
+ '$forRevision' => null,
+ '$forUpdate' => $update1,
+ '$forParent' => 0,
+ '$isReusable' => true,
+ ];
+ yield 'match prepareUpdate' => [
+ '$prepUser' => null,
+ '$prepRevision' => $rev1,
+ '$prepUpdate' => null,
+ '$forUser' => $user1,
+ '$forRevision' => $rev1,
+ '$forUpdate' => null,
+ '$forParent' => 0,
+ '$isReusable' => true,
+ ];
+ yield 'match all' => [
+ '$prepUser' => $user1,
+ '$prepRevision' => $rev1,
+ '$prepUpdate' => $update1,
+ '$forUser' => $user1,
+ '$forRevision' => $rev1,
+ '$forUpdate' => $update1,
+ '$forParent' => 0,
+ '$isReusable' => true,
+ ];
+ yield 'mismatch prepareContent update' => [
+ '$prepUser' => $user1,
+ '$prepRevision' => null,
+ '$prepUpdate' => $update1,
+ '$forUser' => $user1,
+ '$forRevision' => null,
+ '$forUpdate' => $update1b,
+ '$forParent' => 0,
+ '$isReusable' => false,
+ ];
+ yield 'mismatch prepareContent user' => [
+ '$prepUser' => $user1,
+ '$prepRevision' => null,
+ '$prepUpdate' => $update1,
+ '$forUser' => $user2,
+ '$forRevision' => null,
+ '$forUpdate' => $update1,
+ '$forParent' => 0,
+ '$isReusable' => false,
+ ];
+ yield 'mismatch prepareContent parent' => [
+ '$prepUser' => $user1,
+ '$prepRevision' => null,
+ '$prepUpdate' => $update1,
+ '$forUser' => $user1,
+ '$forRevision' => null,
+ '$forUpdate' => $update1,
+ '$forParent' => 7,
+ '$isReusable' => false,
+ ];
+ yield 'mismatch prepareUpdate revision update' => [
+ '$prepUser' => null,
+ '$prepRevision' => $rev1,
+ '$prepUpdate' => null,
+ '$forUser' => null,
+ '$forRevision' => $rev1b,
+ '$forUpdate' => null,
+ '$forParent' => 0,
+ '$isReusable' => false,
+ ];
+ yield 'mismatch prepareUpdate revision user' => [
+ '$prepUser' => null,
+ '$prepRevision' => $rev2,
+ '$prepUpdate' => null,
+ '$forUser' => null,
+ '$forRevision' => $rev2x,
+ '$forUpdate' => null,
+ '$forParent' => 0,
+ '$isReusable' => false,
+ ];
+ yield 'mismatch prepareUpdate revision id' => [
+ '$prepUser' => null,
+ '$prepRevision' => $rev2,
+ '$prepUpdate' => null,
+ '$forUser' => null,
+ '$forRevision' => $rev2y,
+ '$forUpdate' => null,
+ '$forParent' => 0,
+ '$isReusable' => false,
+ ];
+ }
+
+ /**
+ * @dataProvider provideIsReusableFor
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isReusableFor()
+ *
+ * @param User|null $prepUser
+ * @param RevisionRecord|null $prepRevision
+ * @param RevisionSlotsUpdate|null $prepUpdate
+ * @param User|null $forUser
+ * @param RevisionRecord|null $forRevision
+ * @param RevisionSlotsUpdate|null $forUpdate
+ * @param int|null $forParent
+ * @param bool $isReusable
+ */
+ public function testIsReusableFor(
+ User $prepUser = null,
+ RevisionRecord $prepRevision = null,
+ RevisionSlotsUpdate $prepUpdate = null,
+ User $forUser = null,
+ RevisionRecord $forRevision = null,
+ RevisionSlotsUpdate $forUpdate = null,
+ $forParent = null,
+ $isReusable = null
+ ) {
+ $updater = $this->getDerivedPageDataUpdater( __METHOD__ );
+
+ if ( $prepUpdate ) {
+ $updater->prepareContent( $prepUser, $prepUpdate, false );
+ }
+
+ if ( $prepRevision ) {
+ $updater->prepareUpdate( $prepRevision );
+ }
+
+ $this->assertSame(
+ $isReusable,
+ $updater->isReusableFor( $forUser, $forRevision, $forUpdate, $forParent )
+ );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doUpdates()
+ */
+ public function testDoUpdates() {
+ $page = $this->getPage( __METHOD__ );
+
+ $mainContent1 = new WikitextContent( 'first [[main]]' );
+ $rev = $this->createRevision( $page, 'first', $mainContent1 );
+ $pageId = $page->getId();
+ $oldStats = $this->db->selectRow( 'site_stats', '*', '1=1' );
+
+ $updater = $this->getDerivedPageDataUpdater( $page, $rev );
+ $updater->setArticleCountMethod( 'link' );
+
+ $options = []; // TODO: test *all* the options...
+ $updater->prepareUpdate( $rev, $options );
+
+ $updater->doUpdates();
+
+ // links table update
+ $linkCount = $this->db->selectRowCount( 'pagelinks', '*', [ 'pl_from' => $pageId ] );
+ $this->assertSame( 1, $linkCount );
+
+ $pageLinksRow = $this->db->selectRow( 'pagelinks', '*', [ 'pl_from' => $pageId ] );
+ $this->assertInternalType( 'object', $pageLinksRow );
+ $this->assertSame( 'Main', $pageLinksRow->pl_title );
+
+ // parser cache update
+ $pcache = MediaWikiServices::getInstance()->getParserCache();
+ $cached = $pcache->get( $page, $updater->getCanonicalParserOptions() );
+ $this->assertInternalType( 'object', $cached );
+ $this->assertSame( $updater->getCanonicalParserOutput(), $cached );
+
+ // site stats
+ $stats = $this->db->selectRow( 'site_stats', '*', '1=1' );
+ $this->assertSame( $oldStats->ss_total_pages + 1, (int)$stats->ss_total_pages );
+ $this->assertSame( $oldStats->ss_total_edits + 1, (int)$stats->ss_total_edits );
+ $this->assertSame( $oldStats->ss_good_articles + 1, (int)$stats->ss_good_articles );
+
+ // TODO: MCR: test data updates for additional slots!
+ // TODO: test update for edit without page creation
+ // TODO: test message cache purge
+ // TODO: test module cache purge
+ // TODO: test CDN purge
+ // TODO: test newtalk update
+ // TODO: test search update
+ // TODO: test site stats good_articles while turning the page into (or back from) a redir.
+ // TODO: test category membership update (with setRcWatchCategoryMembership())
+ }
+
+}
--- /dev/null
+<?php
+namespace MediaWiki\Tests\Storage;
+
+use CommentStoreComment;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\SlotRecord;
+use TextContent;
+use Title;
+use WikitextContent;
+
+/**
+ * Tests RevisionStore against the post-migration MCR DB schema.
+ *
+ * @covers \MediaWiki\Storage\RevisionStore
+ *
+ * @group RevisionStore
+ * @group Storage
+ * @group Database
+ * @group medium
+ */
+class McrRevisionStoreDbTest extends RevisionStoreDbTestBase {
+
+ use McrSchemaOverride;
+
+ protected function assertRevisionExistsInDatabase( RevisionRecord $rev ) {
+ $numberOfSlots = count( $rev->getSlotRoles() );
+
+ $this->assertSelect(
+ 'slots',
+ [ 'count(*)' ],
+ [ 'slot_revision_id' => $rev->getId() ],
+ [ [ (string)$numberOfSlots ] ]
+ );
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $revQuery = $store->getSlotsQueryInfo( [ 'content' ] );
+
+ $this->assertSelect(
+ $revQuery['tables'],
+ [ 'count(*)' ],
+ [
+ 'slot_revision_id' => $rev->getId(),
+ ],
+ [ [ (string)$numberOfSlots ] ],
+ [],
+ $revQuery['joins']
+ );
+
+ $this->assertSelect(
+ 'content',
+ [ 'count(*)' ],
+ [ 'content_address' => $rev->getSlot( 'main' )->getAddress() ],
+ [ [ 1 ] ]
+ );
+
+ parent::assertRevisionExistsInDatabase( $rev );
+ }
+
+ /**
+ * @param SlotRecord $a
+ * @param SlotRecord $b
+ */
+ protected function assertSameSlotContent( SlotRecord $a, SlotRecord $b ) {
+ parent::assertSameSlotContent( $a, $b );
+
+ // Assert that the same content ID has been used
+ $this->assertSame( $a->getContentId(), $b->getContentId() );
+ }
+
+ public function provideInsertRevisionOn_successes() {
+ foreach ( parent::provideInsertRevisionOn_successes() as $case ) {
+ yield $case;
+ }
+
+ yield 'Multi-slot revision insertion' => [
+ [
+ 'content' => [
+ 'main' => new WikitextContent( 'Chicken' ),
+ 'aux' => new TextContent( 'Egg' ),
+ ],
+ 'page' => true,
+ 'comment' => $this->getRandomCommentStoreComment(),
+ 'timestamp' => '20171117010101',
+ 'user' => true,
+ ],
+ ];
+ }
+
+ public function provideNewNullRevision() {
+ foreach ( parent::provideNewNullRevision() as $case ) {
+ yield $case;
+ }
+
+ yield [
+ Title::newFromText( 'UTPage_notAutoCreated' ),
+ [
+ 'content' => [
+ 'main' => new WikitextContent( 'Chicken' ),
+ 'aux' => new WikitextContent( 'Omelet' ),
+ ],
+ ],
+ CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment multi' ),
+ ];
+ }
+
+ public function provideNewMutableRevisionFromArray() {
+ foreach ( parent::provideNewMutableRevisionFromArray() as $case ) {
+ yield $case;
+ }
+
+ yield 'Basic array, multiple roles' => [
+ [
+ 'id' => 2,
+ 'page' => 1,
+ 'timestamp' => '20171017114835',
+ 'user_text' => '111.0.1.2',
+ 'user' => 0,
+ 'minor_edit' => false,
+ 'deleted' => 0,
+ 'len' => 29,
+ 'parent_id' => 1,
+ 'sha1' => '89qs83keq9c9ccw9olvvm4oc9oq50ii',
+ 'comment' => 'Goat Comment!',
+ 'content' => [
+ 'main' => new WikitextContent( 'Söme Cöntent' ),
+ 'aux' => new TextContent( 'Öther Cöntent' ),
+ ]
+ ]
+ ];
+ }
+
+ public function testGetQueryInfo_NoSlotDataJoin() {
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $queryInfo = $store->getQueryInfo();
+
+ // with the new schema enabled, query info should not join the main slot info
+ $this->assertFalse( array_key_exists( 'a_slot_data', $queryInfo['tables'] ) );
+ $this->assertFalse( array_key_exists( 'a_slot_data', $queryInfo['joins'] ) );
+ }
+
+ public function provideGetArchiveQueryInfo() {
+ yield [
+ [
+ 'tables' => [
+ 'archive',
+ ],
+ 'fields' => array_merge(
+ $this->getDefaultArchiveFields( false ),
+ [
+ 'ar_comment_text' => 'ar_comment',
+ 'ar_comment_data' => 'NULL',
+ 'ar_comment_cid' => 'NULL',
+ 'ar_user_text' => 'ar_user_text',
+ 'ar_user' => 'ar_user',
+ 'ar_actor' => 'NULL',
+ ]
+ ),
+ 'joins' => [
+ ],
+ ]
+ ];
+ }
+
+ public function provideGetQueryInfo() {
+ // TODO: more option variations
+ yield [
+ [ 'page', 'user' ],
+ [
+ 'tables' => [
+ 'revision',
+ 'page',
+ 'user',
+ ],
+ 'fields' => array_merge(
+ $this->getDefaultQueryFields( false ),
+ $this->getCommentQueryFields(),
+ $this->getActorQueryFields(),
+ [
+ 'page_namespace',
+ 'page_title',
+ 'page_id',
+ 'page_latest',
+ 'page_is_redirect',
+ 'page_len',
+ 'user_name',
+ ]
+ ),
+ 'joins' => [
+ 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
+ 'user' => [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ],
+ ],
+ ]
+ ];
+ }
+
+ public function provideGetSlotsQueryInfo() {
+ yield [
+ [],
+ [
+ 'tables' => [
+ 'slots',
+ 'slot_roles',
+ ],
+ 'fields' => array_merge(
+ [
+ 'slot_revision_id',
+ 'slot_content_id',
+ 'slot_origin',
+ 'role_name',
+ ]
+ ),
+ 'joins' => [
+ 'slot_roles' => [ 'INNER JOIN', [ 'slot_role_id = role_id' ] ],
+ ],
+ ]
+ ];
+ yield [
+ [ 'content' ],
+ [
+ 'tables' => [
+ 'slots',
+ 'slot_roles',
+ 'content',
+ 'content_models',
+ ],
+ 'fields' => array_merge(
+ [
+ 'slot_revision_id',
+ 'slot_content_id',
+ 'slot_origin',
+ 'role_name',
+ 'content_size',
+ 'content_sha1',
+ 'content_address',
+ 'model_name',
+ ]
+ ),
+ 'joins' => [
+ 'slot_roles' => [ 'INNER JOIN', [ 'slot_role_id = role_id' ] ],
+ 'content' => [ 'INNER JOIN', [ 'slot_content_id = content_id' ] ],
+ 'content_models' => [ 'INNER JOIN', [ 'content_model = model_id' ] ],
+ ],
+ ]
+ ];
+ }
+
+}
--- /dev/null
+<?php
+namespace MediaWiki\Tests\Storage;
+
+use Wikimedia\Rdbms\IMaintainableDatabase;
+use MediaWiki\DB\PatchFileLocation;
+
+/**
+ * Trait providing schema overrides that allow tests to run against the post-migration
+ * MCR database schema.
+ */
+trait McrSchemaOverride {
+
+ use PatchFileLocation;
+ use McrSchemaDetection;
+
+ /**
+ * @return int
+ */
+ protected function getMcrMigrationStage() {
+ return MIGRATION_NEW;
+ }
+
+ /**
+ * @return string[]
+ */
+ protected function getMcrTablesToReset() {
+ return [
+ 'content',
+ 'content_models',
+ 'slots',
+ 'slot_roles',
+ ];
+ }
+
+ protected function getSchemaOverrides( IMaintainableDatabase $db ) {
+ $overrides = [
+ 'scripts' => [],
+ 'drop' => [],
+ 'create' => [],
+ 'alter' => [],
+ ];
+
+ if ( !$this->hasMcrTables( $db ) ) {
+ $overrides['create'] = [ 'slots', 'content', 'slot_roles', 'content_models', ];
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slot_roles' );
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content_models' );
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content' );
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slots.sql' );
+ }
+
+ if ( !$this->hasPreMcrFields( $db ) ) {
+ $overrides['alter'][] = 'revision';
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'drop-pre-mcr-fields', __DIR__ );
+ }
+
+ return $overrides;
+ }
+
+}
--- /dev/null
+<?php
+namespace MediaWiki\Tests\Storage;
+
+use InvalidArgumentException;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\SlotRecord;
+use Revision;
+use WikitextContent;
+
+/**
+ * Tests RevisionStore against the intermediate MCR DB schema for use during schema migration.
+ *
+ * @covers \MediaWiki\Storage\RevisionStore
+ *
+ * @group RevisionStore
+ * @group Storage
+ * @group Database
+ * @group medium
+ */
+class McrWriteBothRevisionStoreDbTest extends RevisionStoreDbTestBase {
+
+ use McrWriteBothSchemaOverride;
+
+ protected function revisionToRow( Revision $rev, $options = [ 'page', 'user', 'comment' ] ) {
+ $row = parent::revisionToRow( $rev, $options );
+
+ $row->rev_text_id = (string)$rev->getTextId();
+ $row->rev_content_format = (string)$rev->getContentFormat();
+ $row->rev_content_model = (string)$rev->getContentModel();
+
+ return $row;
+ }
+
+ protected function assertRevisionExistsInDatabase( RevisionRecord $rev ) {
+ $this->assertSelect(
+ 'slots',
+ [ 'count(*)' ],
+ [ 'slot_revision_id' => $rev->getId() ],
+ [ [ '1' ] ]
+ );
+
+ $this->assertSelect(
+ 'content',
+ [ 'count(*)' ],
+ [ 'content_address' => $rev->getSlot( 'main' )->getAddress() ],
+ [ [ '1' ] ]
+ );
+
+ parent::assertRevisionExistsInDatabase( $rev );
+ }
+
+ /**
+ * @param SlotRecord $a
+ * @param SlotRecord $b
+ */
+ protected function assertSameSlotContent( SlotRecord $a, SlotRecord $b ) {
+ parent::assertSameSlotContent( $a, $b );
+
+ // Assert that the same content ID has been used
+ if ( $a->hasContentId() && $b->hasContentId() ) {
+ $this->assertSame( $a->getContentId(), $b->getContentId() );
+ }
+ }
+
+ public function provideGetArchiveQueryInfo() {
+ yield [
+ [
+ 'tables' => [ 'archive' ],
+ 'fields' => array_merge(
+ $this->getDefaultArchiveFields(),
+ [
+ 'ar_comment_text' => 'ar_comment',
+ 'ar_comment_data' => 'NULL',
+ 'ar_comment_cid' => 'NULL',
+ 'ar_user_text' => 'ar_user_text',
+ 'ar_user' => 'ar_user',
+ 'ar_actor' => 'NULL',
+ 'ar_content_format',
+ 'ar_content_model',
+ ]
+ ),
+ 'joins' => [],
+ ]
+ ];
+ }
+
+ public function provideGetQueryInfo() {
+ yield [
+ [],
+ [
+ 'tables' => [ 'revision' ],
+ 'fields' => array_merge(
+ $this->getDefaultQueryFields(),
+ $this->getCommentQueryFields(),
+ $this->getActorQueryFields(),
+ $this->getContentHandlerQueryFields()
+ ),
+ 'joins' => [],
+ ]
+ ];
+ yield [
+ [ 'page', 'user' ],
+ [
+ 'tables' => [ 'revision', 'page', 'user' ],
+ 'fields' => array_merge(
+ $this->getDefaultQueryFields(),
+ $this->getCommentQueryFields(),
+ $this->getActorQueryFields(),
+ $this->getContentHandlerQueryFields(),
+ [
+ 'page_namespace',
+ 'page_title',
+ 'page_id',
+ 'page_latest',
+ 'page_is_redirect',
+ 'page_len',
+ 'user_name',
+ ]
+ ),
+ 'joins' => [
+ 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
+ 'user' => [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ],
+ ],
+ ]
+ ];
+ }
+
+ public function provideGetSlotsQueryInfo() {
+ $db = wfGetDB( DB_REPLICA );
+
+ yield [
+ [],
+ [
+ 'tables' => [
+ 'slots' => 'revision',
+ ],
+ 'fields' => array_merge(
+ [
+ 'slot_revision_id' => 'slots.rev_id',
+ 'slot_content_id' => 'NULL',
+ 'slot_origin' => 'slots.rev_id',
+ 'role_name' => $db->addQuotes( 'main' ),
+ ]
+ ),
+ 'joins' => [],
+ ]
+ ];
+ yield [
+ [ 'content' ],
+ [
+ 'tables' => [
+ 'slots' => 'revision',
+ ],
+ 'fields' => array_merge(
+ [
+ 'slot_revision_id' => 'slots.rev_id',
+ 'slot_content_id' => 'NULL',
+ 'slot_origin' => 'slots.rev_id',
+ 'role_name' => $db->addQuotes( 'main' ),
+ 'content_size' => 'slots.rev_len',
+ 'content_sha1' => 'slots.rev_sha1',
+ 'content_address' =>
+ 'CONCAT(' . $db->addQuotes( 'tt:' ) . ',slots.rev_text_id)',
+ 'model_name' => 'slots.rev_content_model',
+ ]
+ ),
+ 'joins' => [],
+ ]
+ ];
+ }
+
+ public function provideInsertRevisionOn_failures() {
+ foreach ( parent::provideInsertRevisionOn_failures() as $case ) {
+ yield $case;
+ }
+
+ yield 'slot that is not main slot' => [
+ [
+ 'content' => [
+ 'main' => new WikitextContent( 'Chicken' ),
+ 'lalala' => new WikitextContent( 'Duck' ),
+ ],
+ 'comment' => $this->getRandomCommentStoreComment(),
+ 'timestamp' => '20171117010101',
+ 'user' => true,
+ ],
+ new InvalidArgumentException( 'Only the main slot is supported' )
+ ];
+ }
+
+ public function provideNewMutableRevisionFromArray() {
+ foreach ( parent::provideNewMutableRevisionFromArray() as $case ) {
+ yield $case;
+ }
+
+ yield 'Basic array, with page & id' => [
+ [
+ 'id' => 2,
+ 'page' => 1,
+ 'text_id' => 2,
+ 'timestamp' => '20171017114835',
+ 'user_text' => '111.0.1.2',
+ 'user' => 0,
+ 'minor_edit' => false,
+ 'deleted' => 0,
+ 'len' => 46,
+ 'parent_id' => 1,
+ 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+ 'comment' => 'Goat Comment!',
+ 'content_format' => 'text/x-wiki',
+ 'content_model' => 'wikitext',
+ ]
+ ];
+ }
+
+}
--- /dev/null
+<?php
+namespace MediaWiki\Tests\Storage;
+
+use Wikimedia\Rdbms\IMaintainableDatabase;
+use MediaWiki\DB\PatchFileLocation;
+
+/**
+ * Trait providing schema overrides that allow tests to run against the intermediate MCR database
+ * schema for use during schema migration.
+ */
+trait McrWriteBothSchemaOverride {
+
+ use PatchFileLocation;
+ use McrSchemaDetection;
+
+ /**
+ * @return int
+ */
+ protected function getMcrMigrationStage() {
+ return MIGRATION_WRITE_BOTH;
+ }
+
+ /**
+ * @return string[]
+ */
+ protected function getMcrTablesToReset() {
+ return [ 'content', 'content_models', 'slots', 'slot_roles' ];
+ }
+
+ /**
+ * @override MediaWikiTestCase::getSchemaOverrides
+ * @return array[]
+ */
+ protected function getSchemaOverrides( IMaintainableDatabase $db ) {
+ $overrides = [
+ 'scripts' => [],
+ 'drop' => [],
+ 'create' => [],
+ 'alter' => [],
+ ];
+
+ if ( !$this->hasMcrTables( $db ) ) {
+ $overrides['create'] = [ 'slots', 'content', 'slot_roles', 'content_models', ];
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slot_roles' );
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content_models' );
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-content' );
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'patch-slots' );
+ }
+
+ if ( !$this->hasPreMcrFields( $db ) ) {
+ $overrides['alter'][] = 'revision';
+ $overrides['scripts'][] = $this->getSqlPatchPath( $db, 'create-pre-mcr-fields', __DIR__ );
+ }
+
+ return $overrides;
+ }
+
+}
use MediaWiki\Storage\MutableRevisionRecord;
use MediaWiki\Storage\RevisionAccessException;
use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\RevisionSlotsUpdate;
use MediaWiki\Storage\SlotRecord;
use MediaWiki\User\UserIdentityValue;
use MediaWikiTestCase;
$this->assertSame( $comment, $record->getComment() );
}
+ public function testSimpleGetOriginalAndInheritedSlots() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $mainSlot = new SlotRecord(
+ (object)[
+ 'slot_id' => 1,
+ 'slot_revision_id' => null, // unsaved
+ 'slot_content_id' => 1,
+ 'content_address' => null, // touched
+ 'model_name' => 'x',
+ 'role_name' => 'main',
+ 'slot_origin' => null // touched
+ ],
+ new WikitextContent( 'main' )
+ );
+ $auxSlot = new SlotRecord(
+ (object)[
+ 'slot_id' => 2,
+ 'slot_revision_id' => null, // unsaved
+ 'slot_content_id' => 1,
+ 'content_address' => 'foo', // inherited
+ 'model_name' => 'x',
+ 'role_name' => 'aux',
+ 'slot_origin' => 1 // inherited
+ ],
+ new WikitextContent( 'aux' )
+ );
+
+ $record->setSlot( $mainSlot );
+ $record->setSlot( $auxSlot );
+
+ $this->assertSame( [ 'main' ], $record->getOriginalSlots()->getSlotRoles() );
+ $this->assertSame( $mainSlot, $record->getOriginalSlots()->getSlot( 'main' ) );
+
+ $this->assertSame( [ 'aux' ], $record->getInheritedSlots()->getSlotRoles() );
+ $this->assertSame( $auxSlot, $record->getInheritedSlots()->getSlot( 'aux' ) );
+ }
+
+ public function testSimpleremoveSlot() {
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+
+ $a = new WikitextContent( 'a' );
+ $b = new WikitextContent( 'b' );
+
+ $record->inheritSlot( SlotRecord::newSaved( 7, 3, 'a', SlotRecord::newUnsaved( 'a', $a ) ) );
+ $record->inheritSlot( SlotRecord::newSaved( 7, 4, 'b', SlotRecord::newUnsaved( 'b', $b ) ) );
+
+ $record->removeSlot( 'b' );
+
+ $this->assertTrue( $record->hasSlot( 'a' ) );
+ $this->assertFalse( $record->hasSlot( 'b' ) );
+ }
+
+ public function testApplyUpdate() {
+ $update = new RevisionSlotsUpdate();
+
+ $a = new WikitextContent( 'a' );
+ $b = new WikitextContent( 'b' );
+ $c = new WikitextContent( 'c' );
+ $x = new WikitextContent( 'x' );
+
+ $update->modifyContent( 'b', $x );
+ $update->modifyContent( 'c', $x );
+ $update->removeSlot( 'c' );
+ $update->removeSlot( 'd' );
+
+ $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+ $record->inheritSlot( SlotRecord::newSaved( 7, 3, 'a', SlotRecord::newUnsaved( 'a', $a ) ) );
+ $record->inheritSlot( SlotRecord::newSaved( 7, 4, 'b', SlotRecord::newUnsaved( 'b', $b ) ) );
+ $record->inheritSlot( SlotRecord::newSaved( 7, 5, 'c', SlotRecord::newUnsaved( 'c', $c ) ) );
+
+ $record->applyUpdate( $update );
+
+ $this->assertEquals( [ 'b' ], array_keys( $record->getOriginalSlots()->getSlots() ) );
+ $this->assertEquals( $a, $record->getSlot( 'a' )->getContent() );
+ $this->assertEquals( $x, $record->getSlot( 'b' )->getContent() );
+ $this->assertFalse( $record->hasSlot( 'c' ) );
+ }
+
}
namespace MediaWiki\Tests\Storage;
+use Content;
use InvalidArgumentException;
use MediaWiki\Storage\MutableRevisionSlots;
use MediaWiki\Storage\RevisionAccessException;
$this->assertSame( [ 'main' => $slotB ], $slots->getSlots() );
}
+ /**
+ * @param string $role
+ * @param Content $content
+ * @return SlotRecord
+ */
+ private function newSavedSlot( $role, Content $content ) {
+ return SlotRecord::newSaved( 7, 7, 'xyz', SlotRecord::newUnsaved( $role, $content ) );
+ }
+
+ public function testInheritSlotOverwritesSlot() {
+ $slots = new MutableRevisionSlots();
+ $slotA = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+ $slots->setSlot( $slotA );
+ $slotB = $this->newSavedSlot( 'main', new WikitextContent( 'B' ) );
+ $slotC = $this->newSavedSlot( 'foo', new WikitextContent( 'C' ) );
+ $slots->inheritSlot( $slotB );
+ $slots->inheritSlot( $slotC );
+ $this->assertSame( [ 'main', 'foo' ], $slots->getSlotRoles() );
+ $this->assertNotSame( $slotB, $slots->getSlot( 'main' ) );
+ $this->assertNotSame( $slotC, $slots->getSlot( 'foo' ) );
+ $this->assertTrue( $slots->getSlot( 'main' )->isInherited() );
+ $this->assertTrue( $slots->getSlot( 'foo' )->isInherited() );
+ $this->assertSame( $slotB->getContent(), $slots->getSlot( 'main' )->getContent() );
+ $this->assertSame( $slotC->getContent(), $slots->getSlot( 'foo' )->getContent() );
+ }
+
public function testSetContentOfExistingSlotOverwritesContent() {
$slots = new MutableRevisionSlots();
$slots->getSlot( 'main' );
}
+ public function testNewFromParentRevisionSlots() {
+ /** @var SlotRecord[] $parentSlots */
+ $parentSlots = [
+ 'some' => $this->newSavedSlot( 'some', new WikitextContent( 'X' ) ),
+ 'other' => $this->newSavedSlot( 'other', new WikitextContent( 'Y' ) ),
+ ];
+ $slots = MutableRevisionSlots::newFromParentRevisionSlots( $parentSlots );
+ $this->assertSame( [ 'some', 'other' ], $slots->getSlotRoles() );
+ $this->assertNotSame( $parentSlots['some'], $slots->getSlot( 'some' ) );
+ $this->assertNotSame( $parentSlots['other'], $slots->getSlot( 'other' ) );
+ $this->assertTrue( $slots->getSlot( 'some' )->isInherited() );
+ $this->assertTrue( $slots->getSlot( 'other' )->isInherited() );
+ $this->assertSame( $parentSlots['some']->getContent(), $slots->getContent( 'some' ) );
+ $this->assertSame( $parentSlots['other']->getContent(), $slots->getContent( 'other' ) );
+ }
+
}
BagOStuff $cacheBag,
$insertCalls,
$selectCalls,
- $normalizationCallback = null
+ $normalizationCallback = null,
+ $insertCallback = null
) {
return new NameTableStore(
$this->getMockLoadBalancer( $this->getCallCheckingDb( $insertCalls, $selectCalls ) ),
$this->getHashWANObjectCache( $cacheBag ),
new NullLogger(),
'slot_roles', 'role_id', 'role_name',
- $normalizationCallback
+ $normalizationCallback,
+ false,
+ $insertCallback
);
}
$this->assertSame( $barId, $store3->acquireId( 'bar' ) );
}
+ public function testGetAndAcquireIdInsertCallback() {
+ $store = $this->getNameTableSqlStore(
+ new EmptyBagOStuff(),
+ 1,
+ 1,
+ null,
+ function ( $insertFields ) {
+ $insertFields['role_id'] = 7251;
+ return $insertFields;
+ }
+ );
+ $this->assertSame( 7251, $store->acquireId( 'A' ) );
+ }
+
}
<?php
namespace MediaWiki\Tests\Storage;
+use Revision;
+
/**
* Tests RevisionStore against the pre-MCR, pre-ContentHandler DB schema.
*
return false;
}
+ protected function revisionToRow( Revision $rev, $options = [ 'page', 'user', 'comment' ] ) {
+ $row = parent::revisionToRow( $rev, $options );
+
+ $row->rev_text_id = (string)$rev->getTextId();
+
+ return $row;
+ }
+
public function provideGetArchiveQueryInfo() {
yield [
[
];
}
+ public function provideGetSlotsQueryInfo() {
+ $db = wfGetDB( DB_REPLICA );
+
+ yield [
+ [],
+ [
+ 'tables' => [
+ 'slots' => 'revision',
+ ],
+ 'fields' => array_merge(
+ [
+ 'slot_revision_id' => 'slots.rev_id',
+ 'slot_content_id' => 'NULL',
+ 'slot_origin' => 'slots.rev_id',
+ 'role_name' => $db->addQuotes( 'main' ),
+ ]
+ ),
+ 'joins' => [],
+ ]
+ ];
+ yield [
+ [ 'content' ],
+ [
+ 'tables' => [
+ 'slots' => 'revision',
+ ],
+ 'fields' => array_merge(
+ [
+ 'slot_revision_id' => 'slots.rev_id',
+ 'slot_content_id' => 'NULL',
+ 'slot_origin' => 'slots.rev_id',
+ 'role_name' => $db->addQuotes( 'main' ),
+ 'content_size' => 'slots.rev_len',
+ 'content_sha1' => 'slots.rev_sha1',
+ 'content_address' =>
+ 'CONCAT(' . $db->addQuotes( 'tt:' ) . ',slots.rev_text_id)',
+ 'model_name' => 'NULL',
+ ]
+ ),
+ 'joins' => [],
+ ]
+ ];
+ }
+
+ public function provideNewMutableRevisionFromArray() {
+ foreach ( parent::provideNewMutableRevisionFromArray() as $case ) {
+ yield $case;
+ }
+
+ yield 'Basic array, with page & id' => [
+ [
+ 'id' => 2,
+ 'page' => 1,
+ 'text_id' => 2,
+ 'timestamp' => '20171017114835',
+ 'user_text' => '111.0.1.2',
+ 'user' => 0,
+ 'minor_edit' => false,
+ 'deleted' => 0,
+ 'len' => 46,
+ 'parent_id' => 1,
+ 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+ 'comment' => 'Goat Comment!',
+ ]
+ ];
+ }
+
}
--- /dev/null
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use CommentStoreComment;
+use Content;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWikiTestCase;
+use RecentChange;
+use Revision;
+use TextContent;
+use Title;
+use WikiPage;
+
+/**
+ * @covers \MediaWiki\Storage\PageUpdater
+ * @group Database
+ */
+class PageUpdaterTest extends MediaWikiTestCase {
+
+ private function getDummyTitle( $method ) {
+ return Title::newFromText( $method, $this->getDefaultWikitextNS() );
+ }
+
+ /**
+ * @param int $revId
+ *
+ * @return null|RecentChange
+ */
+ private function getRecentChangeFor( $revId ) {
+ $qi = RecentChange::getQueryInfo();
+ $row = $this->db->selectRow(
+ $qi['tables'],
+ $qi['fields'],
+ [ 'rc_this_oldid' => $revId ],
+ __METHOD__,
+ [],
+ $qi['joins']
+ );
+
+ return $row ? RecentChange::newFromRow( $row ) : null;
+ }
+
+ // TODO: test setAjaxEditStash();
+
+ /**
+ * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
+ * @covers \WikiPage::newPageUpdater()
+ */
+ public function testCreatePage() {
+ $user = $this->getTestUser()->getUser();
+
+ $title = $this->getDummyTitle( __METHOD__ );
+ $page = WikiPage::factory( $title );
+ $updater = $page->newPageUpdater( $user );
+
+ $oldStats = $this->db->selectRow( 'site_stats', '*', '1=1' );
+
+ $this->assertFalse( $updater->wasCommitted(), 'wasCommitted' );
+ $this->assertFalse( $updater->getBaseRevisionId(), 'getBaseRevisionId' );
+ $this->assertSame( 0, $updater->getUndidRevisionId(), 'getUndidRevisionId' );
+
+ $updater->setBaseRevisionId( 0 );
+ $this->assertSame( 0, $updater->getBaseRevisionId(), 'getBaseRevisionId' );
+
+ $updater->addTag( 'foo' );
+ $updater->addTags( [ 'bar', 'qux' ] );
+
+ $tags = $updater->getExplicitTags();
+ sort( $tags );
+ $this->assertSame( [ 'bar', 'foo', 'qux' ], $tags, 'getExplicitTags' );
+
+ // TODO: MCR: test additional slots
+ $content = new TextContent( 'Lorem Ipsum' );
+ $updater->setContent( 'main', $content );
+
+ $parent = $updater->grabParentRevision();
+
+ // TODO: test that hasEditConflict() grabs the parent revision
+ $this->assertNull( $parent, 'getParentRevision' );
+ $this->assertFalse( $updater->wasCommitted(), 'wasCommitted' );
+ $this->assertFalse( $updater->hasEditConflict(), 'hasEditConflict' );
+
+ // TODO: test failure with EDIT_UPDATE
+ // TODO: test EDIT_MINOR, EDIT_BOT, etc
+ $summary = CommentStoreComment::newUnsavedComment( 'Just a test' );
+ $rev = $updater->saveRevision( $summary );
+
+ $this->assertNotNull( $rev );
+ $this->assertSame( 0, $rev->getParentId() );
+ $this->assertSame( $summary->text, $rev->getComment( RevisionRecord::RAW )->text );
+ $this->assertSame( $user->getName(), $rev->getUser( RevisionRecord::RAW )->getName() );
+
+ $this->assertTrue( $updater->wasCommitted(), 'wasCommitted()' );
+ $this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
+ $this->assertTrue( $updater->getStatus()->isOK(), 'getStatus()->isOK()' );
+ $this->assertTrue( $updater->isNew(), 'isNew()' );
+ $this->assertFalse( $updater->isUnchanged(), 'isUnchanged()' );
+ $this->assertNotNull( $updater->getNewRevision(), 'getNewRevision()' );
+ $this->assertInstanceOf( Revision::class, $updater->getStatus()->value['revision'] );
+
+ $rev = $updater->getNewRevision();
+ $revContent = $rev->getContent( 'main' );
+ $this->assertSame( 'Lorem Ipsum', $revContent->serialize(), 'revision content' );
+
+ // were the WikiPage and Title objects updated?
+ $this->assertTrue( $page->exists(), 'WikiPage::exists()' );
+ $this->assertTrue( $title->exists(), 'Title::exists()' );
+ $this->assertSame( $rev->getId(), $page->getLatest(), 'WikiPage::getRevision()' );
+ $this->assertNotNull( $page->getRevision(), 'WikiPage::getRevision()' );
+
+ // re-load
+ $page2 = WikiPage::factory( $title );
+ $this->assertTrue( $page2->exists(), 'WikiPage::exists()' );
+ $this->assertSame( $rev->getId(), $page2->getLatest(), 'WikiPage::getRevision()' );
+ $this->assertNotNull( $page2->getRevision(), 'WikiPage::getRevision()' );
+
+ // Check RC entry
+ $rc = $this->getRecentChangeFor( $rev->getId() );
+ $this->assertNotNull( $rc, 'RecentChange' );
+
+ // check site stats - this asserts that derived data updates where run.
+ $stats = $this->db->selectRow( 'site_stats', '*', '1=1' );
+ $this->assertSame( $oldStats->ss_total_pages + 1, (int)$stats->ss_total_pages );
+ $this->assertSame( $oldStats->ss_total_edits + 1, (int)$stats->ss_total_edits );
+
+ // re-edit with same content - should be a "null-edit"
+ $updater = $page->newPageUpdater( $user );
+ $updater->setContent( 'main', $content );
+
+ $summary = CommentStoreComment::newUnsavedComment( 'to to re-edit' );
+ $rev = $updater->saveRevision( $summary );
+ $status = $updater->getStatus();
+
+ $this->assertNull( $rev, 'getNewRevision()' );
+ $this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
+ $this->assertTrue( $updater->isUnchanged(), 'isUnchanged' );
+ $this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
+ $this->assertTrue( $status->isOK(), 'getStatus()->isOK()' );
+ $this->assertTrue( $status->hasMessage( 'edit-no-change' ), 'edit-no-change' );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
+ * @covers \WikiPage::newPageUpdater()
+ */
+ public function testUpdatePage() {
+ $user = $this->getTestUser()->getUser();
+
+ $title = $this->getDummyTitle( __METHOD__ );
+ $this->insertPage( $title );
+
+ $page = WikiPage::factory( $title );
+ $parentId = $page->getLatest();
+
+ $updater = $page->newPageUpdater( $user );
+
+ $oldStats = $this->db->selectRow( 'site_stats', '*', '1=1' );
+
+ // TODO: test page update does not fail with mismatching base rev ID
+ $baseRev = $title->getLatestRevID( Title::GAID_FOR_UPDATE );
+ $updater->setBaseRevisionId( $baseRev );
+ $this->assertSame( $baseRev, $updater->getBaseRevisionId(), 'getBaseRevisionId' );
+
+ // TODO: MCR: test additional slots
+ $updater->setContent( 'main', new TextContent( 'Lorem Ipsum' ) );
+
+ // TODO: test all flags for saveRevision()!
+ $summary = CommentStoreComment::newUnsavedComment( 'Just a test' );
+ $rev = $updater->saveRevision( $summary );
+
+ $this->assertNotNull( $rev );
+ $this->assertSame( $parentId, $rev->getParentId() );
+ $this->assertSame( $summary->text, $rev->getComment( RevisionRecord::RAW )->text );
+ $this->assertSame( $user->getName(), $rev->getUser( RevisionRecord::RAW )->getName() );
+
+ $this->assertTrue( $updater->wasCommitted(), 'wasCommitted()' );
+ $this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
+ $this->assertTrue( $updater->getStatus()->isOK(), 'getStatus()->isOK()' );
+ $this->assertFalse( $updater->isNew(), 'isNew()' );
+ $this->assertNotNull( $updater->getNewRevision(), 'getNewRevision()' );
+ $this->assertInstanceOf( Revision::class, $updater->getStatus()->value['revision'] );
+ $this->assertFalse( $updater->isUnchanged(), 'isUnchanged()' );
+
+ // TODO: Test null revision (with different user): new revision!
+
+ $rev = $updater->getNewRevision();
+ $revContent = $rev->getContent( 'main' );
+ $this->assertSame( 'Lorem Ipsum', $revContent->serialize(), 'revision content' );
+
+ // were the WikiPage and Title objects updated?
+ $this->assertTrue( $page->exists(), 'WikiPage::exists()' );
+ $this->assertTrue( $title->exists(), 'Title::exists()' );
+ $this->assertSame( $rev->getId(), $page->getLatest(), 'WikiPage::getRevision()' );
+ $this->assertNotNull( $page->getRevision(), 'WikiPage::getRevision()' );
+
+ // re-load
+ $page2 = WikiPage::factory( $title );
+ $this->assertTrue( $page2->exists(), 'WikiPage::exists()' );
+ $this->assertSame( $rev->getId(), $page2->getLatest(), 'WikiPage::getRevision()' );
+ $this->assertNotNull( $page2->getRevision(), 'WikiPage::getRevision()' );
+
+ // Check RC entry
+ $rc = $this->getRecentChangeFor( $rev->getId() );
+ $this->assertNotNull( $rc, 'RecentChange' );
+
+ // re-edit
+ $updater = $page->newPageUpdater( $user );
+ $updater->setContent( 'main', new TextContent( 'dolor sit amet' ) );
+
+ $summary = CommentStoreComment::newUnsavedComment( 're-edit' );
+ $updater->saveRevision( $summary );
+ $this->assertTrue( $updater->wasSuccessful(), 'wasSuccessful()' );
+ $this->assertTrue( $updater->getStatus()->isOK(), 'getStatus()->isOK()' );
+
+ // check site stats - this asserts that derived data updates where run.
+ $stats = $this->db->selectRow( 'site_stats', '*', '1=1' );
+ $this->assertSame( $oldStats->ss_total_pages + 0, (int)$stats->ss_total_pages );
+ $this->assertSame( $oldStats->ss_total_edits + 2, (int)$stats->ss_total_edits );
+ }
+
+ /**
+ * Creates a revision in the database.
+ *
+ * @param WikiPage $page
+ * @param $summary
+ * @param null|string|Content $content
+ *
+ * @return RevisionRecord|null
+ */
+ private function createRevision( WikiPage $page, $summary, $content = null ) {
+ $user = $this->getTestUser()->getUser();
+ $comment = CommentStoreComment::newUnsavedComment( $summary );
+
+ if ( !$content instanceof Content ) {
+ $content = new TextContent( $content === null ? $summary : $content );
+ }
+
+ $updater = $page->newPageUpdater( $user );
+ $updater->setContent( 'main', $content );
+ $rev = $updater->saveRevision( $comment );
+ return $rev;
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\PageUpdater::grabParentRevision()
+ * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
+ */
+ public function testCompareAndSwapFailure() {
+ $user = $this->getTestUser()->getUser();
+
+ $title = $this->getDummyTitle( __METHOD__ );
+
+ // start editing non-existing page
+ $page = WikiPage::factory( $title );
+ $updater = $page->newPageUpdater( $user );
+ $updater->grabParentRevision();
+
+ // create page concurrently
+ $concurrentPage = WikiPage::factory( $title );
+ $this->createRevision( $concurrentPage, __METHOD__ . '-one' );
+
+ // try creating the page - should trigger CAS failure.
+ $summary = CommentStoreComment::newUnsavedComment( 'create?!' );
+ $updater->setContent( 'main', new TextContent( 'Lorem ipsum' ) );
+ $updater->saveRevision( $summary );
+ $status = $updater->getStatus();
+
+ $this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
+ $this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
+ $this->assertFalse( $status->isOK(), 'getStatus()->isOK()' );
+ $this->assertTrue( $status->hasMessage( 'edit-already-exists' ), 'edit-conflict' );
+
+ // start editing existing page
+ $page = WikiPage::factory( $title );
+ $updater = $page->newPageUpdater( $user );
+ $updater->grabParentRevision();
+
+ // update page concurrently
+ $concurrentPage = WikiPage::factory( $title );
+ $this->createRevision( $concurrentPage, __METHOD__ . '-two' );
+
+ // try creating the page - should trigger CAS failure.
+ $summary = CommentStoreComment::newUnsavedComment( 'edit?!' );
+ $updater->setContent( 'main', new TextContent( 'dolor sit amet' ) );
+ $updater->saveRevision( $summary );
+ $status = $updater->getStatus();
+
+ $this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
+ $this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
+ $this->assertFalse( $status->isOK(), 'getStatus()->isOK()' );
+ $this->assertTrue( $status->hasMessage( 'edit-conflict' ), 'edit-conflict' );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
+ */
+ public function testFailureOnEditFlags() {
+ $user = $this->getTestUser()->getUser();
+
+ $title = $this->getDummyTitle( __METHOD__ );
+
+ // start editing non-existing page
+ $page = WikiPage::factory( $title );
+ $updater = $page->newPageUpdater( $user );
+
+ // update with EDIT_UPDATE flag should fail
+ $summary = CommentStoreComment::newUnsavedComment( 'udpate?!' );
+ $updater->setContent( 'main', new TextContent( 'Lorem ipsum' ) );
+ $updater->saveRevision( $summary, EDIT_UPDATE );
+ $status = $updater->getStatus();
+
+ $this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
+ $this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
+ $this->assertFalse( $status->isOK(), 'getStatus()->isOK()' );
+ $this->assertTrue( $status->hasMessage( 'edit-gone-missing' ), 'edit-gone-missing' );
+
+ // create the page
+ $this->createRevision( $page, __METHOD__ );
+
+ // update with EDIT_NEW flag should fail
+ $summary = CommentStoreComment::newUnsavedComment( 'create?!' );
+ $updater = $page->newPageUpdater( $user );
+ $updater->setContent( 'main', new TextContent( 'dolor sit amet' ) );
+ $updater->saveRevision( $summary, EDIT_NEW );
+ $status = $updater->getStatus();
+
+ $this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
+ $this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
+ $this->assertFalse( $status->isOK(), 'getStatus()->isOK()' );
+ $this->assertTrue( $status->hasMessage( 'edit-already-exists' ), 'edit-already-exists' );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
+ * @covers \MediaWiki\Storage\PageUpdater::setBaseRevisionId()
+ */
+ public function testFailureOnBaseRevision() {
+ $user = $this->getTestUser()->getUser();
+
+ $title = $this->getDummyTitle( __METHOD__ );
+
+ // start editing non-existing page
+ $page = WikiPage::factory( $title );
+ $updater = $page->newPageUpdater( $user );
+
+ // update for base revision 7 should fail
+ $summary = CommentStoreComment::newUnsavedComment( 'udpate?!' );
+ $updater->setBaseRevisionId( 7 ); // expect page to exist
+ $updater->setContent( 'main', new TextContent( 'Lorem ipsum' ) );
+ $updater->saveRevision( $summary );
+ $status = $updater->getStatus();
+
+ $this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
+ $this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
+ $this->assertFalse( $status->isOK(), 'getStatus()->isOK()' );
+ $this->assertTrue( $status->hasMessage( 'edit-gone-missing' ), 'edit-gone-missing' );
+
+ // create the page
+ $this->createRevision( $page, __METHOD__ );
+
+ // update for base revision 0 should fail
+ $summary = CommentStoreComment::newUnsavedComment( 'create?!' );
+ $updater = $page->newPageUpdater( $user );
+ $updater->setBaseRevisionId( 0 ); // expect page to not exist
+ $updater->setContent( 'main', new TextContent( 'dolor sit amet' ) );
+ $updater->saveRevision( $summary );
+ $status = $updater->getStatus();
+
+ $this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
+ $this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
+ $this->assertFalse( $status->isOK(), 'getStatus()->isOK()' );
+ $this->assertTrue( $status->hasMessage( 'edit-already-exists' ), 'edit-already-exists' );
+ }
+
+ public function provideSetRcPatrolStatus( $patrolled ) {
+ yield [ RecentChange::PRC_UNPATROLLED ];
+ yield [ RecentChange::PRC_AUTOPATROLLED ];
+ }
+
+ /**
+ * @dataProvider provideSetRcPatrolStatus
+ * @covers \MediaWiki\Storage\PageUpdater::setRcPatrolStatus()
+ */
+ public function testSetRcPatrolStatus( $patrolled ) {
+ $revisionStore = MediaWikiServices::getInstance()->getRevisionStore();
+
+ $user = $this->getTestUser()->getUser();
+
+ $title = $this->getDummyTitle( __METHOD__ );
+
+ $page = WikiPage::factory( $title );
+ $updater = $page->newPageUpdater( $user );
+
+ $summary = CommentStoreComment::newUnsavedComment( 'Lorem ipsum ' . $patrolled );
+ $updater->setContent( 'main', new TextContent( 'Lorem ipsum ' . $patrolled ) );
+ $updater->setRcPatrolStatus( $patrolled );
+ $rev = $updater->saveRevision( $summary );
+
+ $rc = $revisionStore->getRecentChange( $rev );
+ $this->assertEquals( $patrolled, $rc->getAttribute( 'rc_patrolled' ) );
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\PageUpdater::inheritSlot()
+ * @covers \MediaWiki\Storage\PageUpdater::setContent()
+ */
+ public function testInheritSlot() {
+ $user = $this->getTestUser()->getUser();
+ $title = $this->getDummyTitle( __METHOD__ );
+ $page = WikiPage::factory( $title );
+
+ $updater = $page->newPageUpdater( $user );
+ $summary = CommentStoreComment::newUnsavedComment( 'one' );
+ $updater->setContent( 'main', new TextContent( 'Lorem ipsum' ) );
+ $rev1 = $updater->saveRevision( $summary, EDIT_NEW );
+
+ $updater = $page->newPageUpdater( $user );
+ $summary = CommentStoreComment::newUnsavedComment( 'two' );
+ $updater->setContent( 'main', new TextContent( 'Foo Bar' ) );
+ $rev2 = $updater->saveRevision( $summary, EDIT_UPDATE );
+
+ $updater = $page->newPageUpdater( $user );
+ $summary = CommentStoreComment::newUnsavedComment( 'three' );
+ $updater->inheritSlot( $rev1->getSlot( 'main' ) );
+ $rev3 = $updater->saveRevision( $summary, EDIT_UPDATE );
+
+ $this->assertNotSame( $rev1->getId(), $rev3->getId() );
+ $this->assertNotSame( $rev2->getId(), $rev3->getId() );
+
+ $main1 = $rev1->getSlot( 'main' );
+ $main3 = $rev3->getSlot( 'main' );
+
+ $this->assertNotSame( $main1->getRevision(), $main3->getRevision() );
+ $this->assertSame( $main1->getAddress(), $main3->getAddress() );
+ $this->assertTrue( $main1->getContent()->equals( $main3->getContent() ) );
+ }
+
+ // TODO: MCR: test adding multiple slots, inheriting parent slots, and removing slots.
+
+ public function testSetUseAutomaticEditSummaries() {
+ $this->setContentLang( 'qqx' );
+ $user = $this->getTestUser()->getUser();
+
+ $title = $this->getDummyTitle( __METHOD__ );
+ $page = WikiPage::factory( $title );
+
+ $updater = $page->newPageUpdater( $user );
+ $updater->setUseAutomaticEditSummaries( true );
+ $updater->setContent( 'main', new TextContent( 'Lorem Ipsum' ) );
+
+ // empty comment triggers auto-summary
+ $summary = CommentStoreComment::newUnsavedComment( '' );
+ $updater->saveRevision( $summary, EDIT_AUTOSUMMARY );
+
+ $rev = $updater->getNewRevision();
+ $comment = $rev->getComment( RevisionRecord::RAW );
+ $this->assertSame( '(autosumm-new: Lorem Ipsum)', $comment->text, 'comment text' );
+
+ // check that this also works when blanking the page
+ $updater = $page->newPageUpdater( $user );
+ $updater->setUseAutomaticEditSummaries( true );
+ $updater->setContent( 'main', new TextContent( '' ) );
+
+ $summary = CommentStoreComment::newUnsavedComment( '' );
+ $updater->saveRevision( $summary, EDIT_AUTOSUMMARY );
+
+ $rev = $updater->getNewRevision();
+ $comment = $rev->getComment( RevisionRecord::RAW );
+ $this->assertSame( '(autosumm-blank)', $comment->text, 'comment text' );
+
+ // check that we can also disable edit-summaries
+ $title2 = $this->getDummyTitle( __METHOD__ . '/2' );
+ $page2 = WikiPage::factory( $title2 );
+
+ $updater = $page2->newPageUpdater( $user );
+ $updater->setUseAutomaticEditSummaries( false );
+ $updater->setContent( 'main', new TextContent( 'Lorem Ipsum' ) );
+
+ $summary = CommentStoreComment::newUnsavedComment( '' );
+ $updater->saveRevision( $summary, EDIT_AUTOSUMMARY );
+
+ $rev = $updater->getNewRevision();
+ $comment = $rev->getComment( RevisionRecord::RAW );
+ $this->assertSame( '', $comment->text, 'comment text should still be lank' );
+
+ // check that we don't do auto.summaries without the EDIT_AUTOSUMMARY flag
+ $updater = $page2->newPageUpdater( $user );
+ $updater->setUseAutomaticEditSummaries( true );
+ $updater->setContent( 'main', new TextContent( '' ) );
+
+ $summary = CommentStoreComment::newUnsavedComment( '' );
+ $updater->saveRevision( $summary, 0 );
+
+ $rev = $updater->getNewRevision();
+ $comment = $rev->getComment( RevisionRecord::RAW );
+ $this->assertSame( '', $comment->text, 'comment text' );
+ }
+
+ public function provideSetUsePageCreationLog() {
+ yield [ true, [ [ 'create', 'create' ] ] ];
+ yield [ false, [] ];
+ }
+
+ /**
+ * @dataProvider provideSetUsePageCreationLog
+ * @param bool $use
+ */
+ public function testSetUsePageCreationLog( $use, $expected ) {
+ $user = $this->getTestUser()->getUser();
+ $title = $this->getDummyTitle( __METHOD__ . ( $use ? '_logged' : '_unlogged' ) );
+ $page = WikiPage::factory( $title );
+
+ $updater = $page->newPageUpdater( $user );
+ $updater->setUsePageCreationLog( $use );
+ $summary = CommentStoreComment::newUnsavedComment( 'cmt' );
+ $updater->setContent( 'main', new TextContent( 'Lorem Ipsum' ) );
+ $updater->saveRevision( $summary, EDIT_NEW );
+
+ $rev = $updater->getNewRevision();
+ $this->assertSelect(
+ 'logging',
+ [ 'log_type', 'log_action' ],
+ [ 'log_page' => $rev->getPageId() ],
+ $expected
+ );
+ }
+
+}
<?php
namespace MediaWiki\Tests\Storage;
+use InvalidArgumentException;
+use Revision;
+use WikitextContent;
+
/**
* Tests RevisionStore against the pre-MCR DB schema.
*
use PreMcrSchemaOverride;
+ protected function revisionToRow( Revision $rev, $options = [ 'page', 'user', 'comment' ] ) {
+ $row = parent::revisionToRow( $rev, $options );
+
+ $row->rev_text_id = (string)$rev->getTextId();
+ $row->rev_content_format = (string)$rev->getContentFormat();
+ $row->rev_content_model = (string)$rev->getContentModel();
+
+ return $row;
+ }
+
public function provideGetArchiveQueryInfo() {
yield [
[
];
}
+ public function provideGetSlotsQueryInfo() {
+ $db = wfGetDB( DB_REPLICA );
+
+ yield [
+ [],
+ [
+ 'tables' => [
+ 'slots' => 'revision',
+ ],
+ 'fields' => array_merge(
+ [
+ 'slot_revision_id' => 'slots.rev_id',
+ 'slot_content_id' => 'NULL',
+ 'slot_origin' => 'slots.rev_id',
+ 'role_name' => $db->addQuotes( 'main' ),
+ ]
+ ),
+ 'joins' => [],
+ ]
+ ];
+ yield [
+ [ 'content' ],
+ [
+ 'tables' => [
+ 'slots' => 'revision',
+ ],
+ 'fields' => array_merge(
+ [
+ 'slot_revision_id' => 'slots.rev_id',
+ 'slot_content_id' => 'NULL',
+ 'slot_origin' => 'slots.rev_id',
+ 'role_name' => $db->addQuotes( 'main' ),
+ 'content_size' => 'slots.rev_len',
+ 'content_sha1' => 'slots.rev_sha1',
+ 'content_address' =>
+ 'CONCAT(' . $db->addQuotes( 'tt:' ) . ',slots.rev_text_id)',
+ 'model_name' => 'slots.rev_content_model',
+ ]
+ ),
+ 'joins' => [],
+ ]
+ ];
+ }
+
+ public function provideInsertRevisionOn_failures() {
+ foreach ( parent::provideInsertRevisionOn_failures() as $case ) {
+ yield $case;
+ }
+
+ yield 'slot that is not main slot' => [
+ [
+ 'content' => [
+ 'main' => new WikitextContent( 'Chicken' ),
+ 'lalala' => new WikitextContent( 'Duck' ),
+ ],
+ 'comment' => $this->getRandomCommentStoreComment(),
+ 'timestamp' => '20171117010101',
+ 'user' => true,
+ ],
+ new InvalidArgumentException( 'Only the main slot is supported' )
+ ];
+ }
+
+ public function provideNewMutableRevisionFromArray() {
+ foreach ( parent::provideNewMutableRevisionFromArray() as $case ) {
+ yield $case;
+ }
+
+ yield 'Basic array, with page & id' => [
+ [
+ 'id' => 2,
+ 'page' => 1,
+ 'text_id' => 2,
+ 'timestamp' => '20171017114835',
+ 'user_text' => '111.0.1.2',
+ 'user' => 0,
+ 'minor_edit' => false,
+ 'deleted' => 0,
+ 'len' => 46,
+ 'parent_id' => 1,
+ 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+ 'comment' => 'Goat Comment!',
+ 'content_format' => 'text/x-wiki',
+ 'content_model' => 'wikitext',
+ ]
+ ];
+ }
+
}
}
/**
- * @covers \MediaWiki\Storage\RevisionSlots::getTouchedSlots
+ * @covers \MediaWiki\Storage\RevisionSlots::getOriginalSlots
*/
- public function testGetTouchedSlots() {
+ public function testGetOriginalSlots() {
$mainSlot = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
$auxSlot = SlotRecord::newInherited(
SlotRecord::newSaved(
$slotsArray = [ $mainSlot, $auxSlot ];
$slots = $this->newRevisionSlots( $slotsArray );
- $this->assertEquals( [ 'main' => $mainSlot ], $slots->getTouchedSlots() );
+ $this->assertEquals( [ 'main' => $mainSlot ], $slots->getOriginalSlots() );
}
public function provideComputeSize() {
namespace MediaWiki\Tests\Storage;
+use Content;
+use MediaWiki\Storage\MutableRevisionSlots;
use MediaWiki\Storage\RevisionSlots;
use MediaWiki\Storage\RevisionSlotsUpdate;
use MediaWiki\Storage\RevisionAccessException;
*
* @param RevisionSlots $newSlots
* @param RevisionSlots $parentSlots
- * @param $modified
- * @param $removed
+ * @param string[] $modified
+ * @param string[] $removed
*/
public function testNewFromRevisionSlots(
RevisionSlots $newSlots,
}
}
+ public function provideNewFromContent() {
+ $slotA = SlotRecord::newUnsaved( 'A', new WikitextContent( 'A' ) );
+ $slotB = SlotRecord::newUnsaved( 'B', new WikitextContent( 'B' ) );
+ $slotC = SlotRecord::newUnsaved( 'C', new WikitextContent( 'C' ) );
+
+ $parentSlots = new RevisionSlots( [
+ 'A' => $slotA,
+ 'B' => $slotB,
+ 'C' => $slotC,
+ ] );
+
+ $newContent = [
+ 'A' => new WikitextContent( 'A' ),
+ 'B' => new WikitextContent( 'B2' ),
+ ];
+
+ yield [ $newContent, null, [ 'A', 'B' ] ];
+ yield [ $newContent, $parentSlots, [ 'B' ] ];
+ }
+
+ /**
+ * @dataProvider provideNewFromContent
+ *
+ * @param Content[] $newContent
+ * @param RevisionSlots $parentSlots
+ * @param string[] $modified
+ */
+ public function testNewFromContent(
+ array $newContent,
+ RevisionSlots $parentSlots = null,
+ array $modified = []
+ ) {
+ $update = RevisionSlotsUpdate::newFromContent( $newContent, $parentSlots );
+
+ $this->assertEquals( $modified, $update->getModifiedRoles() );
+ $this->assertEmpty( $update->getRemovedRoles() );
+ }
+
public function testConstructor() {
$update = new RevisionSlotsUpdate();
$this->assertSame( $same, $b->hasSameUpdates( $a ) );
}
+ /**
+ * @param string $role
+ * @param Content $content
+ * @return SlotRecord
+ */
+ private function newSavedSlot( $role, Content $content ) {
+ return SlotRecord::newSaved( 7, 7, 'xyz', SlotRecord::newUnsaved( $role, $content ) );
+ }
+
+ public function testApplyUpdate() {
+ /** @var SlotRecord[] $parentSlots */
+ $parentSlots = [
+ 'X' => $this->newSavedSlot( 'X', new WikitextContent( 'X' ) ),
+ 'Y' => $this->newSavedSlot( 'Y', new WikitextContent( 'Y' ) ),
+ 'Z' => $this->newSavedSlot( 'Z', new WikitextContent( 'Z' ) ),
+ ];
+ $slots = MutableRevisionSlots::newFromParentRevisionSlots( $parentSlots );
+ $update = RevisionSlotsUpdate::newFromContent( [
+ 'A' => new WikitextContent( 'A' ),
+ 'Y' => new WikitextContent( 'yyy' ),
+ ] );
+
+ $update->removeSlot( 'Z' );
+
+ $update->apply( $slots );
+ $this->assertSame( [ 'X', 'Y', 'A' ], $slots->getSlotRoles() );
+ $this->assertSame( $update->getModifiedSlot( 'A' ), $slots->getSlot( 'A' ) );
+ $this->assertSame( $update->getModifiedSlot( 'Y' ), $slots->getSlot( 'Y' ) );
+ }
+
}
namespace MediaWiki\Tests\Storage;
use CommentStoreComment;
+use Content;
use Exception;
use HashBagOStuff;
use InvalidArgumentException;
*/
abstract class RevisionStoreDbTestBase extends MediaWikiTestCase {
+ /**
+ * @var Title
+ */
+ private $testPageTitle;
+
+ /**
+ * @var WikiPage
+ */
+ private $testPage;
+
/**
* @return int
*/
$this->overrideMwServices();
}
+ protected function addCoreDBData() {
+ // Blank out. This would fail with a modified schema, and we don't need it.
+ }
+
+ /**
+ * @return Title
+ */
+ protected function getTestPageTitle() {
+ if ( $this->testPageTitle ) {
+ return $this->testPageTitle;
+ }
+
+ $this->testPageTitle = Title::newFromText( 'UTPage-' . __CLASS__ );
+ return $this->testPageTitle;
+ }
+ /**
+ * @return WikiPage
+ */
+ protected function getTestPage() {
+ if ( $this->testPage ) {
+ return $this->testPage;
+ }
+
+ $title = $this->getTestPageTitle();
+ $this->testPage = WikiPage::factory( $title );
+
+ if ( !$this->testPage->exists() ) {
+ // Make sure we don't write to the live db.
+ $this->ensureMockDatabaseConnection( wfGetDB( DB_MASTER ) );
+
+ $user = static::getTestSysop()->getUser();
+
+ $this->testPage->doEditContent(
+ new WikitextContent( 'UTContent-' . __CLASS__ ),
+ 'UTPageSummary-' . __CLASS__,
+ EDIT_NEW | EDIT_SUPPRESS_RC,
+ false,
+ $user
+ );
+ }
+
+ return $this->testPage;
+ }
+
/**
* @return LoadBalancer|PHPUnit_Framework_MockObject_MockObject
*/
$blobStore,
new WANObjectCache( [ 'cache' => new HashBagOStuff() ] ),
MediaWikiServices::getInstance()->getCommentStore(),
+ MediaWikiServices::getInstance()->getContentModelStore(),
+ MediaWikiServices::getInstance()->getSlotRoleStore(),
+ $this->getMcrMigrationStage(),
MediaWikiServices::getInstance()->getActorMigration(),
$wikiId
);
}
private function assertRevisionRecordsEqual( RevisionRecord $r1, RevisionRecord $r2 ) {
+ $this->assertEquals(
+ $r1->getPageAsLinkTarget()->getNamespace(),
+ $r2->getPageAsLinkTarget()->getNamespace()
+ );
+
+ $this->assertEquals(
+ $r1->getPageAsLinkTarget()->getText(),
+ $r2->getPageAsLinkTarget()->getText()
+ );
+
+ if ( $r1->getParentId() ) {
+ $this->assertEquals( $r1->getParentId(), $r2->getParentId() );
+ }
+
$this->assertEquals( $r1->getUser()->getName(), $r2->getUser()->getName() );
$this->assertEquals( $r1->getUser()->getId(), $r2->getUser()->getId() );
$this->assertEquals( $r1->getComment(), $r2->getComment() );
- $this->assertEquals( $r1->getPageAsLinkTarget(), $r2->getPageAsLinkTarget() );
$this->assertEquals( $r1->getTimestamp(), $r2->getTimestamp() );
$this->assertEquals( $r1->getVisibility(), $r2->getVisibility() );
$this->assertEquals( $r1->getSha1(), $r2->getSha1() );
- $this->assertEquals( $r1->getParentId(), $r2->getParentId() );
$this->assertEquals( $r1->getSize(), $r2->getSize() );
$this->assertEquals( $r1->getPageId(), $r2->getPageId() );
$this->assertEquals( $r1->getSlotRoles(), $r2->getSlotRoles() );
}
private function assertRevisionCompleteness( RevisionRecord $r ) {
+ $this->assertTrue( $r->hasSlot( 'main' ) );
+ $this->assertInstanceOf( SlotRecord::class, $r->getSlot( 'main' ) );
+ $this->assertInstanceOf( Content::class, $r->getContent( 'main' ) );
+
foreach ( $r->getSlotRoles() as $role ) {
$this->assertSlotCompleteness( $r, $r->getSlot( $role ) );
}
private function assertSlotCompleteness( RevisionRecord $r, SlotRecord $slot ) {
$this->assertTrue( $slot->hasAddress() );
$this->assertSame( $r->getId(), $slot->getRevision() );
+
+ $this->assertInstanceOf( Content::class, $slot->getContent() );
}
/**
*
* @return RevisionRecord
*/
- private function getRevisionRecordFromDetailsArray( $title, $details = [] ) {
+ private function getRevisionRecordFromDetailsArray( $details = [] ) {
// Convert some values that can't be provided by dataProviders
- $page = WikiPage::factory( $title );
if ( isset( $details['user'] ) && $details['user'] === true ) {
$details['user'] = $this->getTestUser()->getUser();
}
if ( isset( $details['page'] ) && $details['page'] === true ) {
- $details['page'] = $page->getId();
+ $details['page'] = $this->getTestPage()->getId();
}
if ( isset( $details['parent'] ) && $details['parent'] === true ) {
- $details['parent'] = $page->getLatest();
+ $details['parent'] = $this->getTestPage()->getLatest();
}
// Create the RevisionRecord with any available data
- $rev = new MutableRevisionRecord( $title );
+ $rev = new MutableRevisionRecord( $this->getTestPageTitle() );
isset( $details['slot'] ) ? $rev->setSlot( $details['slot'] ) : null;
isset( $details['parent'] ) ? $rev->setParentId( $details['parent'] ) : null;
isset( $details['page'] ) ? $rev->setPageId( $details['page'] ) : null;
isset( $details['visibility'] ) ? $rev->setVisibility( $details['visibility'] ) : null;
isset( $details['id'] ) ? $rev->setId( $details['id'] ) : null;
+ if ( isset( $details['content'] ) ) {
+ foreach ( $details['content'] as $role => $content ) {
+ $rev->setContent( $role, $content );
+ }
+ }
+
return $rev;
}
public function provideInsertRevisionOn_successes() {
yield 'Bare minimum revision insertion' => [
- Title::newFromText( 'UTPage' ),
[
'slot' => SlotRecord::newUnsaved( 'main', new WikitextContent( 'Chicken' ) ),
- 'parent' => true,
+ 'page' => true,
'comment' => $this->getRandomCommentStoreComment(),
'timestamp' => '20171117010101',
'user' => true,
],
];
yield 'Detailed revision insertion' => [
- Title::newFromText( 'UTPage' ),
[
'slot' => SlotRecord::newUnsaved( 'main', new WikitextContent( 'Chicken' ) ),
'parent' => true,
];
}
- private function getRandomCommentStoreComment() {
+ protected function getRandomCommentStoreComment() {
return CommentStoreComment::newUnsavedComment( __METHOD__ . '.' . rand( 0, 1000 ) );
}
/**
* @dataProvider provideInsertRevisionOn_successes
* @covers \MediaWiki\Storage\RevisionStore::insertRevisionOn
+ * @covers \MediaWiki\Storage\RevisionStore::insertSlotRowOn
+ * @covers \MediaWiki\Storage\RevisionStore::insertContentRowOn
*/
public function testInsertRevisionOn_successes(
- Title $title,
array $revDetails = []
) {
- $rev = $this->getRevisionRecordFromDetailsArray( $title, $revDetails );
+ $title = $this->getTestPageTitle();
+ $rev = $this->getRevisionRecordFromDetailsArray( $revDetails );
$this->overrideMwServices();
$store = MediaWikiServices::getInstance()->getRevisionStore();
$return = $store->insertRevisionOn( $rev, wfGetDB( DB_MASTER ) );
+ // is the new revision correct?
+ $this->assertRevisionCompleteness( $return );
$this->assertLinkTargetsEqual( $title, $return->getPageAsLinkTarget() );
$this->assertRevisionRecordsEqual( $rev, $return );
- $this->assertRevisionCompleteness( $return );
+
+ // can we load it from the store?
+ $loaded = $store->getRevisionById( $return->getId() );
+ $this->assertRevisionCompleteness( $loaded );
+ $this->assertRevisionRecordsEqual( $return, $loaded );
+
+ // can we find it directly in the database?
$this->assertRevisionExistsInDatabase( $return );
}
protected function assertRevisionExistsInDatabase( RevisionRecord $rev ) {
+ $row = $this->revisionToRow( new Revision( $rev ), [] );
+
+ // unset nulled fields
+ unset( $row->rev_content_model );
+ unset( $row->rev_content_format );
+
+ // unset fake fields
+ unset( $row->rev_comment_text );
+ unset( $row->rev_comment_data );
+ unset( $row->rev_comment_cid );
+ unset( $row->rev_comment_id );
+
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $queryInfo = $store->getQueryInfo( [ 'user' ] );
+
+ $row = get_object_vars( $row );
$this->assertSelect(
- 'revision', [ 'count(*)' ], [ 'rev_id' => $rev->getId() ], [ [ '1' ] ]
+ $queryInfo['tables'],
+ array_keys( $row ),
+ [ 'rev_id' => $rev->getId() ],
+ [ array_values( $row ) ],
+ [],
+ $queryInfo['joins']
);
}
* @covers \MediaWiki\Storage\RevisionStore::insertRevisionOn
*/
public function testInsertRevisionOn_blobAddressExists() {
- $title = Title::newFromText( 'UTPage' );
+ $title = $this->getTestPageTitle();
$revDetails = [
'slot' => SlotRecord::newUnsaved( 'main', new WikitextContent( 'Chicken' ) ),
'parent' => true,
$store = MediaWikiServices::getInstance()->getRevisionStore();
// Insert the first revision
- $revOne = $this->getRevisionRecordFromDetailsArray( $title, $revDetails );
+ $revOne = $this->getRevisionRecordFromDetailsArray( $revDetails );
$firstReturn = $store->insertRevisionOn( $revOne, wfGetDB( DB_MASTER ) );
$this->assertLinkTargetsEqual( $title, $firstReturn->getPageAsLinkTarget() );
$this->assertRevisionRecordsEqual( $revOne, $firstReturn );
// Insert a second revision inheriting the same blob address
$revDetails['slot'] = SlotRecord::newInherited( $firstReturn->getSlot( 'main' ) );
- $revTwo = $this->getRevisionRecordFromDetailsArray( $title, $revDetails );
+ $revTwo = $this->getRevisionRecordFromDetailsArray( $revDetails );
$secondReturn = $store->insertRevisionOn( $revTwo, wfGetDB( DB_MASTER ) );
$this->assertLinkTargetsEqual( $title, $secondReturn->getPageAsLinkTarget() );
$this->assertRevisionRecordsEqual( $revTwo, $secondReturn );
public function provideInsertRevisionOn_failures() {
yield 'no slot' => [
- Title::newFromText( 'UTPage' ),
[
'comment' => $this->getRandomCommentStoreComment(),
'timestamp' => '20171117010101',
'user' => true,
],
- new InvalidArgumentException( 'At least one slot needs to be defined!' )
+ new InvalidArgumentException( 'main slot must be provided' )
];
- yield 'slot that is not main slot' => [
- Title::newFromText( 'UTPage' ),
+ yield 'no main slot' => [
[
- 'slot' => SlotRecord::newUnsaved( 'lalala', new WikitextContent( 'Chicken' ) ),
+ 'slot' => SlotRecord::newUnsaved( 'aux', new WikitextContent( 'Turkey' ) ),
'comment' => $this->getRandomCommentStoreComment(),
'timestamp' => '20171117010101',
'user' => true,
],
- new InvalidArgumentException( 'Only the main slot is supported for now!' )
+ new InvalidArgumentException( 'main slot must be provided' )
];
yield 'no timestamp' => [
- Title::newFromText( 'UTPage' ),
[
'slot' => SlotRecord::newUnsaved( 'main', new WikitextContent( 'Chicken' ) ),
'comment' => $this->getRandomCommentStoreComment(),
new IncompleteRevisionException( 'timestamp field must not be NULL!' )
];
yield 'no comment' => [
- Title::newFromText( 'UTPage' ),
[
'slot' => SlotRecord::newUnsaved( 'main', new WikitextContent( 'Chicken' ) ),
'timestamp' => '20171117010101',
new IncompleteRevisionException( 'comment must not be NULL!' )
];
yield 'no user' => [
- Title::newFromText( 'UTPage' ),
[
'slot' => SlotRecord::newUnsaved( 'main', new WikitextContent( 'Chicken' ) ),
'comment' => $this->getRandomCommentStoreComment(),
* @covers \MediaWiki\Storage\RevisionStore::insertRevisionOn
*/
public function testInsertRevisionOn_failures(
- Title $title,
array $revDetails = [],
Exception $exception
) {
- $rev = $this->getRevisionRecordFromDetailsArray( $title, $revDetails );
+ $rev = $this->getRevisionRecordFromDetailsArray( $revDetails );
$store = MediaWikiServices::getInstance()->getRevisionStore();
public function provideNewNullRevision() {
yield [
Title::newFromText( 'UTPage_notAutoCreated' ),
+ [ 'content' => [ 'main' => new WikitextContent( 'Flubber1' ) ] ],
CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment1' ),
true,
];
yield [
Title::newFromText( 'UTPage_notAutoCreated' ),
+ [ 'content' => [ 'main' => new WikitextContent( 'Flubber2' ) ] ],
CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment2', [ 'a' => 1 ] ),
false,
];
/**
* @dataProvider provideNewNullRevision
* @covers \MediaWiki\Storage\RevisionStore::newNullRevision
+ * @covers \MediaWiki\Storage\RevisionStore::findSlotContentId
*/
- public function testNewNullRevision( Title $title, $comment, $minor ) {
+ public function testNewNullRevision( Title $title, $revDetails, $comment, $minor = false ) {
$this->overrideMwServices();
+ $user = TestUserRegistry::getMutableTestUser( __METHOD__ )->getUser();
$page = WikiPage::factory( $title );
- $status = $page->doEditContent(
- new WikitextContent( __METHOD__ ),
- __METHOD__,
- 0,
- false
- );
- /** @var Revision $rev */
- $rev = $status->value['revision'];
+ if ( !$page->exists() ) {
+ $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__, EDIT_NEW );
+ }
+
+ $revDetails['page'] = $page->getId();
+ $revDetails['timestamp'] = wfTimestampNow();
+ $revDetails['comment'] = CommentStoreComment::newUnsavedComment( 'Base' );
+ $revDetails['user'] = $user;
+
+ $baseRev = $this->getRevisionRecordFromDetailsArray( $revDetails );
$store = MediaWikiServices::getInstance()->getRevisionStore();
- $user = TestUserRegistry::getMutableTestUser( __METHOD__ )->getUser();
- $parent = $store->getRevisionById( $rev->getId() );
+ $dbw = wfGetDB( DB_MASTER );
+ $baseRev = $store->insertRevisionOn( $baseRev, $dbw );
+ $page->updateRevisionOn( $dbw, new Revision( $baseRev ), $page->getLatest() );
+
$record = $store->newNullRevision(
wfGetDB( DB_MASTER ),
$title,
$this->assertEquals( $comment, $record->getComment() );
$this->assertEquals( $minor, $record->isMinor() );
$this->assertEquals( $user->getName(), $record->getUser()->getName() );
- $this->assertEquals( $parent->getId(), $record->getParentId() );
+ $this->assertEquals( $baseRev->getId(), $record->getParentId() );
+
+ $this->assertArrayEqualsIgnoringIntKeyOrder(
+ $baseRev->getSlotRoles(),
+ $record->getSlotRoles()
+ );
- $parentSlot = $parent->getSlot( 'main' );
- $slot = $record->getSlot( 'main' );
+ foreach ( $baseRev->getSlotRoles() as $role ) {
+ $parentSlot = $baseRev->getSlot( $role );
+ $slot = $record->getSlot( $role );
- $this->assertTrue( $slot->isInherited(), 'isInherited' );
- $this->assertSame( $parentSlot->getOrigin(), $slot->getOrigin(), 'getOrigin' );
- $this->assertSameSlotContent( $parentSlot, $slot );
+ $this->assertTrue( $slot->isInherited(), 'isInherited' );
+ $this->assertSame( $parentSlot->getOrigin(), $slot->getOrigin(), 'getOrigin' );
+ $this->assertSameSlotContent( $parentSlot, $slot );
+ }
}
/**
* @covers \MediaWiki\Storage\RevisionStore::getRcIdIfUnpatrolled
*/
public function testGetRcIdIfUnpatrolled_returnsRecentChangesId() {
- $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $page = $this->getTestPage();
$status = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ );
/** @var Revision $rev */
$rev = $status->value['revision'];
$this->assertGreaterThan( 0, $result );
$this->assertSame(
- $page->getRevision()->getRecentChange()->getAttribute( 'rc_id' ),
+ $store->getRecentChange( $revisionRecord )->getAttribute( 'rc_id' ),
$result
);
}
public function testGetRcIdIfUnpatrolled_returnsZeroIfPatrolled() {
// This assumes that sysops are auto patrolled
$sysop = $this->getTestSysop()->getUser();
- $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $page = $this->getTestPage();
$status = $page->doEditContent(
new WikitextContent( __METHOD__ ),
__METHOD__,
* @covers \MediaWiki\Storage\RevisionStore::getRecentChange
*/
public function testGetRecentChange() {
- $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $page = $this->getTestPage();
$content = new WikitextContent( __METHOD__ );
$status = $page->doEditContent( $content, __METHOD__ );
/** @var Revision $rev */
* @covers \MediaWiki\Storage\RevisionStore::getRevisionById
*/
public function testGetRevisionById() {
- $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $page = $this->getTestPage();
$content = new WikitextContent( __METHOD__ );
$status = $page->doEditContent( $content, __METHOD__ );
/** @var Revision $rev */
* @covers \MediaWiki\Storage\RevisionStore::getRevisionByTitle
*/
public function testGetRevisionByTitle() {
- $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $page = $this->getTestPage();
$content = new WikitextContent( __METHOD__ );
$status = $page->doEditContent( $content, __METHOD__ );
/** @var Revision $rev */
* @covers \MediaWiki\Storage\RevisionStore::getRevisionByPageId
*/
public function testGetRevisionByPageId() {
- $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $page = $this->getTestPage();
$content = new WikitextContent( __METHOD__ );
$status = $page->doEditContent( $content, __METHOD__ );
/** @var Revision $rev */
// Make sure there is 1 second between the last revision and the rev we create...
// Otherwise we might not get the correct revision and the test may fail...
// :(
+ $page = $this->getTestPage();
sleep( 1 );
- $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
$content = new WikitextContent( __METHOD__ );
$status = $page->doEditContent( $content, __METHOD__ );
/** @var Revision $rev */
$this->assertSame( __METHOD__, $revRecord->getComment()->text );
}
- protected function revisionToRow( Revision $rev ) {
+ protected function revisionToRow( Revision $rev, $options = [ 'page', 'user', 'comment' ] ) {
+ // XXX: the WikiPage object loads another RevisionRecord from the database. Not great.
$page = WikiPage::factory( $rev->getTitle() );
- return (object)[
+ $fields = [
'rev_id' => (string)$rev->getId(),
'rev_page' => (string)$rev->getPage(),
- 'rev_text_id' => (string)$rev->getTextId(),
'rev_timestamp' => $this->db->timestamp( $rev->getTimestamp() ),
'rev_user_text' => (string)$rev->getUserText(),
'rev_user' => (string)$rev->getUser(),
'rev_len' => (string)$rev->getSize(),
'rev_parent_id' => (string)$rev->getParentId(),
'rev_sha1' => (string)$rev->getSha1(),
- 'rev_comment_text' => $rev->getComment(),
- 'rev_comment_data' => null,
- 'rev_comment_cid' => null,
- 'rev_content_format' => $rev->getContentFormat(),
- 'rev_content_model' => $rev->getContentModel(),
- 'page_namespace' => (string)$page->getTitle()->getNamespace(),
- 'page_title' => $page->getTitle()->getDBkey(),
- 'page_id' => (string)$page->getId(),
- 'page_latest' => (string)$page->getLatest(),
- 'page_is_redirect' => $page->isRedirect() ? '1' : '0',
- 'page_len' => (string)$page->getContent()->getSize(),
- 'user_name' => (string)$rev->getUserText(),
];
+
+ if ( in_array( 'page', $options ) ) {
+ $fields += [
+ 'page_namespace' => (string)$page->getTitle()->getNamespace(),
+ 'page_title' => $page->getTitle()->getDBkey(),
+ 'page_id' => (string)$page->getId(),
+ 'page_latest' => (string)$page->getLatest(),
+ 'page_is_redirect' => $page->isRedirect() ? '1' : '0',
+ 'page_len' => (string)$page->getContent()->getSize(),
+ ];
+ }
+
+ if ( in_array( 'user', $options ) ) {
+ $fields += [
+ 'user_name' => (string)$rev->getUserText(),
+ ];
+ }
+
+ if ( in_array( 'comment', $options ) ) {
+ $fields += [
+ 'rev_comment_text' => $rev->getComment(),
+ 'rev_comment_data' => null,
+ 'rev_comment_cid' => null,
+ ];
+ }
+
+ if ( $rev->getId() ) {
+ $fields += [
+ 'rev_id' => (string)$rev->getId(),
+ ];
+ }
+
+ return (object)$fields;
}
private function assertRevisionRecordMatchesRevision(
$this->assertSame( $rev->getContentFormat(), $record->getContent( 'main' )->getDefaultFormat() );
$this->assertSame( $rev->getContentModel(), $record->getContent( 'main' )->getModel() );
$this->assertLinkTargetsEqual( $rev->getTitle(), $record->getPageAsLinkTarget() );
+
+ $revRec = $rev->getRevisionRecord();
+ $revMain = $revRec->getSlot( 'main' );
+ $recMain = $record->getSlot( 'main' );
+
+ $this->assertSame( $revMain->hasOrigin(), $recMain->hasOrigin(), 'hasOrigin' );
+ $this->assertSame( $revMain->hasAddress(), $recMain->hasAddress(), 'hasAddress' );
+ $this->assertSame( $revMain->hasContentId(), $recMain->hasContentId(), 'hasContentId' );
+
+ if ( $revMain->hasOrigin() ) {
+ $this->assertSame( $revMain->getOrigin(), $recMain->getOrigin(), 'getOrigin' );
+ }
+
+ if ( $revMain->hasAddress() ) {
+ $this->assertSame( $revMain->getAddress(), $recMain->getAddress(), 'getAddress' );
+ }
+
+ if ( $revMain->hasContentId() ) {
+ $this->assertSame( $revMain->getContentId(), $recMain->getContentId(), 'getContentId' );
+ }
}
/**
* @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow
- * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow_1_29
*/
public function testNewRevisionFromRow_anonEdit() {
- $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $page = $this->getTestPage();
$text = __METHOD__ . 'a-ä';
/** @var Revision $rev */
$rev = $page->doEditContent(
/**
* @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow
- * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow_1_29
*/
public function testNewRevisionFromRow_anonEdit_legacyEncoding() {
$this->setMwGlobals( 'wgLegacyEncoding', 'windows-1252' );
$this->overrideMwServices();
- $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $page = $this->getTestPage();
$text = __METHOD__ . 'a-ä';
/** @var Revision $rev */
$rev = $page->doEditContent(
/**
* @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow
- * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow_1_29
*/
public function testNewRevisionFromRow_userEdit() {
- $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $page = $this->getTestPage();
$text = __METHOD__ . 'b-ä';
/** @var Revision $rev */
$rev = $page->doEditContent(
$this->assertSame( $text, $record->getContent( 'main' )->serialize() );
}
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::insertRevisionOn
+ */
+ public function testInsertRevisionOn_archive() {
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+ $title = Title::newFromText( __METHOD__ );
+
+ $page = WikiPage::factory( $title );
+ /** @var Revision $origRev */
+ $page->doEditContent( new WikitextContent( "First" ), __METHOD__ . '-first' );
+ $origRev = $page->doEditContent( new WikitextContent( "Foo" ), __METHOD__ )
+ ->value['revision'];
+ $orig = $origRev->getRevisionRecord();
+ $page->doDeleteArticle( __METHOD__ );
+
+ $db = wfGetDB( DB_MASTER );
+ $arQuery = $store->getArchiveQueryInfo();
+ $row = $db->selectRow(
+ $arQuery['tables'], $arQuery['fields'], [ 'ar_rev_id' => $orig->getId() ],
+ __METHOD__, [], $arQuery['joins']
+ );
+
+ $record = $store->newRevisionFromArchiveRow( $row );
+
+ $restored = $store->insertRevisionOn( $record, $db );
+ $this->assertSame( $orig->getPageId(), $restored->getPageId() );
+ $this->assertSame( $orig->getId(), $restored->getId() );
+ $this->assertSame( $orig->getComment()->text, $restored->getComment()->text );
+
+ $origMain = $orig->getSlot( 'main' );
+ $restoredMain = $restored->getSlot( 'main' );
+ $this->assertSame(
+ $origMain->getOrigin(),
+ $restoredMain->getOrigin()
+ );
+
+ if ( $origMain->hasContentId() ) {
+ $this->assertSame(
+ $origMain->getContentId(),
+ $restoredMain->getContentId()
+ );
+ }
+
+ // NOTE: we didn't restore the page row, so we can't use RevisionStore::getRevisionById
+ $this->assertSelect(
+ 'revision',
+ [ 'rev_id' ],
+ [ 'rev_id' => $orig->getId() ],
+ [ [ $orig->getId() ] ]
+ );
+ }
+
/**
* @covers \MediaWiki\Storage\RevisionStore::loadRevisionFromId
*/
* @covers \MediaWiki\Storage\RevisionStore::getTimestampFromId
*/
public function testGetTimestampFromId_found() {
- $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $page = $this->getTestPage();
/** @var Revision $rev */
$rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
->value['revision'];
* @covers \MediaWiki\Storage\RevisionStore::getTimestampFromId
*/
public function testGetTimestampFromId_notFound() {
- $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $page = $this->getTestPage();
/** @var Revision $rev */
$rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
->value['revision'];
*/
public function testUserWasLastToEdit_false() {
$sysop = $this->getTestSysop()->getUser();
- $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $page = $this->getTestPage();
$page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ );
$store = MediaWikiServices::getInstance()->getRevisionStore();
public function testUserWasLastToEdit_true() {
$startTime = wfTimestampNow();
$sysop = $this->getTestSysop()->getUser();
- $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $page = $this->getTestPage();
$page->doEditContent(
new WikitextContent( __METHOD__ ),
__METHOD__,
* @covers \MediaWiki\Storage\RevisionStore::getKnownCurrentRevision
*/
public function testGetKnownCurrentRevision() {
- $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+ $page = $this->getTestPage();
/** @var Revision $rev */
$rev = $page->doEditContent(
new WikitextContent( __METHOD__ . 'b' ),
}
public function provideNewMutableRevisionFromArray() {
- yield 'Basic array, with page & id' => [
- [
- 'id' => 2,
- 'page' => 1,
- 'text_id' => 2,
- 'timestamp' => '20171017114835',
- 'user_text' => '111.0.1.2',
- 'user' => 0,
- 'minor_edit' => false,
- 'deleted' => 0,
- 'len' => 46,
- 'parent_id' => 1,
- 'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
- 'comment' => 'Goat Comment!',
- 'content_format' => 'text/x-wiki',
- 'content_model' => 'wikitext',
- ]
- ];
yield 'Basic array, content object' => [
[
'id' => 2,
yield 'Basic array, with title' => [
[
'title' => Title::newFromText( 'SomeText' ),
- 'text_id' => 2,
'timestamp' => '20171017114835',
'user_text' => '111.0.1.2',
'user' => 0,
'parent_id' => 1,
'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
'comment' => 'Goat Comment!',
- 'content_format' => 'text/x-wiki',
- 'content_model' => 'wikitext',
+ 'content' => new WikitextContent( 'Some Content' ),
]
];
yield 'Basic array, no user field' => [
[
'id' => 2,
'page' => 1,
- 'text_id' => 2,
'timestamp' => '20171017114835',
'user_text' => '111.0.1.3',
'minor_edit' => false,
'parent_id' => 1,
'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
'comment' => 'Goat Comment!',
- 'content_format' => 'text/x-wiki',
- 'content_model' => 'wikitext',
+ 'content' => new WikitextContent( 'Some Content' ),
]
];
}
public function testNewMutableRevisionFromArray( array $array ) {
$store = MediaWikiServices::getInstance()->getRevisionStore();
+ // HACK: if $array['page'] is given, make sure that the page exists
+ if ( isset( $array['page'] ) ) {
+ $t = Title::newFromID( $array['page'] );
+ if ( !$t || !$t->exists() ) {
+ $t = Title::makeTitle( NS_MAIN, __METHOD__ );
+ $info = $this->insertPage( $t );
+ $array['page'] = $info['id'];
+ }
+ }
+
$result = $store->newMutableRevisionFromArray( $array );
if ( isset( $array['id'] ) ) {
$this->assertSame( $array['sha1'], $result->getSha1() );
$this->assertSame( $array['comment'], $result->getComment()->text );
if ( isset( $array['content'] ) ) {
- $this->assertTrue(
- $result->getSlot( 'main' )->getContent()->equals( $array['content'] )
- );
+ foreach ( $array['content'] as $role => $content ) {
+ $this->assertTrue(
+ $result->getContent( $role )->equals( $content )
+ );
+ }
} elseif ( isset( $array['text'] ) ) {
$this->assertSame( $array['text'], $result->getSlot( 'main' )->getContent()->serialize() );
- } else {
+ } elseif ( isset( $array['content_format'] ) ) {
$this->assertSame(
$array['content_format'],
$result->getSlot( 'main' )->getContent()->getDefaultFormat()
);
}
+ abstract public function provideGetSlotsQueryInfo();
+
+ /**
+ * @dataProvider provideGetSlotsQueryInfo
+ * @covers \MediaWiki\Storage\RevisionStore::getSlotsQueryInfo
+ */
+ public function testGetSlotsQueryInfo( $options, $expected ) {
+ $store = MediaWikiServices::getInstance()->getRevisionStore();
+
+ $archiveQueryInfo = $store->getSlotsQueryInfo( $options );
+
+ $this->assertArrayEqualsIgnoringIntKeyOrder(
+ $expected['tables'],
+ $archiveQueryInfo['tables']
+ );
+
+ $this->assertArrayEqualsIgnoringIntKeyOrder(
+ $expected['fields'],
+ $archiveQueryInfo['fields']
+ );
+
+ $this->assertArrayEqualsIgnoringIntKeyOrder(
+ $expected['joins'],
+ $archiveQueryInfo['joins']
+ );
+ }
+
/**
* Assert that the two arrays passed are equal, ignoring the order of the values that integer
* keys.
$this->assertSame( $user, $rec->getUser( RevisionRecord::RAW ), 'getUser' );
$this->assertSame( $comment, $rec->getComment(), 'getComment' );
+ $this->assertSame( $slots, $rec->getSlots(), 'getSlots' );
$this->assertSame( $slots->getSlotRoles(), $rec->getSlotRoles(), 'getSlotRoles' );
+ $this->assertSame( $slots->getSlots(), $rec->getSlots()->getSlots(), 'getSlots' );
$this->assertSame( $wikiId, $rec->getWikiId(), 'getWikiId' );
$this->assertSame( (int)$row->rev_id, $rec->getId(), 'getId' );
namespace MediaWiki\Tests\Storage;
+use CommentStore;
use HashBagOStuff;
+use InvalidArgumentException;
use Language;
use MediaWiki\MediaWikiServices;
use MediaWiki\Storage\RevisionAccessException;
use MediaWiki\Storage\RevisionStore;
use MediaWiki\Storage\SqlBlobStore;
use MediaWikiTestCase;
+use MWException;
use Title;
use WANObjectCache;
use Wikimedia\Rdbms\Database;
use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\TestingAccessWrapper;
class RevisionStoreTest extends MediaWikiTestCase {
$blobStore = null,
$WANObjectCache = null
) {
+ global $wgMultiContentRevisionSchemaMigrationStage;
+ // the migration stage should be irrelevant, since all the tests that interact with
+ // the database are in RevisionStoreDbTest, not here.
+
return new RevisionStore(
$loadBalancer ?: $this->getMockLoadBalancer(),
$blobStore ?: $this->getMockSqlBlobStore(),
$WANObjectCache ?: $this->getHashWANObjectCache(),
MediaWikiServices::getInstance()->getCommentStore(),
+ MediaWikiServices::getInstance()->getContentModelStore(),
+ MediaWikiServices::getInstance()->getSlotRoleStore(),
+ $wgMultiContentRevisionSchemaMigrationStage,
MediaWikiServices::getInstance()->getActorMigration()
);
}
->disableOriginalConstructor()->getMock();
}
- private function getHashWANObjectCache() {
- return new WANObjectCache( [ 'cache' => new \HashBagOStuff() ] );
- }
-
/**
- * @covers \MediaWiki\Storage\RevisionStore::getContentHandlerUseDB
- * @covers \MediaWiki\Storage\RevisionStore::setContentHandlerUseDB
+ * @return \PHPUnit_Framework_MockObject_MockObject|CommentStore
*/
- public function testGetSetContentHandlerDb() {
- $store = $this->getRevisionStore();
- $this->assertTrue( $store->getContentHandlerUseDB() );
- $store->setContentHandlerUseDB( false );
- $this->assertFalse( $store->getContentHandlerUseDB() );
- $store->setContentHandlerUseDB( true );
- $this->assertTrue( $store->getContentHandlerUseDB() );
- }
-
- private function getDefaultQueryFields() {
- return [
- 'rev_id',
- 'rev_page',
- 'rev_text_id',
- 'rev_timestamp',
- 'rev_minor_edit',
- 'rev_deleted',
- 'rev_len',
- 'rev_parent_id',
- 'rev_sha1',
- ];
- }
-
- private function getCommentQueryFields() {
- return [
- 'rev_comment_text' => 'rev_comment',
- 'rev_comment_data' => 'NULL',
- 'rev_comment_cid' => 'NULL',
- ];
+ private function getMockCommentStore() {
+ return $this->getMockBuilder( CommentStore::class )
+ ->disableOriginalConstructor()->getMock();
}
- private function getActorQueryFields() {
- return [
- 'rev_user' => 'rev_user',
- 'rev_user_text' => 'rev_user_text',
- 'rev_actor' => 'NULL',
- ];
+ private function getHashWANObjectCache() {
+ return new WANObjectCache( [ 'cache' => new \HashBagOStuff() ] );
}
- private function getContentHandlerQueryFields() {
+ public function provideSetContentHandlerUseDB() {
return [
- 'rev_content_format',
- 'rev_content_model',
- ];
- }
-
- public function provideGetQueryInfo() {
- yield [
- true,
- [],
- [
- 'tables' => [ 'revision' ],
- 'fields' => array_merge(
- $this->getDefaultQueryFields(),
- $this->getCommentQueryFields(),
- $this->getActorQueryFields(),
- $this->getContentHandlerQueryFields()
- ),
- 'joins' => [],
- ]
- ];
- yield [
- false,
- [],
- [
- 'tables' => [ 'revision' ],
- 'fields' => array_merge(
- $this->getDefaultQueryFields(),
- $this->getCommentQueryFields(),
- $this->getActorQueryFields()
- ),
- 'joins' => [],
- ]
- ];
- yield [
- false,
- [ 'page' ],
- [
- 'tables' => [ 'revision', 'page' ],
- 'fields' => array_merge(
- $this->getDefaultQueryFields(),
- $this->getCommentQueryFields(),
- $this->getActorQueryFields(),
- [
- 'page_namespace',
- 'page_title',
- 'page_id',
- 'page_latest',
- 'page_is_redirect',
- 'page_len',
- ]
- ),
- 'joins' => [
- 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
- ],
- ]
- ];
- yield [
- false,
- [ 'user' ],
- [
- 'tables' => [ 'revision', 'user' ],
- 'fields' => array_merge(
- $this->getDefaultQueryFields(),
- $this->getCommentQueryFields(),
- $this->getActorQueryFields(),
- [
- 'user_name',
- ]
- ),
- 'joins' => [
- 'user' => [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ],
- ],
- ]
- ];
- yield [
- false,
- [ 'text' ],
- [
- 'tables' => [ 'revision', 'text' ],
- 'fields' => array_merge(
- $this->getDefaultQueryFields(),
- $this->getCommentQueryFields(),
- $this->getActorQueryFields(),
- [
- 'old_text',
- 'old_flags',
- ]
- ),
- 'joins' => [
- 'text' => [ 'INNER JOIN', [ 'rev_text_id=old_id' ] ],
- ],
- ]
- ];
- yield [
- true,
- [ 'page', 'user', 'text' ],
- [
- 'tables' => [ 'revision', 'page', 'user', 'text' ],
- 'fields' => array_merge(
- $this->getDefaultQueryFields(),
- $this->getCommentQueryFields(),
- $this->getActorQueryFields(),
- $this->getContentHandlerQueryFields(),
- [
- 'page_namespace',
- 'page_title',
- 'page_id',
- 'page_latest',
- 'page_is_redirect',
- 'page_len',
- 'user_name',
- 'old_text',
- 'old_flags',
- ]
- ),
- 'joins' => [
- 'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
- 'user' => [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ],
- 'text' => [ 'INNER JOIN', [ 'rev_text_id=old_id' ] ],
- ],
- ]
+ // ContentHandlerUseDB can be true of false pre migration
+ [ false, MIGRATION_OLD, false ],
+ [ true, MIGRATION_OLD, false ],
+ // During migration it can not be false
+ [ false, MIGRATION_WRITE_BOTH, true ],
+ // But it can be true
+ [ true, MIGRATION_WRITE_BOTH, false ],
];
}
/**
- * @dataProvider provideGetQueryInfo
- * @covers \MediaWiki\Storage\RevisionStore::getQueryInfo
+ * @dataProvider provideSetContentHandlerUseDB
+ * @covers \MediaWiki\Storage\RevisionStore::getContentHandlerUseDB
+ * @covers \MediaWiki\Storage\RevisionStore::setContentHandlerUseDB
*/
- public function testGetQueryInfo( $contentHandlerUseDb, $options, $expected ) {
- $this->setMwGlobals( 'wgCommentTableSchemaMigrationStage', MIGRATION_OLD );
- $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', MIGRATION_OLD );
- $this->overrideMwServices();
- $store = $this->getRevisionStore();
- $store->setContentHandlerUseDB( $contentHandlerUseDb );
- $this->assertEquals( $expected, $store->getQueryInfo( $options ) );
+ public function testSetContentHandlerUseDB( $contentHandlerDb, $migrationMode, $expectedFail ) {
+ if ( $expectedFail ) {
+ $this->setExpectedException( MWException::class );
+ }
+
+ $store = new RevisionStore(
+ $this->getMockLoadBalancer(),
+ $this->getMockSqlBlobStore(),
+ $this->getHashWANObjectCache(),
+ $this->getMockCommentStore(),
+ MediaWikiServices::getInstance()->getContentModelStore(),
+ MediaWikiServices::getInstance()->getSlotRoleStore(),
+ $migrationMode,
+ MediaWikiServices::getInstance()->getActorMigration()
+ );
+
+ $store->setContentHandlerUseDB( $contentHandlerDb );
+ $this->assertSame( $contentHandlerDb, $store->getContentHandlerUseDB() );
}
public function testGetTitle_successFromPageId() {
* @dataProvider provideNewRevisionFromRow_legacyEncoding_applied
*
* @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow
- * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow_1_29
*/
public function testNewRevisionFromRow_legacyEncoding_applied( $encoding, $locale, $row, $text ) {
$cache = new WANObjectCache( [ 'cache' => new HashBagOStuff() ] );
/**
* @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow
- * @covers \MediaWiki\Storage\RevisionStore::newRevisionFromRow_1_29
*/
public function testNewRevisionFromRow_legacyEncoding_ignored() {
$row = [
return (object)$row;
}
+ public function provideMigrationConstruction() {
+ return [
+ [ MIGRATION_OLD, false ],
+ [ MIGRATION_WRITE_BOTH, false ],
+ ];
+ }
+
+ /**
+ * @covers \MediaWiki\Storage\RevisionStore::__construct
+ * @dataProvider provideMigrationConstruction
+ */
+ public function testMigrationConstruction( $migration, $expectException ) {
+ if ( $expectException ) {
+ $this->setExpectedException( InvalidArgumentException::class );
+ }
+ $loadBalancer = $this->getMockLoadBalancer();
+ $blobStore = $this->getMockSqlBlobStore();
+ $cache = $this->getHashWANObjectCache();
+ $commentStore = $this->getMockCommentStore();
+ $contentModelStore = MediaWikiServices::getInstance()->getContentModelStore();
+ $slotRoleStore = MediaWikiServices::getInstance()->getSlotRoleStore();
+ $store = new RevisionStore(
+ $loadBalancer,
+ $blobStore,
+ $cache,
+ $commentStore,
+ MediaWikiServices::getInstance()->getContentModelStore(),
+ MediaWikiServices::getInstance()->getSlotRoleStore(),
+ $migration,
+ MediaWikiServices::getInstance()->getActorMigration()
+ );
+ if ( !$expectException ) {
+ $store = TestingAccessWrapper::newFromObject( $store );
+ $this->assertSame( $loadBalancer, $store->loadBalancer );
+ $this->assertSame( $blobStore, $store->blobStore );
+ $this->assertSame( $cache, $store->cache );
+ $this->assertSame( $commentStore, $store->commentStore );
+ $this->assertSame( $contentModelStore, $store->contentModelStore );
+ $this->assertSame( $slotRoleStore, $store->slotRoleStore );
+ $this->assertSame( $migration, $store->mcrMigrationStage );
+ }
+ }
+
}
$record = new SlotRecord( $row, new WikitextContent( 'A' ) );
$this->assertTrue( $record->hasAddress() );
+ $this->assertTrue( $record->hasContentId() );
$this->assertTrue( $record->hasRevision() );
$this->assertTrue( $record->isInherited() );
$this->assertSame( 'A', $record->getContent()->getNativeData() );
},
'slot_revision_id' => '2',
'slot_origin' => '2',
+ 'slot_content_id' => function () {
+ return null;
+ },
] );
$content = function () {
$this->assertTrue( $record->hasAddress() );
$this->assertTrue( $record->hasRevision() );
+ $this->assertFalse( $record->hasContentId() );
$this->assertFalse( $record->isInherited() );
$this->assertSame( 'A', $record->getContent()->getNativeData() );
$this->assertSame( 1, $record->getSize() );
$this->assertSame( 2, $record->getRevision() );
$this->assertSame( 2, $record->getRevision() );
$this->assertSame( 'tt:456', $record->getAddress() );
- $this->assertSame( 33, $record->getContentId() );
$this->assertSame( CONTENT_FORMAT_WIKITEXT, $record->getFormat() );
$this->assertSame( 'myRole', $record->getRole() );
}
$record = SlotRecord::newUnsaved( 'myRole', new WikitextContent( 'A' ) );
$this->assertFalse( $record->hasAddress() );
+ $this->assertFalse( $record->hasContentId() );
$this->assertFalse( $record->hasRevision() );
$this->assertFalse( $record->isInherited() );
+ $this->assertFalse( $record->hasOrigin() );
$this->assertSame( 'A', $record->getContent()->getNativeData() );
$this->assertSame( 1, $record->getSize() );
$this->assertNotNull( $record->getSha1() );
$this->assertSame( $parent->getAddress(), $inherited->getAddress() );
$this->assertSame( $parent->getContent(), $inherited->getContent() );
$this->assertTrue( $inherited->isInherited() );
+ $this->assertTrue( $inherited->hasOrigin() );
$this->assertFalse( $inherited->hasRevision() );
// make sure we didn't mess with the internal state of $parent
// and content meta-data.
$saved = SlotRecord::newSaved( 10, 20, 'theNewAddress', $unsaved );
$this->assertFalse( $saved->isInherited() );
+ $this->assertTrue( $saved->hasOrigin() );
$this->assertTrue( $saved->hasRevision() );
$this->assertTrue( $saved->hasAddress() );
+ $this->assertTrue( $saved->hasContentId() );
$this->assertSame( 'theNewAddress', $saved->getAddress() );
$this->assertSame( 20, $saved->getContentId() );
$this->assertSame( 'A', $saved->getContent()->getNativeData() );
// make sure we didn't mess with the internal state of $unsaved
$this->assertFalse( $unsaved->hasAddress() );
+ $this->assertFalse( $unsaved->hasContentId() );
$this->assertFalse( $unsaved->hasRevision() );
}
--- /dev/null
+ALTER TABLE /*_*/revision DROP COLUMN rev_text_id;
+ALTER TABLE /*_*/revision DROP COLUMN rev_content_model;
+ALTER TABLE /*_*/revision DROP COLUMN rev_content_format;
--- /dev/null
+DROP TABLE /*_*/revision;
+
+CREATE TABLE /*_*/revision (
+ rev_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ rev_page INTEGER NOT NULL,
+ rev_comment BLOB NOT NULL,
+ rev_user INTEGER NOT NULL default 0,
+ rev_user_text varchar(255) NOT NULL default '',
+ rev_timestamp blob(14) NOT NULL default '',
+ rev_minor_edit INTEGER NOT NULL default 0,
+ rev_deleted INTEGER NOT NULL default 0,
+ rev_len INTEGER unsigned,
+ rev_parent_id INTEGER default NULL,
+ rev_sha1 varbinary(32) NOT NULL default ''
+) /*$wgDBTableOptions*/;
<?php
+use MediaWiki\MediaWikiServices;
+
/**
* @group ContentHandler
* @group Database
* @covers Title::clearCaches
*/
public function testClearCaches() {
- $linkCache = LinkCache::singleton();
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
$title1 = Title::newFromText( 'Foo' );
$linkCache->addGoodLinkObj( 23, $title1 );
<?php
+use MediaWiki\MediaWikiServices;
+
/**
* @group Database
* @group Title
*/
public function testExists() {
$title = Title::makeTitle( NS_PROJECT, 'New page' );
- $linkCache = LinkCache::singleton();
+ $linkCache = MediaWikiServices::getInstance()->getLinkCache();
$article = new Article( $title );
$page = $article->getPage();
}
public function testGetTitleOrPageIdPageId() {
+ $page = $this->getExistingTestPage();
$result = ( new MockApi() )->getTitleOrPageId(
- [ 'pageid' => Title::newFromText( 'UTPage' )->getArticleId() ] );
+ [ 'pageid' => $page->getId() ] );
$this->assertInstanceOf( WikiPage::class, $result );
- $this->assertSame( 'UTPage', $result->getTitle()->getPrefixedText() );
+ $this->assertSame(
+ $page->getTitle()->getPrefixedText(),
+ $result->getTitle()->getPrefixedText()
+ );
}
public function testGetTitleOrPageIdInvalidPageId() {
}
public function testGetTitleFromTitleOrPageIdPageId() {
+ $page = $this->getExistingTestPage();
$result = ( new MockApi() )->getTitleFromTitleOrPageId(
- [ 'pageid' => Title::newFromText( 'UTPage' )->getArticleId() ] );
+ [ 'pageid' => $page->getId() ] );
$this->assertInstanceOf( Title::class, $result );
- $this->assertSame( 'UTPage', $result->getPrefixedText() );
+ $this->assertSame( $page->getTitle()->getPrefixedText(), $result->getPrefixedText() );
}
public function testGetTitleFromTitleOrPageIdInvalidPageId() {
--- /dev/null
+<?php
+
+/**
+ * @group API
+ * @group medium
+ *
+ * @covers ApiQueryPrefixSearch
+ */
+class ApiQueryPrefixSearchTest extends ApiTestCase {
+ const TEST_QUERY = 'unittest';
+
+ public function setUp() {
+ parent::setUp();
+ $this->setMwGlobals( [
+ 'wgSearchType' => MockCompletionSearchEngine::class,
+ ] );
+ MockCompletionSearchEngine::clearMockResults();
+ $results = [];
+ foreach ( range( 0, 10 ) as $i ) {
+ $title = "Search_Result_$i";
+ $results[] = $title;
+ $this->editPage( $title, 'hi there' );
+ }
+ MockCompletionSearchEngine::addMockResults( self::TEST_QUERY, $results );
+ }
+
+ public function offsetContinueProvider() {
+ return [
+ 'no offset' => [ 2, 2, 0, 2 ],
+ 'with offset' => [ 7, 2, 5, 2 ],
+ 'past end, no offset' => [ null, 11, 0, 20 ],
+ 'past end, with offset' => [ null, 5, 6, 10 ],
+ ];
+ }
+
+ /**
+ * @dataProvider offsetContinueProvider
+ */
+ public function testOffsetContinue( $expectedOffset, $expectedResults, $offset, $limit ) {
+ $response = $this->doApiRequest( [
+ 'action' => 'query',
+ 'list' => 'prefixsearch',
+ 'pssearch' => self::TEST_QUERY,
+ 'psoffset' => $offset,
+ 'pslimit' => $limit,
+ ] );
+ $result = $response[0];
+ $this->assertArrayNotHasKey( 'warnings', $result );
+ $suggestions = $result['query']['prefixsearch'];
+ $this->assertCount( $expectedResults, $suggestions );
+ if ( $expectedOffset == null ) {
+ $this->assertArrayNotHasKey( 'continue', $result );
+ } else {
+ $this->assertArrayHasKey( 'continue', $result );
+ $this->assertEquals( $expectedOffset, $result['continue']['psoffset'] );
+ }
+ }
+}
User $patrollingUser
) {
$title = Title::newFromLinkTarget( $target );
+ $summary = CommentStoreComment::newUnsavedComment( trim( $summary ) );
$page = WikiPage::factory( $title );
- $status = $page->doEditContent(
- ContentHandler::makeContent( $content, $title ),
- $summary,
- 0,
- false,
- $user
- );
- /** @var Revision $rev */
- $rev = $status->value['revision'];
- $rc = $rev->getRecentChange();
+
+ $updater = $page->newPageUpdater( $user );
+ $updater->setContent( 'main', ContentHandler::makeContent( $content, $title ) );
+ $rev = $updater->saveRevision( $summary );
+
+ $rc = MediaWikiServices::getInstance()->getRevisionStore()->getRecentChange( $rev );
$rc->doMarkPatrolled( $patrollingUser, false, [] );
}
*/
public function testGetForTitle( $title, $expectedContentModel ) {
$title = Title::newFromText( $title );
- LinkCache::singleton()->addBadLinkObj( $title );
+ MediaWikiServices::getInstance()->getLinkCache()->addBadLinkObj( $title );
$handler = ContentHandler::getForTitle( $title );
$this->assertEquals( $expectedContentModel, $handler->getModelID() );
}
public function testGetPageLanguage( $title, $expected ) {
if ( is_string( $title ) ) {
$title = Title::newFromText( $title );
- LinkCache::singleton()->addBadLinkObj( $title );
+ MediaWikiServices::getInstance()->getLinkCache()->addBadLinkObj( $title );
}
$expected = wfGetLangObj( $expected );
$expectedModelId, $expectedNativeData, $shouldFail
) {
$title = Title::newFromText( $title );
- LinkCache::singleton()->addBadLinkObj( $title );
+ MediaWikiServices::getInstance()->getLinkCache()->addBadLinkObj( $title );
try {
$content = ContentHandler::makeContent( $data, $title, $modelId, $format );
'1@542#c47dcfb0566e7d7bc110a6128a45c93a',
LBFactory::makeCookieValueFromCPIndex( 1, 542, $agentId )
);
+
$this->assertSame(
- 5,
- LBFactory::getCPInfoFromCookieValue( "5", $time - 10 )['index'],
+ null,
+ LBFactory::getCPInfoFromCookieValue( "5#$agentId", $time - 10 )['index'],
'No time set'
);
$this->assertSame(
null,
- LBFactory::getCPInfoFromCookieValue( "0", $time - 10 )['index'],
+ LBFactory::getCPInfoFromCookieValue( "5@$time", $time - 10 )['index'],
+ 'No agent set'
+ );
+ $this->assertSame(
+ null,
+ LBFactory::getCPInfoFromCookieValue( "0@$time#$agentId", $time - 10 )['index'],
'Bad index'
);
$this->assertSame(
2,
- LBFactory::getCPInfoFromCookieValue( "2@$time", $time - 10 )['index'],
+ LBFactory::getCPInfoFromCookieValue( "2@$time#$agentId", $time - 10 )['index'],
'Fresh'
);
$this->assertSame(
2,
- LBFactory::getCPInfoFromCookieValue( "2@$time", $time + 9 - 10 )['index'],
+ LBFactory::getCPInfoFromCookieValue( "2@$time#$agentId", $time + 9 - 10 )['index'],
'Almost stale'
);
$this->assertSame(
null,
- LBFactory::getCPInfoFromCookieValue( "0@$time", $time + 9 - 10 )['index'],
+ LBFactory::getCPInfoFromCookieValue( "0@$time#$agentId", $time + 9 - 10 )['index'],
'Almost stale; bad index'
);
$this->assertSame(
null,
- LBFactory::getCPInfoFromCookieValue( "2@$time", $time + 11 - 10 )['index'],
+ LBFactory::getCPInfoFromCookieValue( "2@$time#$agentId", $time + 11 - 10 )['index'],
'Stale'
);
);
$this->assertSame(
null,
- LBFactory::getCPInfoFromCookieValue( "5@$time", $time + 11 - 10 )['clientId'],
+ LBFactory::getCPInfoFromCookieValue( "5@$time#$agentId", $time + 11 - 10 )['clientId'],
'Stale (client ID)'
);
}
. '(page does not exist)"><script>evil()</script></a>',
$linkRenderer->makeLink( $foobar, new HtmlArmor( '<script>evil()</script>' ) )
);
+
+ $this->assertEquals(
+ '<a href="#fragment">fragment</a>',
+ $linkRenderer->makeLink( Title::newFromText( '#fragment' ) )
+ );
}
public function testGetLinkClasses() {
<?php
+use MediaWiki\Storage\RevisionSlotsUpdate;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @covers WikiPage
+ */
abstract class WikiPageDbTestBase extends MediaWikiLangTestCase {
private $pagesToDelete;
'iwlinks' ] );
}
+ protected function addCoreDBData() {
+ // Blank out. This would fail with a modified schema, and we don't need it.
+ }
+
/**
* @return int
*/
*
* @return WikiPage
*/
- protected function createPage( $page, $text, $model = null ) {
+ protected function createPage( $page, $text, $model = null, $user = null ) {
if ( is_string( $page ) || $page instanceof Title ) {
$page = $this->newPage( $page, $model );
}
$content = ContentHandler::makeContent( $text, $page->getTitle(), $model );
- $page->doEditContent( $content, "testing", EDIT_NEW );
+ $page->doEditContent( $content, "testing", EDIT_NEW, false, $user );
return $page;
}
/**
- * @covers WikiPage::doEditContent
- * @covers WikiPage::doModify
- * @covers WikiPage::doCreate
+ * @covers WikiPage::prepareContentForEdit
+ */
+ public function testPrepareContentForEdit() {
+ $user = $this->getTestUser()->getUser();
+ $sysop = $this->getTestUser( [ 'sysop' ] )->getUser();
+
+ $page = $this->createPage( __METHOD__, __METHOD__, null, $user );
+ $title = $page->getTitle();
+
+ $content = ContentHandler::makeContent(
+ "[[Lorem ipsum]] dolor sit amet, consetetur sadipscing elitr, sed diam "
+ . " nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.",
+ $title,
+ CONTENT_MODEL_WIKITEXT
+ );
+ $content2 = ContentHandler::makeContent(
+ "At vero eos et accusam et justo duo [[dolores]] et ea rebum. "
+ . "Stet clita kasd [[gubergren]], no sea takimata sanctus est. ~~~~",
+ $title,
+ CONTENT_MODEL_WIKITEXT
+ );
+
+ $edit = $page->prepareContentForEdit( $content, null, $user, null, false );
+
+ $this->assertInstanceOf(
+ ParserOptions::class,
+ $edit->popts,
+ "pops"
+ );
+ $this->assertContains( '</a>', $edit->output->getText(), "output" );
+ $this->assertContains(
+ 'consetetur sadipscing elitr',
+ $edit->output->getText(),
+ "output"
+ );
+
+ $this->assertTrue( $content->equals( $edit->newContent ), "newContent field" );
+ $this->assertTrue( $content->equals( $edit->pstContent ), "pstContent field" );
+ $this->assertSame( $edit->output, $edit->output, "output field" );
+ $this->assertSame( $edit->popts, $edit->popts, "popts field" );
+ $this->assertSame( null, $edit->revid, "revid field" );
+
+ // Re-using the prepared info if possible
+ $sameEdit = $page->prepareContentForEdit( $content, null, $user, null, false );
+ $this->assertEquals( $edit, $sameEdit, 'equivalent PreparedEdit' );
+ $this->assertSame( $edit->pstContent, $sameEdit->pstContent, 're-use output' );
+ $this->assertSame( $edit->output, $sameEdit->output, 're-use output' );
+
+ // Not re-using the same PreparedEdit if not possible
+ $rev = $page->getRevision();
+ $edit2 = $page->prepareContentForEdit( $content2, null, $user, null, false );
+ $this->assertNotEquals( $edit, $edit2 );
+ $this->assertContains( 'At vero eos', $edit2->pstContent->serialize(), "content" );
+
+ // Check pre-safe transform
+ $this->assertContains( '[[gubergren]]', $edit2->pstContent->serialize() );
+ $this->assertNotContains( '~~~~', $edit2->pstContent->serialize() );
+
+ $edit3 = $page->prepareContentForEdit( $content2, null, $sysop, null, false );
+ $this->assertNotEquals( $edit2, $edit3 );
+
+ // TODO: test with passing revision, then same without revision.
+ }
+
+ /**
* @covers WikiPage::doEditUpdates
*/
+ public function testDoEditUpdates() {
+ $user = $this->getTestUser()->getUser();
+
+ // NOTE: if site stats get out of whack and drop below 0,
+ // that causes a DB error during tear-down. So bump the
+ // numbers high enough to not drop below 0.
+ $siteStatsUpdate = SiteStatsUpdate::factory(
+ [ 'edits' => 1000, 'articles' => 1000, 'pages' => 1000 ]
+ );
+ $siteStatsUpdate->doUpdate();
+
+ $page = $this->createPage( __METHOD__, __METHOD__ );
+
+ $revision = new Revision(
+ [
+ 'id' => 9989,
+ 'page' => $page->getId(),
+ 'title' => $page->getTitle(),
+ 'comment' => __METHOD__,
+ 'minor_edit' => true,
+ 'text' => __METHOD__ . ' [[|foo]][[bar]]', // PST turns [[|foo]] into [[foo]]
+ 'user' => $user->getId(),
+ 'user_text' => $user->getName(),
+ 'timestamp' => '20170707040404',
+ 'content_model' => CONTENT_MODEL_WIKITEXT,
+ 'content_format' => CONTENT_FORMAT_WIKITEXT,
+ ]
+ );
+
+ $page->doEditUpdates( $revision, $user );
+
+ // TODO: test various options; needs temporary hooks
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $res = $dbr->select( 'pagelinks', '*', [ 'pl_from' => $page->getId() ] );
+ $n = $res->numRows();
+ $res->free();
+
+ $this->assertEquals( 1, $n, 'pagelinks should contain only one link if PST was not applied' );
+ }
+
+ /**
+ * @covers WikiPage::doEditContent
+ * @covers WikiPage::prepareContentForEdit
+ */
public function testDoEditContent() {
$this->setMwGlobals( 'wgPageCreationLog', true );
$page = $this->newPage( __METHOD__ );
$title = $page->getTitle();
+ $user1 = $this->getTestUser()->getUser();
+ // Use the confirmed group for user2 to make sure the user is different
+ $user2 = $this->getTestUser( [ 'confirmed' ] )->getUser();
+
$content = ContentHandler::makeContent(
"[[Lorem ipsum]] dolor sit amet, consetetur sadipscing elitr, sed diam "
. " nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.",
CONTENT_MODEL_WIKITEXT
);
- $page->doEditContent( $content, "[[testing]] 1" );
+ $status = $page->doEditContent( $content, "[[testing]] 1", EDIT_NEW, false, $user1 );
+
+ $this->assertTrue( $status->isOK(), 'OK' );
+ $this->assertTrue( $status->value['new'], 'new' );
+ $this->assertNotNull( $status->value['revision'], 'revision' );
+ $this->assertSame( $status->value['revision']->getId(), $page->getRevision()->getId() );
+ $this->assertSame( $status->value['revision']->getSha1(), $page->getRevision()->getSha1() );
+ $this->assertTrue( $status->value['revision']->getContent()->equals( $content ), 'equals' );
+
+ $rev = $page->getRevision();
+ $this->assertNotNull( $rev->getRecentChange() );
+ $this->assertSame( $rev->getId(), (int)$rev->getRecentChange()->getAttribute( 'rc_this_oldid' ) );
$id = $page->getId();
$retrieved = $page->getContent();
$this->assertTrue( $content->equals( $retrieved ), 'retrieved content doesn\'t equal original' );
+ # ------------------------
+ $page = new WikiPage( $title );
+
+ // try null edit, with a different user
+ $status = $page->doEditContent( $content, 'This changes nothing', EDIT_UPDATE, false, $user2 );
+ $this->assertTrue( $status->isOK(), 'OK' );
+ $this->assertFalse( $status->value['new'], 'new' );
+ $this->assertNull( $status->value['revision'], 'revision' );
+ $this->assertNotNull( $page->getRevision() );
+ $this->assertTrue( $page->getRevision()->getContent()->equals( $content ), 'equals' );
+
# ------------------------
$content = ContentHandler::makeContent(
"At vero eos et accusam et justo duo [[dolores]] et ea rebum. "
- . "Stet clita kasd [[gubergren]], no sea takimata sanctus est.",
+ . "Stet clita kasd [[gubergren]], no sea takimata sanctus est. ~~~~",
$title,
CONTENT_MODEL_WIKITEXT
);
- $page->doEditContent( $content, "testing 2" );
+ $status = $page->doEditContent( $content, "testing 2", EDIT_UPDATE );
+ $this->assertTrue( $status->isOK(), 'OK' );
+ $this->assertFalse( $status->value['new'], 'new' );
+ $this->assertNotNull( $status->value['revision'], 'revision' );
+ $this->assertSame( $status->value['revision']->getId(), $page->getRevision()->getId() );
+ $this->assertSame( $status->value['revision']->getSha1(), $page->getRevision()->getSha1() );
+ $this->assertFalse(
+ $status->value['revision']->getContent()->equals( $content ),
+ 'not equals (PST must substitute signature)'
+ );
+
+ $rev = $page->getRevision();
+ $this->assertNotNull( $rev->getRecentChange() );
+ $this->assertSame( $rev->getId(), (int)$rev->getRecentChange()->getAttribute( 'rc_this_oldid' ) );
# ------------------------
$page = new WikiPage( $title );
$retrieved = $page->getContent();
- $this->assertTrue( $content->equals( $retrieved ), 'retrieved content doesn\'t equal original' );
+ $newText = $retrieved->serialize();
+ $this->assertContains( '[[gubergren]]', $newText, 'New text must replace old text.' );
+ $this->assertNotContains( '~~~~', $newText, 'PST must substitute signature.' );
# ------------------------
$dbr = wfGetDB( DB_REPLICA );
$this->assertEquals( WikiPage::class, get_class( $page ) );
}
+ /**
+ * @covers WikiPage::loadPageData
+ * @covers WikiPage::wasLoadedFrom
+ */
+ public function testLoadPageData() {
+ $title = Title::makeTitle( NS_MAIN, 'SomePage' );
+ $page = WikiPage::factory( $title );
+
+ $this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_NORMAL ) );
+ $this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_LATEST ) );
+ $this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_LOCKING ) );
+ $this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_EXCLUSIVE ) );
+
+ $page->loadPageData( IDBAccessObject::READ_NORMAL );
+ $this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_NORMAL ) );
+ $this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_LATEST ) );
+ $this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_LOCKING ) );
+ $this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_EXCLUSIVE ) );
+
+ $page->loadPageData( IDBAccessObject::READ_LATEST );
+ $this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_NORMAL ) );
+ $this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_LATEST ) );
+ $this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_LOCKING ) );
+ $this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_EXCLUSIVE ) );
+
+ $page->loadPageData( IDBAccessObject::READ_LOCKING );
+ $this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_NORMAL ) );
+ $this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_LATEST ) );
+ $this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_LOCKING ) );
+ $this->assertFalse( $page->wasLoadedFrom( IDBAccessObject::READ_EXCLUSIVE ) );
+
+ $page->loadPageData( IDBAccessObject::READ_EXCLUSIVE );
+ $this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_NORMAL ) );
+ $this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_LATEST ) );
+ $this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_LOCKING ) );
+ $this->assertTrue( $page->wasLoadedFrom( IDBAccessObject::READ_EXCLUSIVE ) );
+ }
+
/**
* @dataProvider provideCommentMigrationOnDeletion
*
);
}
+ /**
+ * @covers WikiPage::newPageUpdater
+ * @covers WikiPage::getDerivedDataUpdater
+ */
+ public function testNewPageUpdater() {
+ $user = $this->getTestUser()->getUser();
+ $page = $this->newPage( __METHOD__, __METHOD__ );
+
+ /** @var Content $content */
+ $content = $this->getMockBuilder( WikitextContent::class )
+ ->setConstructorArgs( [ 'Hello World' ] )
+ ->setMethods( [ 'getParserOutput' ] )
+ ->getMock();
+ $content->expects( $this->once() )
+ ->method( 'getParserOutput' )
+ ->willReturn( new ParserOutput( 'HTML' ) );
+
+ $updater = $page->newPageUpdater( $user );
+ $updater->setContent( 'main', $content );
+ $revision = $updater->saveRevision(
+ CommentStoreComment::newUnsavedComment( 'test' ),
+ EDIT_NEW
+ );
+
+ $this->assertSame( $revision->getId(), $page->getLatest() );
+ }
+
+ /**
+ * @covers WikiPage::newPageUpdater
+ * @covers WikiPage::getDerivedDataUpdater
+ */
+ public function testGetDerivedDataUpdater() {
+ $admin = $this->getTestSysop()->getUser();
+
+ /** @var object $page */
+ $page = $this->createPage( __METHOD__, __METHOD__ );
+ $page = TestingAccessWrapper::newFromObject( $page );
+
+ $revision = $page->getRevision()->getRevisionRecord();
+ $user = $revision->getUser();
+
+ $slotsUpdate = new RevisionSlotsUpdate();
+ $slotsUpdate->modifyContent( 'main', new WikitextContent( 'Hello World' ) );
+
+ // get a virgin updater
+ $updater1 = $page->getDerivedDataUpdater( $user );
+ $this->assertFalse( $updater1->isUpdatePrepared() );
+
+ $updater1->prepareUpdate( $revision );
+
+ // Re-use updater with same revision or content
+ $this->assertSame( $updater1, $page->getDerivedDataUpdater( $user, $revision ) );
+
+ $slotsUpdate = RevisionSlotsUpdate::newFromContent(
+ [ 'main' => $revision->getContent( 'main' ) ]
+ );
+ $this->assertSame( $updater1, $page->getDerivedDataUpdater( $user, null, $slotsUpdate ) );
+
+ // Don't re-use with different user
+ $updater2a = $page->getDerivedDataUpdater( $admin, null, $slotsUpdate );
+ $updater2a->prepareContent( $admin, $slotsUpdate, false );
+
+ $updater2b = $page->getDerivedDataUpdater( $user, null, $slotsUpdate );
+ $updater2b->prepareContent( $user, $slotsUpdate, false );
+ $this->assertNotSame( $updater2a, $updater2b );
+
+ // Don't re-use with different content
+ $updater3 = $page->getDerivedDataUpdater( $admin, null, $slotsUpdate );
+ $updater3->prepareUpdate( $revision );
+ $this->assertNotSame( $updater2b, $updater3 );
+
+ // Don't re-use if no context given
+ $updater4 = $page->getDerivedDataUpdater( $admin );
+ $updater4->prepareUpdate( $revision );
+ $this->assertNotSame( $updater3, $updater4 );
+
+ // Don't re-use if AGAIN no context given
+ $updater5 = $page->getDerivedDataUpdater( $admin );
+ $this->assertNotSame( $updater4, $updater5 );
+
+ // Don't re-use cached "virgin" unprepared updater
+ $updater6 = $page->getDerivedDataUpdater( $admin, $revision );
+ $this->assertNotSame( $updater5, $updater6 );
+ }
+
}
--- /dev/null
+<?php
+
+use MediaWiki\Tests\Storage\McrSchemaOverride;
+
+/**
+ * Tests WikiPage against the MCR DB schema after schema migration.
+ *
+ * @covers WikiPage
+ *
+ * @group WikiPage
+ * @group Storage
+ * @group ContentHandler
+ * @group Database
+ * @group medium
+ */
+class WikiPageMcrDbTest extends WikiPageDbTestBase {
+
+ use McrSchemaOverride;
+
+ public function setUp() {
+ parent::setUp();
+ }
+
+ protected function getContentHandlerUseDB() {
+ return true;
+ }
+
+}
--- /dev/null
+<?php
+use MediaWiki\Tests\Storage\McrWriteBothSchemaOverride;
+
+/**
+ * Tests WikiPage against the intermediate MCR DB schema for use during schema migration.
+ *
+ * @covers WikiPage
+ *
+ * @group WikiPage
+ * @group Storage
+ * @group ContentHandler
+ * @group Database
+ * @group medium
+ */
+class WikiPageMcrWriteBothDbTest extends WikiPageDbTestBase {
+
+ use McrWriteBothSchemaOverride;
+
+ protected function getContentHandlerUseDB() {
+ return true;
+ }
+
+}
$this->wanCache->expects( $this->any() )
->method( 'makePurgeValue' )
->will( $this->returnCallback( function ( $timestamp, $holdoff ) {
- // Disable holdoff as it messes with testing
- return WANObjectCache::PURGE_VAL_PREFIX . (float)$timestamp . ':0';
+ // Disable holdoff as it messes with testing. Aside from a 0-second holdoff,
+ // make sure that "time" passes between getMulti() check init and the set()
+ // in recacheMessageBlob(). This especially matters for Windows clocks.
+ $ts = (float)$timestamp - 0.0001;
+
+ return WANObjectCache::PURGE_VAL_PREFIX . $ts . ':0';
} ) );
}
->method( 'fetchMessage' )
->will( $this->onConsecutiveCalls( 'First', 'Second' ) );
+ $now = microtime( true );
+ $this->wanCache->setMockTime( $now );
+
$blob = $blobStore->getBlob( $module, 'en' );
$this->assertEquals( '{"example":"First"}', $blob, 'Generated blob' );
$blob = $blobStore->getBlob( $module, 'en' );
$this->assertEquals( '{"example":"First"}', $blob, 'Cache-hit' );
+ $now += 1;
$blobStore->clear();
$blob = $blobStore->getBlob( $module, 'en' );
$this->insertPage( 'Talk:Example' );
$this->insertPage( 'User:Example' );
+ $this->insertPage( 'Barcelona' );
+ $this->insertPage( 'Barbara' );
+ $this->insertPage( 'External' );
}
protected function setUp() {
],
] ],
[ [
- 'Exact match not on top (T72958)',
+ 'Exact match not in first result should be moved to the first result (T72958)',
'provision' => [
'Barcelona',
'Bar',
],
] ],
[ [
- 'Exact match missing (T72958)',
+ 'Exact match missing from results should be added as first result (T72958)',
'provision' => [
'Barcelona',
'Barbara',
],
] ],
[ [
- 'Exact match missing and not existing',
+ 'Exact match missing and not existing pages should be dropped',
'provision' => [
'Exile',
'Exist',
],
'query' => 'Ex',
'results' => [
- 'Exile',
- 'Exist',
'External',
],
] ],
'Redirect test',
],
] ],
+ [ [
+ "Extra results must not be returned",
+ 'provision' => [
+ 'Example',
+ 'Example Bar',
+ 'Example Foo',
+ 'Example Foo/Bar'
+ ],
+ 'query' => 'foo',
+ 'results' => [
+ 'Example',
+ 'Example Bar',
+ 'Example Foo',
+ ],
+ ] ],
];
}
* @covers PrefixSearch::searchBackend
*/
public function testSearchBackend( array $case ) {
- $search = $stub = $this->getMockBuilder( SearchEngine::class )
- ->setMethods( [ 'completionSearchBackend' ] )->getMock();
-
- $return = SearchSuggestionSet::fromStrings( $case['provision'] );
-
- $search->expects( $this->any() )
- ->method( 'completionSearchBackend' )
- ->will( $this->returnValue( $return ) );
-
- $search->setLimitOffset( 3 );
+ $search = $this->mockSearchWithResults( $case['provision'] );
$results = $search->completionSearch( $case['query'] );
$results = $results->map( function ( SearchSuggestion $s ) {
$case[0]
);
}
+
+ public function paginationProvider() {
+ $res = [ 'Example', 'Example Bar', 'Example Foo', 'Example Foo/Bar' ];
+ return [
+ 'With less than requested results no pagination' => [
+ false, array_slice( $res, 0, 2 ),
+ ],
+ 'With same as requested results no pagination' => [
+ false, array_slice( $res, 0, 3 ),
+ ],
+ 'With extra result returned offer pagination' => [
+ true, $res,
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider paginationProvider
+ */
+ public function testPagination( $hasMoreResults, $provision ) {
+ $search = $this->mockSearchWithResults( $provision );
+ $results = $search->completionSearch( 'irrelevant' );
+
+ $this->assertEquals( $hasMoreResults, $results->hasMoreResults() );
+ }
+
+ private function mockSearchWithResults( $titleStrings, $limit = 3 ) {
+ $search = $stub = $this->getMockBuilder( SearchEngine::class )
+ ->setMethods( [ 'completionSearchBackend' ] )->getMock();
+
+ $return = SearchSuggestionSet::fromStrings( $titleStrings );
+
+ $search->expects( $this->any() )
+ ->method( 'completionSearchBackend' )
+ ->will( $this->returnValue( $return ) );
+
+ $search->setLimitOffset( $limit );
+ return $search;
+ }
}
} );
$rowAugmentors['testRow'] = $rowAugmentor;
}
+
+ public function testFiltersMissing() {
+ $availableResults = [];
+ foreach ( range( 0, 11 ) as $i ) {
+ $title = "Search_Result_$i";
+ $availableResults[] = $title;
+ // pages not created must be filtered
+ if ( $i % 2 == 0 ) {
+ $this->editPage( $title );
+ }
+ }
+ MockCompletionSearchEngine::addMockResults( 'foo', $availableResults );
+
+ $engine = new MockCompletionSearchEngine();
+ $engine->setLimitOffset( 10, 0 );
+ $results = $engine->completionSearch( 'foo' );
+ $this->assertEquals( 5, $results->getSize() );
+ $this->assertTrue( $results->hasMoreResults() );
+
+ $engine->setLimitOffset( 10, 10 );
+ $results = $engine->completionSearch( 'foo' );
+ $this->assertEquals( 1, $results->getSize() );
+ $this->assertFalse( $results->hasMoreResults() );
+ }
+
+ private function editPage( $title ) {
+ $page = WikiPage::factory( Title::newFromText( $title ) );
+ $page->doEditContent(
+ new WikitextContent( 'UTContent' ),
+ 'UTPageSummary',
+ EDIT_NEW | EDIT_SUPPRESS_RC
+ );
+ }
}
] );
$this->assertEquals( [ 'foo' => 'bar' ], $result->getExtensionData() );
}
+
+ /**
+ * @covers SearchResultSet::shrink
+ * @covers SearchResultSet::count
+ * @covers SearchResultSet::hasMoreResults
+ */
+ public function testHasMoreResults() {
+ $result = SearchResult::newFromTitle( Title::newMainPage() );
+ $resultSet = new MockSearchResultSet( array_fill( 0, 3, $result ) );
+ $this->assertEquals( 3, count( $resultSet ) );
+ $this->assertFalse( $resultSet->hasMoreResults() );
+ $resultSet->shrink( 3 );
+ $this->assertFalse( $resultSet->hasMoreResults() );
+ $resultSet->shrink( 2 );
+ $this->assertTrue( $resultSet->hasMoreResults() );
+ }
}
define( 'NS_UNITTEST_TALK', 5601 );
use MediaWiki\MediaWikiServices;
+use MediaWiki\User\UserIdentityValue;
use Wikimedia\TestingAccessWrapper;
/**
}
}
+ /**
+ * @covers User::newFromIdentity
+ */
+ public function testNewFromIdentity() {
+ // Registered user
+ $user = $this->getTestUser()->getUser();
+
+ $this->assertSame( $user, User::newFromIdentity( $user ) );
+
+ // ID only
+ $identity = new UserIdentityValue( $user->getId(), '', 0 );
+ $result = User::newFromIdentity( $identity );
+ $this->assertInstanceOf( User::class, $result );
+ $this->assertSame( $user->getId(), $result->getId(), 'ID' );
+ $this->assertSame( $user->getName(), $result->getName(), 'Name' );
+ $this->assertSame( $user->getActorId(), $result->getActorId(), 'Actor' );
+
+ // Name only
+ $identity = new UserIdentityValue( 0, $user->getName(), 0 );
+ $result = User::newFromIdentity( $identity );
+ $this->assertInstanceOf( User::class, $result );
+ $this->assertSame( $user->getId(), $result->getId(), 'ID' );
+ $this->assertSame( $user->getName(), $result->getName(), 'Name' );
+ $this->assertSame( $user->getActorId(), $result->getActorId(), 'Actor' );
+
+ // Actor only
+ $identity = new UserIdentityValue( 0, '', $user->getActorId() );
+ $result = User::newFromIdentity( $identity );
+ $this->assertInstanceOf( User::class, $result );
+ $this->assertSame( $user->getId(), $result->getId(), 'ID' );
+ $this->assertSame( $user->getName(), $result->getName(), 'Name' );
+ $this->assertSame( $user->getActorId(), $result->getActorId(), 'Actor' );
+ }
+
/**
* @covers User::getBlockedStatus
* @covers User::getBlock
--- /dev/null
+<?php
+
+/**
+ * SearchEngine implementation for returning mocked completion search results.
+ */
+class MockCompletionSearchEngine extends SearchEngine {
+ /** @var string[][] */
+ private static $results = [];
+
+ /**
+ * Reset any mocked results
+ */
+ public static function clearMockResults() {
+ self::$results = [];
+ }
+
+ /**
+ * Allows returning arbitrary lists of titles for completion search.
+ * Provided results will be sliced based on offset/limit of query.
+ *
+ * For results to exit the search engine they must pass Title::isKnown.
+ * Injecting into link cache is not enough, as LinkBatch will mark them
+ * bad, they need to be injected into the DB.
+ *
+ * @param string $query Search term as seen in completionSearchBackend
+ * @param string[] $result List of titles to respond to query with
+ */
+ public static function addMockResults( $query, array $result ) {
+ // Leading : ensures we don't treat another : as a namespace separator
+ $normalized = Title::newFromText( ":$query" )->getText();
+ self::$results[$normalized] = $result;
+ }
+
+ public function completionSearchBackend( $search ) {
+ if ( !isset( self::$results[$search] ) ) {
+ return SearchSuggestionSet::emptySuggestionSet();
+ }
+ $results = array_slice( self::$results[$search], $this->offset, $this->limit );
+
+ return SearchSuggestionSet::fromStrings( $results );
+ }
+}
self::$results[$query] = $results;
$lc = MediaWikiServices::getInstance()->getLinkCache();
foreach ( $results as $result ) {
- // TODO: better page ids?
+ // TODO: better page ids? Does it matter?
$lc->addGoodLinkObj( mt_rand(), $result->getTitle() );
}
}
* to list of results for that type.
*/
public function __construct( array $results, array $interwikiResults = [] ) {
+ parent::__construct( false, false );
$this->results = $results;
$this->interwikiResults = $interwikiResults;
}
--- /dev/null
+const Page = require( 'wdio-mediawiki/Page' );
+
+class RecentChangesPage extends Page {
+ get changesList() { return browser.element( '.mw-changeslist' ); }
+ get changesListTitles() { return this.changesList.$$( '.mw-changeslist-title' ); }
+ get titles() {
+ return this.changesListTitles.map( function ( element ) {
+ return element.getText();
+ } );
+ }
+
+ open() {
+ super.openTitle( 'Special:RecentChanges' );
+ }
+
+}
+
+module.exports = new RecentChangesPage();
# Check the command before running in background so
# that it can actually fail and have a descriptive error
hash chromedriver
-chromedriver --url-base=/wd/hub --port=4444 &
+chromedriver --url-base=wd/hub --port=4444 &
+CHROME_DRIVER_PID=$!
+echo chromedriver running with PID $CHROME_DRIVER_PID
# Make sure it is killed to prevent file descriptors leak
function kill_chromedriver() {
- killall chromedriver > /dev/null
+ # Use kill instead of killall to increase chances of this working on Windows
+ kill $CHROME_DRIVER_PID > /dev/null
}
trap kill_chromedriver EXIT
npm run selenium-test
var content,
name;
- function getTestString() {
- return Math.random().toString() + '-öäü-♠♣♥♦';
+ function getTestString( suffix = 'defaultsuffix' ) {
+ return Math.random().toString() + '-Iñtërnâtiônàlizætiøn☃-' + suffix;
}
before( function () {
beforeEach( function () {
browser.deleteCookie();
- content = getTestString();
- name = getTestString();
+ content = getTestString( 'beforeEach-content' );
+ name = getTestString( 'beforeEach-name' );
} );
it( 'should be creatable', function () {
} );
it( 'should be re-creatable', function () {
- let initialContent = getTestString();
+ let initialContent = getTestString( 'initialContent' );
// create
browser.call( function () {
} );
// edit
- EditPage.edit( name, content );
+ let editContent = getTestString( 'editContent' );
+ EditPage.edit( name, editContent );
// check
assert.strictEqual( EditPage.heading.getText(), name );
- assert.strictEqual( EditPage.displayedContent.getText(), content );
+ assert.strictEqual( EditPage.displayedContent.getText(), editContent );
} );
it( 'should have history', function () {
--- /dev/null
+const assert = require( 'assert' ),
+ Api = require( 'wdio-mediawiki/Api' ),
+ RecentChangesPage = require( '../pageobjects/recentchanges.page' );
+
+describe( 'Special:RecentChanges', function () {
+ let content,
+ name;
+
+ function getTestString() {
+ return Math.random().toString() + '-öäü-♠♣♥♦';
+ }
+
+ beforeEach( function () {
+ browser.deleteCookie();
+ content = getTestString();
+ name = getTestString();
+ } );
+
+ it( 'shows page creation', function () {
+ browser.call( function () {
+ return Api.edit( name, content );
+ } );
+
+ RecentChangesPage.open();
+
+ assert.strictEqual( name, RecentChangesPage.titles[ 0 ] );
+ } );
+
+} );