From: jenkins-bot Date: Sat, 12 May 2018 07:40:54 +0000 (+0000) Subject: Merge "Use {{int:}} on MediaWiki:Blockedtext and MediaWiki:Autoblockedtext" X-Git-Tag: 1.34.0-rc.0~5441 X-Git-Url: https://git.heureux-cyclage.org/?p=lhc%2Fweb%2Fwiklou.git;a=commitdiff_plain;h=478a58f63101f2b47d18a618296b5e7970fa3f24;hp=8ab60cd260b9d61546ff1071d3ada33cc15a003a Merge "Use {{int:}} on MediaWiki:Blockedtext and MediaWiki:Autoblockedtext" --- diff --git a/.gitignore b/.gitignore index 0112cf31a6..d440e728ca 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ sftp-config.json npm-debug.log node_modules/ /tests/phpunit/phpunit.phar +/tests/selenium/log # Composer /vendor diff --git a/.mailmap b/.mailmap index 08e1aaa15f..6c316171bd 100644 --- a/.mailmap +++ b/.mailmap @@ -107,7 +107,9 @@ Christoph Jauera Christoph Jauera Christopher Johnson church of emacs -Cindy Cicalese +Cindy Cicalese +Cindy Cicalese +Cindy Cicalese ckoerner Conrad Irwin Dan Duvall @@ -473,6 +475,7 @@ Yuvi Panda Zak Greant Zhengzhu Feng Zhengzhu Feng +Zoranzoki21 Zppix Ævar Arnfjörð Bjarmason Étienne Beaulé diff --git a/.phpcs.xml b/.phpcs.xml index 31e6eebc71..d43a2814cf 100644 --- a/.phpcs.xml +++ b/.phpcs.xml @@ -17,7 +17,6 @@ - diff --git a/.travis.yml b/.travis.yml index a28dac0ff0..73e4af5438 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,22 +23,20 @@ matrix: # On Trusty, mysql user 'travis' doesn't have create database rights # Postgres has no user called 'root'. - env: dbtype=mysql dbuser=root - php: 5.5 - - env: dbtype=postgres dbuser=travis - php: 5.5 - # https://docs.travis-ci.com/user/languages/php#HHVM-versions + php: 7.2 - env: dbtype=mysql dbuser=root - php: hhvm-3.18 + php: 7.1 + - env: dbtype=postgres dbuser=travis + php: 7.1 - env: dbtype=mysql dbuser=root - php: hhvm-3.21 + php: 7 + # https://docs.travis-ci.com/user/languages/php#HHVM-versions - env: dbtype=mysql dbuser=root php: hhvm-3.24 - env: dbtype=mysql dbuser=root - php: 7 - - env: dbtype=mysql dbuser=root - php: 7.1 + php: hhvm-3.21 - env: dbtype=mysql dbuser=root - php: 7.2 + php: hhvm-3.18 services: - mysql diff --git a/CREDITS b/CREDITS index 95d6a6cfa6..d9bdc3d8f7 100644 --- a/CREDITS +++ b/CREDITS @@ -21,6 +21,7 @@ The following list can be found parsed under Special:Version/Credits --> * Adam Roses Wight * addshore * Aditya Sastry +* AdityaJ * Adrian Heine * Adrian Lang * Ævar Arnfjörð Bjarmason @@ -85,6 +86,7 @@ The following list can be found parsed under Special:Version/Credits --> * Bahodir Mansurov * balloonguy * Bartosz Dziewoński +* Base * Beau * Ben Davis * Ben Hartshorne @@ -141,6 +143,7 @@ The following list can be found parsed under Special:Version/Credits --> * Dan Barrett * Dan Collins * Dan Duvall +* Dan Mattern * Dan Nessett * Dan Poltawski * dan-nl @@ -233,7 +236,7 @@ The following list can be found parsed under Special:Version/Credits --> * Gary Guo * gbt248 * Geoffrey Mon -* GeoffreyT2000 +* Geoffrey Trang * georggi * Gergő Tisza * Gero Scholz @@ -246,13 +249,16 @@ The following list can be found parsed under Special:Version/Credits --> * gladoscc * glaisher * golopot +* gopavasanth * Greg Maxwell * Greg Sabino Mullane * Gregory Szorc * Grunny * Guillaume Blanchard * Guy Van den Broeck +* Guycn2 * Haikal Izzuddin +* HakanIST * Happy-melon * haritha28 * Harry Burt @@ -280,8 +286,8 @@ The following list can be found parsed under Special:Version/Credits --> * jagori * Jaime Crespo * Jakub Vrana -* James Earl Douglas * James D. Forrester +* James Earl Douglas * Jan Berkel * Jan Drewniak * Jan Gerber @@ -435,6 +441,7 @@ The following list can be found parsed under Special:Version/Credits --> * Max Sikström * mayankmadan * Mehmet Mert Yıldıran +* Melos * Meno25 * merl * Merlijn S. van Deen @@ -476,6 +483,7 @@ The following list can be found parsed under Special:Version/Credits --> * Namit * Nathan Larson * Nathaniel Herman +* navisk13 * Neil Kandalgaonkar * Nemo bis * nephele @@ -599,6 +607,7 @@ The following list can be found parsed under Special:Version/Credits --> * Sam Reed * Sam Smith * Sam Wilson +* SamanthaNguyen * Santhosh Thottingal * saptaks * Schnark (Michael M.) @@ -735,9 +744,10 @@ The following list can be found parsed under Special:Version/Credits --> * Zhaofeng Li * Zhengzhu Feng * Zhuyifei1999 -* zoranzoki21 +* Zoranzoki21 * Zppix * محمد شعیب +* 星耀晨曦 == Translators == diff --git a/Gruntfile.js b/Gruntfile.js index cb9a20d0a7..3687d2805e 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -4,7 +4,6 @@ module.exports = function ( grunt ) { var wgServer = process.env.MW_SERVER, wgScriptPath = process.env.MW_SCRIPT_PATH, - WebdriverIOconfigFile, karmaProxy = {}; grunt.loadNpmTasks( 'grunt-banana-checker' ); @@ -14,19 +13,12 @@ module.exports = function ( grunt ) { grunt.loadNpmTasks( 'grunt-jsonlint' ); grunt.loadNpmTasks( 'grunt-karma' ); grunt.loadNpmTasks( 'grunt-stylelint' ); - grunt.loadNpmTasks( 'grunt-webdriver' ); karmaProxy[ wgScriptPath ] = { target: wgServer + wgScriptPath, changeOrigin: true }; - if ( process.env.JENKINS_HOME ) { - WebdriverIOconfigFile = './tests/selenium/wdio.conf.jenkins.js'; - } else { - WebdriverIOconfigFile = './tests/selenium/wdio.conf.js'; - } - grunt.initConfig( { eslint: { all: [ @@ -36,7 +28,7 @@ module.exports = function ( grunt ) { '!resources/lib/**', '!resources/src/jquery.tipsy/**', '!resources/src/jquery/jquery.farbtastic.js', - '!resources/src/mediawiki.libs/**', + '!resources/src/mediawiki.libs.jpegmeta/**', // Third-party code of PHPUnit coverage report '!tests/coverage/**', '!vendor/**', @@ -44,7 +36,7 @@ module.exports = function ( grunt ) { '!extensions/**/*.js', '!skins/**/*.js', // Skip functions aren't even parseable - '!resources/src/mediawiki.hidpi-skip.js' + '!resources/src/mediawiki.hidpi/skip.js' ] }, jsonlint: { @@ -111,15 +103,7 @@ module.exports = function ( grunt ) { return require( 'path' ).join( dest, src.replace( 'resources/', '' ) ); } } - }, - - // Configure WebdriverIO task - webdriver: { - test: { - configFile: WebdriverIOconfigFile - } } - } ); grunt.registerTask( 'assert-mw-env', function () { diff --git a/INSTALL b/INSTALL index 1a59f0b580..3b935059dc 100644 --- a/INSTALL +++ b/INSTALL @@ -9,7 +9,7 @@ Required software: * Web server with PHP 5.5.9 or higher. * A SQL server, the following types are supported ** MySQL 5.5.8 or higher -** PostgreSQL 8.3 or higher +** PostgreSQL 9.2 or higher ** SQLite 3.3.7 or higher ** Oracle 9.0.1 or higher ** Microsoft SQL Server 2005 (9.00.1399) diff --git a/README b/README deleted file mode 100644 index ad9b9d9d83..0000000000 --- a/README +++ /dev/null @@ -1,33 +0,0 @@ -== MediaWiki == - -MediaWiki is a free and open-source wiki software package written in PHP. It -serves as the platform for Wikipedia and the other Wikimedia projects, used -by hundreds of millions of people each month. MediaWiki is localised in over -350 languages and its reliability and robust feature set have earned it a large -and vibrant community of third-party users and developers. - -MediaWiki is: - -* feature-rich and extensible, both on-wiki and with hundreds of extensions; -* scalable and suitable for both small and large sites; -* simple to install, working on most hardware/software combinations; and -* available in your language. - -For system requirements, installation, and upgrade details, see the files -RELEASE-NOTES, INSTALL, and UPGRADE. - -* Ready to get started? -** https://www.mediawiki.org/wiki/Special:MyLanguage/Download -* Looking for the technical manual? -** https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents -* Seeking help from a person? -** https://www.mediawiki.org/wiki/Special:MyLanguage/Communication -* Looking to file a bug report or a feature request? -** https://bugs.mediawiki.org/ -* Interested in helping out? -** https://www.mediawiki.org/wiki/Special:MyLanguage/How_to_contribute - -MediaWiki is the result of global collaboration and cooperation. The CREDITS -file lists technical contributors to the project. The COPYING file explains -MediaWiki's copyright and license (GNU General Public License, version 2 or -later). Many thanks to the Wikimedia community for testing and suggestions. diff --git a/README.md b/README.md new file mode 100644 index 0000000000..ca703dbc0f --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +MediaWiki +=========== + +MediaWiki is a free and open-source wiki software package written in PHP. It +serves as the platform for Wikipedia and the other Wikimedia projects, used +by hundreds of millions of people each month. MediaWiki is localised in over +350 languages and its reliability and robust feature set have earned it a large +and vibrant community of third-party users and developers. + +MediaWiki is: + +* feature-rich and extensible, both on-wiki and with hundreds of extensions; +* scalable and suitable for both small and large sites; +* simple to install, working on most hardware/software combinations; and +* available in your language. + +For system requirements, installation, and upgrade details, see the files +RELEASE-NOTES, INSTALL, and UPGRADE. + +* Ready to get started? +** https://www.mediawiki.org/wiki/Special:MyLanguage/Download +* Looking for the technical manual? +** https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Contents +* Seeking help from a person? +** https://www.mediawiki.org/wiki/Special:MyLanguage/Communication +* Looking to file a bug report or a feature request? +** https://bugs.mediawiki.org/ +* Interested in helping out? +** https://www.mediawiki.org/wiki/Special:MyLanguage/How_to_contribute + +MediaWiki is the result of global collaboration and cooperation. The CREDITS +file lists technical contributors to the project. The COPYING file explains +MediaWiki's copyright and license (GNU General Public License, version 2 or +later). Many thanks to the Wikimedia community for testing and suggestions. diff --git a/README.mediawiki b/README.mediawiki deleted file mode 120000 index 100b93820a..0000000000 --- a/README.mediawiki +++ /dev/null @@ -1 +0,0 @@ -README \ No newline at end of file diff --git a/RELEASE-NOTES-1.31 b/RELEASE-NOTES-1.31 index 9d9a26be57..a702451321 100644 --- a/RELEASE-NOTES-1.31 +++ b/RELEASE-NOTES-1.31 @@ -9,37 +9,50 @@ production. * $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. +* $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. +* (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. -* $wgResourceLoaderMinifierStatementsOnOwnLine and $wgResourceLoaderMinifierMaxLineLength - were removed (deprecated since 1.27). -* (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. +* 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'. +* (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. === 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. +* (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. + 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 @@ -65,9 +78,9 @@ production. 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. + 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(), @@ -76,27 +89,34 @@ production. * (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 + + 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 === ==== 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 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.5. +* 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. @@ -105,54 +125,170 @@ production. * The deprecated 'jquery.placeholder' module was removed. * The deprecated 'jquery.appear' module was removed. Use the 'mediawiki.viewport' module instead. -* The deprecated 'mediawiki.widgets.CategorySelector' module alias was removed. - Use the 'mediawiki.widgets.CategoryMultiselectWidget' module directly instead. +* mediawiki/at-ease was replaced with wikimedia/at-ease. === Bug fixes in 1.31 === -* (T90902) Non-breaking space in header ID breaks anchor +* (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. === 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 namespaces. +* (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). -=== Other changes in 1.31 === -* Browser support for Internet Explorer 10 was lowered from Grade A to Grade C. -* Introducing multi-content-revision capability into the storage layer. For details, - see . -* The Revision class was deprecated in favor of RevisionStore, BlobStore, and - RevisionRecord and its subclasses. -* MessageBlobStore::insertMessageBlob() (deprecated in 1.27) was removed. -* The global function wfBCP47 was renamed to LanguageCode::bcp47. -* The global function wfBCP47 is now deprecated. -* The global function wfCountDown() is now deprecated in favor of - Maintenance::countDown() -* The OutputPage class constructor now requires a context parameter, - (instantiating without context was deprecated in 1.18) -* mw.page (deprecated in 1.30) was removed. +=== 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(), Article::onArticleCreate(), - Article::onArticleDelete(), and Article::onArticleEdit() methods, deprecated - in 1.24, were removed. -* Installer::locateExecutable() and Installer::locateExecutableInDefaultPaths() - were removed, use ExecutableFinder::findInDefaultPaths() instead. +* 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. @@ -173,26 +309,17 @@ changes to languages because of Phabricator reports. * Revision::selectArchiveFields() → Revision::getArchiveQueryInfo() * User::selectFields() → User::getQueryInfo() * WikiPage::selectFields() → WikiPage::getQueryInfo() -* Due to significant refactoring, method ContribsPager::getUserCond() that had - no access restriction has been removed. * 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. -* The deprecated MW_DIFF_VERSION constant was removed. - DifferenceEngine::MW_DIFF_VERSION should be used 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. -* Browser support for Opera 12 and older was removed. - Opera 15+ continues at Grade A support. -* 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 RevisionInsertComplete hook is now deprecated, use RevisionRecordInserted instead. - RevisionInsertComplete is still called, but the second and third parameter will always be null. - Hard deprecation is scheduled for 1.32. +* 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. @@ -204,130 +331,72 @@ changes to languages because of Phabricator reports. * ParserOutput::setTOCEnabled() * OutputPage::enableSectionEditLinks() * OutputPage::sectionEditLinksEnabled() - * The public ParserOutput state fields $mTOCEnabled and $mEditSectionTokens are also deprecated. -* The following methods and constants from the WatchedItem class 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. - The corresponding configuration variable ($wgResourceLoaderMinifierStatementsOnOwnLine) - has been deprecated since 1.27 and was removed as well. -* The $maxLineLength parameter of JavaScriptMinifier::minify was removed. - The corresponding configuration variable ($wgResourceLoaderMinifierMaxLineLength) - has been deprecated since 1.27 and was removed as well. -* The HtmlFormatter class was removed (deprecated in 1.27). The namespaced - HtmlFormatter\HtmlFormatter class should be used instead. + * The public ParserOutput state fields $mTOCEnabled and $mEditSectionTokens + are also deprecated. * License::getLicenses has been deprecated; use License::getLines 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 * 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() was deprecated in favour of Skin::msg() parameters. -* MediaWikiI18N::set() was deprecated in favour of Skin::msg() parameters. -* MediaWikiI18N::translate() was deprecated in favour of Skin::msg() or wfMessage(). + 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. -* ParserOutput objects 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. -* \ObjectFactory (no namespace) is deprecated, the namespaced \Wikimedia\ObjectFactory - from the wikimedia/object-factory library should be used instead. -* CommentStore::newKey is deprecated. Get an instance from MediaWikiServices instead. -* 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: +* \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 +* 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 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() -* The method ResourceLoaderModule::getPosition(), deprecated in 1.29, has been removed. -* The DeferredStringifier class is deprecated, use Message::listParam() instead. -* The type string for the parameter $lang of DateFormatter::getInstance is - deprecated. -* 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. -* The global functions wfProfileIn and wfProfileOut, deprecated in 1.25, have been removed. * The following methods related to caching of half-parsed HTML were deprecated: * Parser::serializeHalfParsedText() * Parser::unserializeHalfParsedText() * Parser::isValidHalfParsedText() * StripState::getSubState() * StripState::merge() -* 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". -* 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. +* 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(). +=== 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 + . +* 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. -* 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. + 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 5.5.9 or later. Although HHVM 3.18.5 or later is supported, -it is generally advised to use PHP 5.5.9 or later for long term support. +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 @@ -335,7 +404,7 @@ Oracle and Microsoft SQL Server. The supported versions are: -* MySQL 5.0.3 or later +* MySQL 5.5.8 or later * PostgreSQL 9.2 or later * SQLite 3.3.7 or later * Oracle 9.0.1 or later diff --git a/RELEASE-NOTES-1.32 b/RELEASE-NOTES-1.32 new file mode 100644 index 0000000000..9fd3161f1e --- /dev/null +++ b/RELEASE-NOTES-1.32 @@ -0,0 +1,149 @@ +== MediaWiki 1.32 == + +THIS IS NOT A RELEASE YET + +MediaWiki 1.32 is an alpha-quality branch and is not recommended for use in +production. + +=== Configuration changes in 1.32 === +* (T115414) The $wgEnableAPI and $wgEnableWriteAPI settings, deprecated in 1.31, + have been removed. +* The $wgUseAjax setting, deprecated in 1.31, is now ignored. +* The $wgSiteSupportPage setting, unused since 1.5, was removed. +* The default quality of JPEG thumbnails generated by GD was reduced from 95 to + 80. The quality of JPEG thumbnails is now configurable through the new setting + $wgJpegQuality (default 80). This aligns the quality to what ImageMagick uses. +* $wgExperimentalHtmlIds, deprecated since 1.30, has been removed. The + 'html5-legacy' value for $wgFragmentMode is no longer accepted. +* The experimental Html5Internal and Html5Depurate tidy drivers were removed. + RemexHtml, which is the default, should be used instead. + +=== New features in 1.32 === +* (T112474) Generalized the ResourceLoader mechanism for overriding modules + using a particular page during edit previews. +* Added 'ApiParseMakeOutputPage' hook. + +=== External library changes in 1.32 === +* … + +==== Upgraded external libraries ==== +* Updated QUnit from 2.4.0 to 2.6.0. + +==== New external libraries ==== +* … + +==== Removed and replaced external libraries ==== +* … + +=== Bug fixes in 1.32 === +* … + +=== Action API changes in 1.32 === +* … + +=== Action API internal changes in 1.32 === +* Added 'ApiParseMakeOutputPage' hook. + +=== Languages updated in 1.32 === +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. + +* (T193566) Added language support for Ambonese Malay (abs). + +=== Breaking changes in 1.32 === +* $wgRequestTime, deprecated in 1.25, was removed. Use + $_SERVER['REQUEST_TIME_FLOAT'] or WebRequest::getElapsedTime() instead. +* The MediaWikiI18N class, deprecated in 1.31, was removed. +* QuickTemplate::setTranslator(), deprecated in 1.31, was removed. Use + Skin::msg() instead. +* wfInitShellLocale(), deprecated in 1.30, was removed. +* wfShellExecDisabled(), deprecated in 1.30, was removed. +* The type string for the parameter $lang of DateFormatter::getInstance, + deprecated in 1.31, was removed. +* The EDIT_TOKEN_SUFFIX constant deprecated in 1.27, was removed. Use + MediaWiki\Session\Token::SUFFIX instead. +* EditPage::isOouiEnabled() deprecated in 1.30, was removed. +* mw.util.wikiGetlink(), deprecated in 1.23, was removed. Use mw.util.getUrl() + instead. +* (T61113) The following methods and constants from the Revision class, which + were deprecated in 1.25, have now been removed: + * Revision::getRawUser() + * Revision::getRawUserText() + * Revision::getRawComment() +* window.gM() from mediawiki.jqueryMsg, deprecated in 1.23, was removed. Use + mw.msg() or mw.message() instead. +* mw.util.escapeId(), deprecated in 1.30, was removed. Use + mw.util.escapeIdForAttribute or mw.util.escapeIdForLink instead. +* mw.util.updateTooltipAccessKeys(), deprecated in 1.24, was removed. Use + jquery.accessKeyLabel instead. +* The SqlDataUpdate class, deprecated in 1.28, has been removed. +* The Html5Internal and Html5Depurate tidy driver classes were removed, along with the + Balancer tidy implementation. Both implementations were experimental, and were replaced + by RemexHtml. + +=== Deprecations in 1.32 === +* Use of a StartProfiler.php file is deprecated in favour of placing + configuration in LocalSettings.php. +* HTMLForm::setSubmitProgressive() is deprecated. No need to call it. Submit + button is already marked as progressive. +* Skin::setupSkinUserCss() is deprecated. Adding of modules to load + has been centralised to Skin::getDefaultModules(), which is now capable + of queueing style modules as well. +* OutputPage::addModuleScripts() and ParserOutput::addModuleScripts are + deprecated. Use addModules() instead. + +=== Other changes in 1.32 === +* … + +== Compatibility == +MediaWiki 1.32 requires PHP 5.5.9 or later. Although HHVM 3.18.5 or later is +supported, it is generally advised to use PHP 5.5.9 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.32 has several database changes since 1.31, 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.31.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. diff --git a/StartProfiler.sample b/StartProfiler.sample deleted file mode 100644 index bdf21396e2..0000000000 --- a/StartProfiler.sample +++ /dev/null @@ -1,38 +0,0 @@ -@gmail.com * @@ -55,14 +52,6 @@ if ( isset( $_SERVER['PATH_INFO'] ) && $_SERVER['PATH_INFO'] != '' ) { die( 1 ); } -// Verify that the API has not been disabled -if ( !$wgEnableAPI ) { - header( $_SERVER['SERVER_PROTOCOL'] . ' 500 MediaWiki configuration Error', true, 500 ); - echo 'MediaWiki API is not enabled for this site. Add the following line to your LocalSettings.php' - . '
$wgEnableAPI=true;
'; - die( 1 ); -} - // Set a dummy $wgTitle, because $wgTitle == null breaks various things // In a perfect world this wouldn't be necessary $wgTitle = Title::makeTitle( NS_SPECIAL, 'Badtitle/dummy title for API calls set in api.php' ); @@ -76,7 +65,7 @@ try { * is some form of an ApiMain, possibly even one that produces an error message, * but we don't care here, as that is handled by the constructor. */ - $processor = new ApiMain( RequestContext::getMain(), $wgEnableWriteAPI ); + $processor = new ApiMain( RequestContext::getMain(), true ); // Last chance hook before executing the API Hooks::run( 'ApiBeforeMain', [ &$processor ] ); diff --git a/autoload.php b/autoload.php index eed1c95c1f..27ff84857e 100644 --- a/autoload.php +++ b/autoload.php @@ -1,6 +1,6 @@ __DIR__ . '/maintenance/benchmarks/benchmarkSanitizer.php', 'BenchmarkTidy' => __DIR__ . '/maintenance/benchmarks/benchmarkTidy.php', 'Benchmarker' => __DIR__ . '/maintenance/benchmarks/Benchmarker.php', - 'BitmapHandler' => __DIR__ . '/includes/media/Bitmap.php', - 'BitmapHandler_ClientOnly' => __DIR__ . '/includes/media/Bitmap_ClientOnly.php', + 'BitmapHandler' => __DIR__ . '/includes/media/BitmapHandler.php', + 'BitmapHandler_ClientOnly' => __DIR__ . '/includes/media/BitmapHandler_ClientOnly.php', 'BitmapMetadataHandler' => __DIR__ . '/includes/media/BitmapMetadataHandler.php', 'Blob' => __DIR__ . '/includes/libs/rdbms/encasing/Blob.php', 'Block' => __DIR__ . '/includes/Block.php', 'BlockLevelPass' => __DIR__ . '/includes/parser/BlockLevelPass.php', 'BlockListPager' => __DIR__ . '/includes/specials/pagers/BlockListPager.php', 'BlockLogFormatter' => __DIR__ . '/includes/logging/BlockLogFormatter.php', - 'BmpHandler' => __DIR__ . '/includes/media/BMP.php', + 'BmpHandler' => __DIR__ . '/includes/media/BmpHandler.php', 'BotPassword' => __DIR__ . '/includes/user/BotPassword.php', 'BrokenRedirectsPage' => __DIR__ . '/includes/specials/SpecialBrokenRedirects.php', 'BufferingStatsdDataFactory' => __DIR__ . '/includes/libs/stats/BufferingStatsdDataFactory.php', @@ -225,6 +225,7 @@ $wgAutoloadLocalClasses = [ 'CapsCleanup' => __DIR__ . '/maintenance/cleanupCaps.php', 'CategoriesRdf' => __DIR__ . '/includes/CategoriesRdf.php', 'Category' => __DIR__ . '/includes/Category.php', + 'CategoryChangesAsRdf' => __DIR__ . '/maintenance/categoryChangesAsRdf.php', 'CategoryFinder' => __DIR__ . '/includes/CategoryFinder.php', 'CategoryMembershipChange' => __DIR__ . '/includes/changes/CategoryMembershipChange.php', 'CategoryMembershipChangeJob' => __DIR__ . '/includes/jobqueue/jobs/CategoryMembershipChangeJob.php', @@ -396,7 +397,7 @@ $wgAutoloadLocalClasses = [ 'DiffOpDelete' => __DIR__ . '/includes/diff/DairikiDiff.php', 'DifferenceEngine' => __DIR__ . '/includes/diff/DifferenceEngine.php', 'Digit2Html' => __DIR__ . '/maintenance/language/digit2html.php', - 'DjVuHandler' => __DIR__ . '/includes/media/DjVu.php', + 'DjVuHandler' => __DIR__ . '/includes/media/DjVuHandler.php', 'DjVuImage' => __DIR__ . '/includes/media/DjVuImage.php', 'DnsSrvDiscoverer' => __DIR__ . '/includes/libs/DnsSrvDiscoverer.php', 'DoubleRedirectJob' => __DIR__ . '/includes/jobqueue/jobs/DoubleRedirectJob.php', @@ -452,10 +453,11 @@ $wgAutoloadLocalClasses = [ 'EventRelayerNull' => __DIR__ . '/includes/libs/eventrelayer/EventRelayerNull.php', 'ExecutableFinder' => __DIR__ . '/includes/utils/ExecutableFinder.php', 'Exif' => __DIR__ . '/includes/media/Exif.php', - 'ExifBitmapHandler' => __DIR__ . '/includes/media/ExifBitmap.php', + 'ExifBitmapHandler' => __DIR__ . '/includes/media/ExifBitmapHandler.php', 'ExplodeIterator' => __DIR__ . '/includes/libs/ExplodeIterator.php', 'ExportProgressFilter' => __DIR__ . '/includes/export/ExportProgressFilter.php', 'ExportSites' => __DIR__ . '/maintenance/exportSites.php', + 'ExtensionDependencyError' => __DIR__ . '/includes/registration/ExtensionDependencyError.php', 'ExtensionJsonValidationError' => __DIR__ . '/includes/registration/ExtensionJsonValidationError.php', 'ExtensionJsonValidator' => __DIR__ . '/includes/registration/ExtensionJsonValidator.php', 'ExtensionLanguages' => __DIR__ . '/maintenance/language/languages.inc', @@ -536,7 +538,7 @@ $wgAutoloadLocalClasses = [ 'FormatMetadata' => __DIR__ . '/includes/media/FormatMetadata.php', 'FormattedRCFeed' => __DIR__ . '/includes/rcfeed/FormattedRCFeed.php', 'FormlessAction' => __DIR__ . '/includes/actions/FormlessAction.php', - 'GIFHandler' => __DIR__ . '/includes/media/GIF.php', + 'GIFHandler' => __DIR__ . '/includes/media/GIFHandler.php', 'GIFMetadataExtractor' => __DIR__ . '/includes/media/GIFMetadataExtractor.php', 'GanConverter' => __DIR__ . '/languages/classes/LanguageGan.php', 'GenderCache' => __DIR__ . '/includes/cache/GenderCache.php', @@ -565,6 +567,7 @@ $wgAutoloadLocalClasses = [ 'HTMLComboboxField' => __DIR__ . '/includes/htmlform/fields/HTMLComboboxField.php', 'HTMLDateTimeField' => __DIR__ . '/includes/htmlform/fields/HTMLDateTimeField.php', 'HTMLEditTools' => __DIR__ . '/includes/htmlform/fields/HTMLEditTools.php', + 'HTMLExpiryField' => __DIR__ . '/includes/htmlform/fields/HTMLExpiryField.php', 'HTMLFileCache' => __DIR__ . '/includes/cache/HTMLFileCache.php', 'HTMLFloatField' => __DIR__ . '/includes/htmlform/fields/HTMLFloatField.php', 'HTMLForm' => __DIR__ . '/includes/htmlform/HTMLForm.php', @@ -697,7 +700,7 @@ $wgAutoloadLocalClasses = [ 'JobQueueSecondTestQueue' => __DIR__ . '/includes/jobqueue/JobQueueSecondTestQueue.php', 'JobRunner' => __DIR__ . '/includes/jobqueue/JobRunner.php', 'JobSpecification' => __DIR__ . '/includes/jobqueue/JobSpecification.php', - 'JpegHandler' => __DIR__ . '/includes/media/Jpeg.php', + 'JpegHandler' => __DIR__ . '/includes/media/JpegHandler.php', 'JpegMetadataExtractor' => __DIR__ . '/includes/media/JpegMetadataExtractor.php', 'JsonContent' => __DIR__ . '/includes/content/JsonContent.php', 'JsonContentHandler' => __DIR__ . '/includes/content/JsonContentHandler.php', @@ -842,7 +845,6 @@ $wgAutoloadLocalClasses = [ 'MediaTransformInvalidParametersException' => __DIR__ . '/includes/media/MediaTransformInvalidParametersException.php', 'MediaTransformOutput' => __DIR__ . '/includes/media/MediaTransformOutput.php', 'MediaWiki' => __DIR__ . '/includes/MediaWiki.php', - 'MediaWikiI18N' => __DIR__ . '/includes/skins/MediaWikiI18N.php', 'MediaWikiShell' => __DIR__ . '/maintenance/shell.php', 'MediaWikiSite' => __DIR__ . '/includes/site/MediaWikiSite.php', 'MediaWikiTitleCodec' => __DIR__ . '/includes/title/MediaWikiTitleCodec.php', @@ -964,19 +966,12 @@ $wgAutoloadLocalClasses = [ 'MediaWiki\\Storage\\RevisionLookup' => __DIR__ . '/includes/Storage/RevisionLookup.php', 'MediaWiki\\Storage\\RevisionRecord' => __DIR__ . '/includes/Storage/RevisionRecord.php', 'MediaWiki\\Storage\\RevisionSlots' => __DIR__ . '/includes/Storage/RevisionSlots.php', + 'MediaWiki\\Storage\\RevisionSlotsUpdate' => __DIR__ . '/includes/Storage/RevisionSlotsUpdate.php', 'MediaWiki\\Storage\\RevisionStore' => __DIR__ . '/includes/Storage/RevisionStore.php', 'MediaWiki\\Storage\\RevisionStoreRecord' => __DIR__ . '/includes/Storage/RevisionStoreRecord.php', 'MediaWiki\\Storage\\SlotRecord' => __DIR__ . '/includes/Storage/SlotRecord.php', 'MediaWiki\\Storage\\SqlBlobStore' => __DIR__ . '/includes/Storage/SqlBlobStore.php', 'MediaWiki\\Storage\\SuppressedDataException' => __DIR__ . '/includes/Storage/SuppressedDataException.php', - 'MediaWiki\\Tidy\\BalanceActiveFormattingElements' => __DIR__ . '/includes/tidy/Balancer.php', - 'MediaWiki\\Tidy\\BalanceElement' => __DIR__ . '/includes/tidy/Balancer.php', - 'MediaWiki\\Tidy\\BalanceMarker' => __DIR__ . '/includes/tidy/Balancer.php', - 'MediaWiki\\Tidy\\BalanceSets' => __DIR__ . '/includes/tidy/Balancer.php', - 'MediaWiki\\Tidy\\BalanceStack' => __DIR__ . '/includes/tidy/Balancer.php', - 'MediaWiki\\Tidy\\Balancer' => __DIR__ . '/includes/tidy/Balancer.php', - 'MediaWiki\\Tidy\\Html5Depurate' => __DIR__ . '/includes/tidy/Html5Depurate.php', - 'MediaWiki\\Tidy\\Html5Internal' => __DIR__ . '/includes/tidy/Html5Internal.php', 'MediaWiki\\Tidy\\RaggettBase' => __DIR__ . '/includes/tidy/RaggettBase.php', 'MediaWiki\\Tidy\\RaggettExternal' => __DIR__ . '/includes/tidy/RaggettExternal.php', 'MediaWiki\\Tidy\\RaggettInternalHHVM' => __DIR__ . '/includes/tidy/RaggettInternalHHVM.php', @@ -993,6 +988,7 @@ $wgAutoloadLocalClasses = [ 'MediaWiki\\Widget\\ComplexTitleInputWidget' => __DIR__ . '/includes/widget/ComplexTitleInputWidget.php', 'MediaWiki\\Widget\\DateInputWidget' => __DIR__ . '/includes/widget/DateInputWidget.php', 'MediaWiki\\Widget\\DateTimeInputWidget' => __DIR__ . '/includes/widget/DateTimeInputWidget.php', + 'MediaWiki\\Widget\\ExpiryInputWidget' => __DIR__ . '/includes/widget/ExpiryInputWidget.php', 'MediaWiki\\Widget\\NamespaceInputWidget' => __DIR__ . '/includes/widget/NamespaceInputWidget.php', 'MediaWiki\\Widget\\SearchInputWidget' => __DIR__ . '/includes/widget/SearchInputWidget.php', 'MediaWiki\\Widget\\Search\\BasicSearchResultSetWidget' => __DIR__ . '/includes/widget/search/BasicSearchResultSetWidget.php', @@ -1096,7 +1092,7 @@ $wgAutoloadLocalClasses = [ 'Orphans' => __DIR__ . '/maintenance/orphans.php', 'OutputPage' => __DIR__ . '/includes/OutputPage.php', 'PHPVersionCheck' => __DIR__ . '/includes/PHPVersionCheck.php', - 'PNGHandler' => __DIR__ . '/includes/media/PNG.php', + 'PNGHandler' => __DIR__ . '/includes/media/PNGHandler.php', 'PNGMetadataExtractor' => __DIR__ . '/includes/media/PNGMetadataExtractor.php', 'PPCustomFrame_DOM' => __DIR__ . '/includes/parser/Preprocessor_DOM.php', 'PPCustomFrame_Hash' => __DIR__ . '/includes/parser/Preprocessor_Hash.php', @@ -1180,6 +1176,8 @@ $wgAutoloadLocalClasses = [ 'PostgresUpdater' => __DIR__ . '/includes/installer/PostgresUpdater.php', 'Preferences' => __DIR__ . '/includes/Preferences.php', 'PreferencesForm' => __DIR__ . '/includes/specials/forms/PreferencesForm.php', + 'PreferencesFormLegacy' => __DIR__ . '/includes/specials/forms/PreferencesFormLegacy.php', + 'PreferencesFormOOUI' => __DIR__ . '/includes/specials/forms/PreferencesFormOOUI.php', 'PrefixSearch' => __DIR__ . '/includes/PrefixSearch.php', 'PreprocessDump' => __DIR__ . '/maintenance/preprocessDump.php', 'Preprocessor' => __DIR__ . '/includes/parser/Preprocessor.php', @@ -1282,6 +1280,7 @@ $wgAutoloadLocalClasses = [ 'ResourceLoaderJqueryMsgModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderJqueryMsgModule.php', 'ResourceLoaderLanguageDataModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderLanguageDataModule.php', 'ResourceLoaderLanguageNamesModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderLanguageNamesModule.php', + 'ResourceLoaderLessVarFileModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderLessVarFileModule.php', 'ResourceLoaderMediaWikiUtilModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderMediaWikiUtilModule.php', 'ResourceLoaderModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderModule.php', 'ResourceLoaderOOUIFileModule' => __DIR__ . '/includes/resourceloader/ResourceLoaderOOUIFileModule.php', @@ -1479,7 +1478,6 @@ $wgAutoloadLocalClasses = [ 'SpecialWatchlist' => __DIR__ . '/includes/specials/SpecialWatchlist.php', 'SpecialWhatLinksHere' => __DIR__ . '/includes/specials/SpecialWhatlinkshere.php', 'SqlBagOStuff' => __DIR__ . '/includes/objectcache/SqlBagOStuff.php', - 'SqlDataUpdate' => __DIR__ . '/includes/deferred/SqlDataUpdate.php', 'SqlSearchResultSet' => __DIR__ . '/includes/search/SqlSearchResultSet.php', 'Sqlite' => __DIR__ . '/maintenance/sqlite.inc', 'SqliteInstaller' => __DIR__ . '/includes/installer/SqliteInstaller.php', @@ -1503,7 +1501,7 @@ $wgAutoloadLocalClasses = [ 'StubUserLang' => __DIR__ . '/includes/StubObject.php', 'SubmitAction' => __DIR__ . '/includes/actions/SubmitAction.php', 'SubpageImportTitleFactory' => __DIR__ . '/includes/title/SubpageImportTitleFactory.php', - 'SvgHandler' => __DIR__ . '/includes/media/SVG.php', + 'SvgHandler' => __DIR__ . '/includes/media/SvgHandler.php', 'SwiftFileBackend' => __DIR__ . '/includes/libs/filebackend/SwiftFileBackend.php', 'SwiftFileBackendDirList' => __DIR__ . '/includes/libs/filebackend/SwiftFileBackend.php', 'SwiftFileBackendFileList' => __DIR__ . '/includes/libs/filebackend/SwiftFileBackend.php', @@ -1529,7 +1527,7 @@ $wgAutoloadLocalClasses = [ 'ThumbnailImage' => __DIR__ . '/includes/media/MediaTransformOutput.php', 'ThumbnailRenderJob' => __DIR__ . '/includes/jobqueue/jobs/ThumbnailRenderJob.php', 'TidyUpBug37714' => __DIR__ . '/maintenance/tidyUpBug37714.php', - 'TiffHandler' => __DIR__ . '/includes/media/Tiff.php', + 'TiffHandler' => __DIR__ . '/includes/media/TiffHandler.php', 'Timing' => __DIR__ . '/includes/libs/Timing.php', 'Title' => __DIR__ . '/includes/Title.php', 'TitleArray' => __DIR__ . '/includes/TitleArray.php', @@ -1659,7 +1657,7 @@ $wgAutoloadLocalClasses = [ 'WebInstallerUpgrade' => __DIR__ . '/includes/installer/WebInstallerUpgrade.php', 'WebInstallerUpgradeDoc' => __DIR__ . '/includes/installer/WebInstallerUpgradeDoc.php', 'WebInstallerWelcome' => __DIR__ . '/includes/installer/WebInstallerWelcome.php', - 'WebPHandler' => __DIR__ . '/includes/media/WebP.php', + 'WebPHandler' => __DIR__ . '/includes/media/WebPHandler.php', 'WebRequest' => __DIR__ . '/includes/WebRequest.php', 'WebRequestUpload' => __DIR__ . '/includes/WebRequestUpload.php', 'WebResponse' => __DIR__ . '/includes/WebResponse.php', @@ -1675,6 +1673,7 @@ $wgAutoloadLocalClasses = [ 'WikiTextStructure' => __DIR__ . '/includes/content/WikiTextStructure.php', 'Wikimedia\\Http\\HttpAcceptNegotiator' => __DIR__ . '/includes/libs/http/HttpAcceptNegotiator.php', 'Wikimedia\\Http\\HttpAcceptParser' => __DIR__ . '/includes/libs/http/HttpAcceptParser.php', + 'Wikimedia\\Rdbms\\AtomicSectionIdentifier' => __DIR__ . '/includes/libs/rdbms/database/AtomicSectionIdentifier.php', 'Wikimedia\\Rdbms\\Blob' => __DIR__ . '/includes/libs/rdbms/encasing/Blob.php', 'Wikimedia\\Rdbms\\ChronologyProtector' => __DIR__ . '/includes/libs/rdbms/ChronologyProtector.php', 'Wikimedia\\Rdbms\\ConnectionManager' => __DIR__ . '/includes/libs/rdbms/connectionmanager/ConnectionManager.php', @@ -1690,6 +1689,7 @@ $wgAutoloadLocalClasses = [ 'Wikimedia\\Rdbms\\DBReplicationWaitError' => __DIR__ . '/includes/libs/rdbms/exception/DBReplicationWaitError.php', 'Wikimedia\\Rdbms\\DBTransactionError' => __DIR__ . '/includes/libs/rdbms/exception/DBTransactionError.php', 'Wikimedia\\Rdbms\\DBTransactionSizeError' => __DIR__ . '/includes/libs/rdbms/exception/DBTransactionSizeError.php', + 'Wikimedia\\Rdbms\\DBTransactionStateError' => __DIR__ . '/includes/libs/rdbms/exception/DBTransactionStateError.php', 'Wikimedia\\Rdbms\\DBUnexpectedError' => __DIR__ . '/includes/libs/rdbms/exception/DBUnexpectedError.php', 'Wikimedia\\Rdbms\\Database' => __DIR__ . '/includes/libs/rdbms/database/Database.php', 'Wikimedia\\Rdbms\\DatabaseDomain' => __DIR__ . '/includes/libs/rdbms/database/DatabaseDomain.php', diff --git a/composer.json b/composer.json index e193218c52..833e3bfd0e 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,7 @@ { "name": "mediawiki/core", "description": "Free software wiki application developed by the Wikimedia Foundation and others", + "type": "mediawiki-core", "keywords": ["mediawiki", "wiki"], "homepage": "https://www.mediawiki.org/", "authors": [ @@ -24,26 +25,26 @@ "ext-mbstring": "*", "ext-xml": "*", "liuggio/statsd-php-client": "1.0.18", - "mediawiki/at-ease": "1.2.0", - "oojs/oojs-ui": "0.26.1", + "oojs/oojs-ui": "0.27.0", "oyejorge/less.php": "1.7.0.14", "php": ">=5.5.9", "psr/log": "1.0.2", "wikimedia/assert": "0.2.2", + "wikimedia/at-ease": "1.2.0", "wikimedia/base-convert": "1.0.1", "wikimedia/cdb": "1.4.1", "wikimedia/cldr-plural-rule-parser": "1.0.0", "wikimedia/composer-merge-plugin": "1.4.1", - "wikimedia/html-formatter": "1.0.1", + "wikimedia/html-formatter": "1.0.2", "wikimedia/ip-set": "1.2.0", "wikimedia/object-factory": "1.0.0", - "wikimedia/php-session-serializer": "1.0.5", + "wikimedia/php-session-serializer": "1.0.6", "wikimedia/purtle": "1.0.7", "wikimedia/relpath": "2.1.1", "wikimedia/remex-html": "1.0.3", "wikimedia/running-stat": "1.2.1", "wikimedia/scoped-callback": "1.0.0", - "wikimedia/utfnormal": "1.1.0", + "wikimedia/utfnormal": "2.0.0", "wikimedia/timestamp": "1.0.0", "wikimedia/wait-condition-loop": "1.0.1", "wikimedia/wrappedstring": "2.3.0", @@ -59,7 +60,7 @@ "monolog/monolog": "~1.22.1", "nikic/php-parser": "3.1.3", "nmred/kafka-php": "0.1.5", - "phpunit/phpunit": "4.8.36", + "phpunit/phpunit": "4.8.36 || ^6.5", "psy/psysh": "0.8.11", "wikimedia/avro": "1.8.0", "wikimedia/testing-access-wrapper": "~1.0", diff --git a/docs/database.txt b/docs/database.txt index dbc92044de..6e88d681f4 100644 --- a/docs/database.txt +++ b/docs/database.txt @@ -71,7 +71,7 @@ want to write code destined for Wikipedia. It's often the case that the best algorithm to use for a given task depends on whether or not replication is in use. Due to our unabashed Wikipedia-centrism, we often just use the replication-friendly version, -but if you like, you can use wfGetLB()->getServerCount() > 1 to +but if you like, you can use LoadBalancer::getServerCount() > 1 to check to see if replication is in use. === Lag === @@ -107,7 +107,7 @@ in the session, and then at the start of each request, waiting for the slave to catch up to that position before doing any reads from it. If this wait times out, reads are allowed anyway, but the request is considered to be in "lagged slave mode". Lagged slave mode can be -checked by calling wfGetLB()->getLaggedSlaveMode(). The only +checked by calling LoadBalancer::getLaggedReplicaMode(). The only practical consequence at present is a warning displayed in the page footer. diff --git a/docs/distributors.txt b/docs/distributors.txt index 758111009f..729dffa3a1 100644 --- a/docs/distributors.txt +++ b/docs/distributors.txt @@ -87,10 +87,15 @@ which the user can edit by hand thereafter. It's just a plain old PHP file, and can contain any PHP statements. It usually sets global variables that are used for configuration, and includes files used by any extensions. -Distributors can easily change the installer behavior, including LocalSettings -generated, by placing their overrides into mw-config/overrides directory. Doing -that is highly preferred to modifying MediaWiki code directly. See -mw-config/overrides/README for more details and examples. +Distributors can easily change the default settings by creating +includes/PlatformSettings.php with overrides/additions to the default settings. +The installer will automatically include the platform defaults when generating +the user's LocalSettings.php file. + +Furthermore, distributors can change the installer behavior, by placing their +overrides into mw-config/overrides directory. Doing that is highly preferred +to modifying MediaWiki code directly. See mw-config/overrides/README for more +details and examples. There's a new maintenance/install.php script which could be used for performing an install through the command line. diff --git a/docs/hooks.txt b/docs/hooks.txt index 4e8474bfd6..b38bd666e4 100644 --- a/docs/hooks.txt +++ b/docs/hooks.txt @@ -467,6 +467,12 @@ can alter or append to the array. (url), 'width', 'height', 'alt', 'align'. - url: Url for the given title. +'ApiParseMakeOutputPage': Called when preparing the OutputPage object for +ApiParse. This is mainly intended for calling OutputPage::addContentOverride() +or OutputPage::addContentOverrideCallback(). +$module: ApiBase (which is also a ContextSource) +$output: OutputPage + 'ApiQuery::moduleManager': Called when ApiQuery has finished initializing its module manager. Can be used to conditionally register API query modules. $moduleManager: ApiModuleManager Module manager instance @@ -1209,6 +1215,14 @@ $row: the DB row for this line Currently only data attributes reserved to MediaWiki are allowed (see Sanitizer::isReservedDataAttribute). +'DeleteUnknownPreferences': Called by the cleanupPreferences.php maintenance script to build a WHERE clause with which +to delete preferences that are not known about. This hook is used by extensions that have dynamically-named preferences +that should not be deleted in the usual cleanup process. For example, the Gadgets extension creates preferences prefixed +with 'gadget-', and so anything with that prefix is excluded from the deletion. +&where: An array that will be passed as the $cond parameter to IDatabase::select() to determine what will be deleted + from the user_properties table. +$db: The IDatabase object, useful for accessing $db->buildLike() etc. + 'DifferenceEngineAfterLoadNewText': called in DifferenceEngine::loadNewText() after the new revision's content has been loaded into the class member variable $differenceEngine->mNewContent but before returning true from this function. @@ -2787,12 +2801,6 @@ configuration variables to JavaScript. Things that depend on the current page or request state must be added through MakeGlobalVariablesScript instead. &$vars: array( variable name => value ) -'ResourceLoaderGetLessVars': DEPRECATED! Called in ResourceLoader::getLessVars -to add global LESS variables. Loaded after $wgResourceLoaderLESSVars is added. -Global LESS variables are deprecated. Use ResourceLoaderModule::getLessVars() -instead to expose variables only in modules that need them. -&$lessVars: array of variables already added - 'ResourceLoaderJqueryMsgModuleMagicWords': Called in ResourceLoaderJqueryMsgModule to allow adding magic words for jQueryMsg. The value should be a string, and they can depend only on the diff --git a/docs/scripts.txt b/docs/scripts.txt index 53dff36e42..dff428c5a2 100644 --- a/docs/scripts.txt +++ b/docs/scripts.txt @@ -39,14 +39,10 @@ Primary scripts: maintenance/archives/patch-profiling.sql patch to the database. To enable the profileinfo.php itself, you'll need to set $wgDBadminuser - and $wgDBadminpassword in your LocalSettings.php, as well as $wgEnableProfileInfo + and $wgDBadminpassword in your LocalSettings.php, as well as $wgEnableProfileInfo See also https://www.mediawiki.org/wiki/Manual:Profiling . thumb.php Script used to resize images if it is configured to be done when the web browser requests the image and not when generating the page. This script can be used as a 404 handler to generate image thumbs when they don't exist. - -There is also a file with a .php5 extension for each script. They can be used if -the web server needs a .php5 to run the file with the PHP 5 engine and runs .php -scripts with PHP 4. You should not use them anymore. diff --git a/docs/uidesign/design.html b/docs/uidesign/design.html index 6ab57d7d4f..8395cd5bb7 100644 --- a/docs/uidesign/design.html +++ b/docs/uidesign/design.html @@ -2,7 +2,7 @@ - + diff --git a/docs/uidesign/mediawiki.diff.html b/docs/uidesign/mediawiki.diff.html index cd13dbac20..651cac1661 100644 --- a/docs/uidesign/mediawiki.diff.html +++ b/docs/uidesign/mediawiki.diff.html @@ -2,8 +2,8 @@ - - + + diff --git a/includes/Category.php b/includes/Category.php index 9241730a04..46b86d8cd8 100644 --- a/includes/Category.php +++ b/includes/Category.php @@ -328,25 +328,35 @@ class Category { $dbw = wfGetDB( DB_MASTER ); # Avoid excess contention on the same category (T162121) $name = __METHOD__ . ':' . md5( $this->mName ); - $scopedLock = $dbw->getScopedLockAndFlush( $name, __METHOD__, 1 ); + $scopedLock = $dbw->getScopedLockAndFlush( $name, __METHOD__, 0 ); if ( !$scopedLock ) { return false; } $dbw->startAtomic( __METHOD__ ); - $cond1 = $dbw->conditional( [ 'page_namespace' => NS_CATEGORY ], 1, 'NULL' ); - $cond2 = $dbw->conditional( [ 'page_namespace' => NS_FILE ], 1, 'NULL' ); - $result = $dbw->selectRow( + // Lock all the `categorylinks` records and gaps for this category; + // this is a separate query due to postgres/oracle limitations + $dbw->selectRowCount( [ 'categorylinks', 'page' ], - [ 'pages' => 'COUNT(*)', - 'subcats' => "COUNT($cond1)", - 'files' => "COUNT($cond2)" - ], + '*', [ 'cl_to' => $this->mName, 'page_id = cl_from' ], __METHOD__, [ 'LOCK IN SHARE MODE' ] ); + // Get the aggregate `categorylinks` row counts for this category + $catCond = $dbw->conditional( [ 'page_namespace' => NS_CATEGORY ], 1, 'NULL' ); + $fileCond = $dbw->conditional( [ 'page_namespace' => NS_FILE ], 1, 'NULL' ); + $result = $dbw->selectRow( + [ 'categorylinks', 'page' ], + [ + 'pages' => 'COUNT(*)', + 'subcats' => "COUNT($catCond)", + 'files' => "COUNT($fileCond)" + ], + [ 'cl_to' => $this->mName, 'page_id = cl_from' ], + __METHOD__ + ); $shouldExist = $result->pages > 0 || $this->getTitle()->exists(); diff --git a/includes/CategoryViewer.php b/includes/CategoryViewer.php index f36c75800b..4202249578 100644 --- a/includes/CategoryViewer.php +++ b/includes/CategoryViewer.php @@ -735,11 +735,7 @@ class CategoryViewer extends ContextSource { $totalcnt = $dbcnt; } elseif ( $rescnt < $this->limit && !$fromOrUntil ) { // Case 2: not sane, but salvageable. Use the number of results. - // Since there are fewer than 200, we can also take this opportunity - // to refresh the incorrect category table entry -- which should be - // quick due to the small number of entries. $totalcnt = $rescnt; - DeferredUpdates::addCallableUpdate( [ $this->cat, 'refreshCounts' ] ); } else { // Case 3: hopeless. Don't give a total count at all. // Messages: category-subcat-count-limited, category-article-count-limited, diff --git a/includes/CommentStore.php b/includes/CommentStore.php index 55f6857267..e9b08e89dc 100644 --- a/includes/CommentStore.php +++ b/includes/CommentStore.php @@ -134,7 +134,7 @@ class CommentStore { /** * Compat method allowing use of self::newKey until removed. * @param string|null $methodKey - * @throw InvalidArgumentException + * @throws InvalidArgumentException * @return string */ private function getKey( $methodKey = null ) { diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 81d3c35d29..18c941ef76 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -71,7 +71,7 @@ $wgConfigRegistry = [ * MediaWiki version number * @since 1.2 */ -$wgVersion = '1.31.0-alpha'; +$wgVersion = '1.32.0-alpha'; /** * Name of the site. It must be changed in LocalSettings.php @@ -157,19 +157,6 @@ $wgUsePathInfo = ( strpos( PHP_SAPI, 'cgi' ) === false ) && ( strpos( PHP_SAPI, 'apache2filter' ) === false ) && ( strpos( PHP_SAPI, 'isapi' ) === false ); -/** - * The extension to append to script names by default. - * - * Some hosting providers used PHP 4 for *.php files, and PHP 5 for *.php5. - * This variable was provided to support those providers. - * - * @since 1.11 - * @deprecated since 1.25; support for '.php5' has been phased out of MediaWiki - * proper. Backward-compatibility can be maintained by configuring your web - * server to rewrite URLs. See RELEASE-NOTES for details. - */ -$wgScriptExtension = '.php'; - /**@}*/ /************************************************************************//** @@ -503,9 +490,6 @@ $wgImgAuthUrlPathMap = []; * - descBaseUrl URL of image description pages, e.g. https://en.wikipedia.org/wiki/File: * - scriptDirUrl URL of the MediaWiki installation, equivalent to $wgScriptPath, e.g. * https://en.wikipedia.org/w - * - scriptExtension Script extension of the MediaWiki installation, equivalent to - * $wgScriptExtension, e.g. ".php5". Defaults to ".php". - * * - articleUrl Equivalent to $wgArticlePath, e.g. https://en.wikipedia.org/wiki/$1 * - fetchDescription Fetch the text of the remote file description page. Equivalent to * $wgFetchCommonsDescriptions. @@ -1101,6 +1085,15 @@ $wgJpegTran = '/usr/bin/jpegtran'; */ $wgJpegPixelFormat = 'yuv420'; +/** + * When scaling a JPEG thumbnail, this is the quality we request + * from the backend. It should be an int between 1 and 100, + * with 100 indicating 100% quality. + * + * @since 1.32 + */ +$wgJpegQuality = 80; + /** * Some tests and extensions use exiv2 to manipulate the Exif metadata in some * image formats. @@ -1580,16 +1573,17 @@ $wgDjvuOutputExtension = 'jpg'; /** * Site admin email address. * - * Defaults to "wikiadmin@$wgServerName". + * Defaults to "wikiadmin@$wgServerName" (in Setup.php). */ $wgEmergencyContact = false; /** * Sender email address for e-mail notifications. * - * The address we use as sender when a user requests a password reminder. + * The address we use as sender when a user requests a password reminder, + * as well as other e-mail notifications. * - * Defaults to "apache@$wgServerName". + * Defaults to "apache@$wgServerName" (in Setup.php). */ $wgPasswordSender = false; @@ -1603,7 +1597,7 @@ $wgPasswordSenderName = 'MediaWiki Mail'; /** * Reply-To address for e-mail notifications. * - * Defaults to $wgPasswordSender. + * Defaults to $wgPasswordSender (in Setup.php). */ $wgNoReplyAddress = false; @@ -1697,8 +1691,15 @@ $wgAdditionalMailParams = null; $wgAllowHTMLEmail = false; /** - * True: from page editor if s/he opted-in. False: Enotif mails appear to come - * from $wgEmergencyContact + * Allow sending of e-mail notifications with the editor's address as sender. + * + * This setting depends on $wgEnotifRevealEditorAddress also being enabled. + * If both are enabled, notifications for actions from users that have opted-in, + * will be sent to other users with their address as "From" instead of "Reply-To". + * + * If disabled, or not opted-in, notifications come from $wgPasswordSender. + * + * @var bool */ $wgEnotifFromEditor = false; @@ -1730,8 +1731,18 @@ $wgEnotifWatchlist = false; $wgEnotifUserTalk = false; /** - * Set the Reply-to address in notifications to the editor's address, if user - * allowed this in the preferences. + * Allow sending of e-mail notifications with the editor's address in "Reply-To". + * + * Note, enabling this only actually uses it in notification e-mails if the user + * opted-in to this feature. This feature flag also controls visibility of the + * 'enotifrevealaddr' preference, which, if users opt into, will make e-mail + * notifications about their actions use their address as "Reply-To". + * + * To set the address as "From" instead of "Reply-To", also enable $wgEnotifFromEditor. + * + * If disabled, or not opted-in, notifications come from $wgPasswordSender. + * + * @var bool */ $wgEnotifRevealEditorAddress = false; @@ -1908,8 +1919,8 @@ $wgSQLiteDataDir = ''; * $wgSharedSchema is the table schema for the shared database. It defaults to * $wgDBmwschema. * - * @deprecated since 1.21 In new code, use the $wiki parameter to wfGetLB() to - * access remote databases. Using wfGetLB() allows the shared database to + * @deprecated since 1.21 In new code, use the $wiki parameter to LBFactory::getMainLB() to + * access remote databases. Using LBFactory::getMainLB() allows the shared database to * reside on separate servers to the wiki's own database, with suitable * configuration of $wgLBFactoryConf. */ @@ -3226,6 +3237,14 @@ $wgHTMLFormAllowTableFormat = true; */ $wgUseMediaWikiUIEverywhere = false; +/** + * Temporary variable that determines whether the EditPage class should use OOjs UI or not. + * This will be removed later and OOjs UI will become the only option. + * + * @since 1.32 + */ +$wgOOUIPreferences = false; + /** * Whether to label the store-to-database-and-show-to-others button in the editor * as "Save page"/"Save changes" if false (the default) or, if true, instead as @@ -3260,17 +3279,6 @@ $wgXhtmlNamespaces = []; */ $wgSiteNotice = ''; -/** - * If this is set, a "donate" link will appear in the sidebar. Set it to a URL. - */ -$wgSiteSupportPage = ''; - -/** - * Validate the overall output using tidy and refuse - * to display the page if it's not valid. - */ -$wgValidateAllHtml = false; - /** * Default skin, for new users and anonymous visitors. Registered users may * change this to any one of the other available skins in their preferences. @@ -3372,23 +3380,12 @@ $wgApiFrameOptions = 'DENY'; */ $wgDisableOutputCompression = false; -/** - * Abandoned experiment with HTML5-style ID escaping. Normalized IDs a bit - * too aggressively, breaking preexisting content (particularly Cite). - * See T29733, T29694, T29474. - * - * @deprecated since 1.30, use $wgFragmentMode - */ -$wgExperimentalHtmlIds = false; - /** * How should section IDs be encoded? * This array can contain 1 or 2 elements, each of them can be one of: * - 'html5' is modern HTML5 style encoding with minimal escaping. Displays Unicode * characters in most browsers' address bars. * - 'legacy' is old MediaWiki-style encoding, e.g. 啤酒 turns into .E5.95.A4.E9.85.92 - * - 'html5-legacy' corresponds to DEPRECATED $wgExperimentalHtmlIds mode. DO NOT use - * it for anything but migration off that mode (see below). * * The first element of this array specifies the primary mode of escaping IDs. This * is what users will see when they e.g. follow an [[#internal link]] to a section of @@ -4270,8 +4267,9 @@ $wgAllowImageTag = false; /** * Configuration for HTML postprocessing tool. Set this to a configuration - * array to enable an external tool. Dave Raggett's "HTML Tidy" is typically - * used. See https://www.w3.org/People/Raggett/tidy/ + * array to enable an external tool. By default, we now use the RemexHtml + * library; historically, Dave Raggett's "HTML Tidy" was typically used. + * See https://www.w3.org/People/Raggett/tidy/ * * If this is null and $wgUseTidy is true, the deprecated configuration * parameters will be used instead. @@ -4283,8 +4281,6 @@ $wgAllowImageTag = false; * - RaggettInternalHHVM: Use the limited-functionality HHVM extension * - RaggettInternalPHP: Use the PECL extension * - RaggettExternal: Shell out to an external binary (tidyBin) - * - Html5Depurate: Use external Depurate service - * - Html5Internal: Use the Balancer library in PHP * - RemexHtml: Use the RemexHtml library in PHP * * - tidyConfigFile: Path to configuration file for any of the Raggett drivers @@ -4292,7 +4288,7 @@ $wgAllowImageTag = false; * - tidyBin: For RaggettExternal, the path to the tidy binary. * - tidyCommandLine: For RaggettExternal, additional command line options. */ -$wgTidyConfig = null; +$wgTidyConfig = [ 'driver' => 'RemexHtml' ]; /** * Set this to true to use the deprecated tidy configuration parameters. @@ -5673,6 +5669,7 @@ $wgRateLimits = [ 'edit' => [ 'ip' => [ 8, 60 ], 'newbie' => [ 8, 60 ], + 'user' => [ 90, 60 ], ], // Page moves 'move' => [ @@ -6066,7 +6063,7 @@ $wgUseTeX = false; /************************************************************************//** * @name Profiling, testing and debugging * - * To enable profiling, edit StartProfiler.php + * See $wgProfiler for how to enable profiling. * * @{ */ @@ -6311,6 +6308,66 @@ $wgDevelopmentWarnings = false; */ $wgDeprecationReleaseLimit = false; +/** + * Profiler configuration. + * + * To use a profiler, set $wgProfiler in LocalSetings.php. + * For backwards-compatibility, it is also allowed to set the variable from + * a separate file called StartProfiler.php, which MediaWiki will include. + * + * Example: + * + * @code + * $wgProfiler['class'] = ProfilerXhprof::class; + * @endcode + * + * For output, set the 'output' key to an array of class names, one for each + * output type you want the profiler to generate. For example: + * + * @code + * $wgProfiler['output'] = [ ProfilerOutputText::class ]; + * @endcode + * + * The output classes available to you by default are ProfilerOutputDb, + * ProfilerOutputDump, ProfilerOutputStats, ProfilerOutputText, and + * ProfilerOutputUdp. + * + * ProfilerOutputStats outputs profiling data as StatsD metrics. It expects + * that you have set the $wgStatsdServer configuration variable to the host (or + * host:port) of your statsd server. + * + * ProfilerOutputText will output profiling data in the page body as a comment. + * You can make the profiling data in HTML render as part of the page content + * by setting the 'visible' configuration flag: + * + * @code + * $wgProfiler['visible'] = true; + * @endcode + * + * 'ProfilerOutputDb' expects a database table that can be created by applying + * maintenance/archives/patch-profiling.sql to your database. + * + * 'ProfilerOutputDump' expects a $wgProfiler['outputDir'] telling it where to + * write dump files. The files produced are compatible with the XHProf gui. + * For a rudimentary sampling profiler: + * + * @code + * $wgProfiler['class'] = 'ProfilerXhprof'; + * $wgProfiler['output'] = array( 'ProfilerOutputDb' ); + * $wgProfiler['sampling'] = 50; // one every 50 requests + * @endcode + * + * When using the built-in `sampling` option, the `class` will changed to + * ProfilerStub for non-sampled cases. + * + * For performance, the profiler is always disabled for CLI scripts as they + * could be long running and the data would accumulate. Use the '--profiler' + * parameter of maintenance scripts to override this. + * + * @since 1.17.0 + */ +$wgProfiler = []; + /** * Only record profiling info for pages that took longer than this * @deprecated since 1.25: set $wgProfiler['threshold'] instead. @@ -6867,11 +6924,6 @@ $wgUseNPPatrol = true; */ $wgUseFilePatrol = true; -/** - * Log autopatrol actions to the log table - */ -$wgLogAutopatrol = true; - /** * Provide syndication feeds (RSS, Atom) for, e.g., Recentchanges, Newpages */ @@ -7812,10 +7864,6 @@ $wgActionFilteredLogs = [ 'autocreate' => [ 'autocreate' ], 'byemail' => [ 'byemail' ], ], - 'patrol' => [ - 'patrol' => [ 'patrol' ], - 'autopatrol' => [ 'autopatrol' ], - ], 'protect' => [ 'protect' => [ 'protect' ], 'modify' => [ 'modify' ], @@ -7994,25 +8042,6 @@ $wgExemptFromUserRobotsControl = null; * @{ */ -/** - * Enable the MediaWiki API for convenient access to - * machine-readable data via api.php - * - * See https://www.mediawiki.org/wiki/API - * - * @deprecated since 1.31 - */ -$wgEnableAPI = true; - -/** - * Allow the API to be used to perform write operations - * (page edits, rollback, etc.) when an authorised user - * accesses it - * - * @deprecated since 1.31 - */ -$wgEnableWriteAPI = true; - /** * * WARNING: SECURITY THREAT - debug use only @@ -8151,6 +8180,8 @@ $wgAPIUselessQueryPages = [ /** * Enable AJAX framework + * + * @deprecated (officially) since MediaWiki 1.31 and ignored since 1.32 */ $wgUseAjax = true; @@ -8167,7 +8198,7 @@ $wgAjaxExportList = []; $wgAjaxUploadDestCheck = true; /** - * Enable previewing licences via AJAX. Also requires $wgEnableAPI to be true. + * Enable previewing licences via AJAX. */ $wgAjaxLicensePreview = true; @@ -8261,7 +8292,7 @@ $wgMaxShellWallClockTime = 180; $wgShellCgroup = false; /** - * Executable path of the PHP cli binary (php/php5). Should be set up on install. + * Executable path of the PHP cli binary. Should be set up on install. */ $wgPhpCli = '/usr/bin/php'; @@ -8842,6 +8873,15 @@ $wgCommentTableSchemaMigrationStage = MIGRATION_OLD; */ $wgActorTableSchemaMigrationStage = MIGRATION_OLD; +/** + * Temporary option to disable the date picker from the Expiry Widget. + * + * @since 1.32 + * @deprecated 1.32 + * @var bool + */ +$wgExpiryWidgetNoDatePicker = false; + /** * For really cool vim folding this needs to be at the end: * vim: foldmarker=@{,@} foldmethod=marker diff --git a/includes/EditPage.php b/includes/EditPage.php index a1d9ae82d5..4f6b7b4bbb 100644 --- a/includes/EditPage.php +++ b/includes/EditPage.php @@ -504,16 +504,6 @@ class EditPage { } } - /** - * Check if the edit page is using OOUI controls - * @return bool Always true - * @deprecated since 1.30 - */ - public function isOouiEnabled() { - wfDeprecated( __METHOD__, '1.30' ); - return true; - } - /** * Returns if the given content model is editable. * @@ -3893,6 +3883,9 @@ ERROR; $previewHTML = $parserResult['html']; $this->mParserOutput = $parserOutput; $out->addParserOutputMetadata( $parserOutput ); + if ( $out->userCanPreview() ) { + $out->addContentOverride( $this->getTitle(), $content ); + } if ( count( $parserOutput->getWarnings() ) ) { $note .= "\n\n" . implode( "\n\n", $parserOutput->getWarnings() ); diff --git a/includes/FileDeleteForm.php b/includes/FileDeleteForm.php index 783de1c0c4..898005ecc9 100644 --- a/includes/FileDeleteForm.php +++ b/includes/FileDeleteForm.php @@ -212,7 +212,7 @@ class FileDeleteForm { $logEntry->setTags( $tags ); $logid = $logEntry->insert(); $dbw->onTransactionPreCommitOrIdle( - function () use ( $dbw, $logEntry, $logid ) { + function () use ( $logEntry, $logid ) { $logEntry->publish( $logid ); }, __METHOD__ diff --git a/includes/GlobalFunctions.php b/includes/GlobalFunctions.php index 513f59346c..9569bc1fb4 100644 --- a/includes/GlobalFunctions.php +++ b/includes/GlobalFunctions.php @@ -712,6 +712,8 @@ function wfAssembleUrl( $urlParts ) { * * @todo Need to integrate this into wfExpandUrl (see T34168) * + * @since 1.19 + * * @param string $urlPath URL path, potentially containing dot-segments * @return string URL path with all dot-segments removed */ @@ -2269,18 +2271,6 @@ function wfEscapeShellArg( /*...*/ ) { return call_user_func_array( Shell::class . '::escape', $args ); } -/** - * Check if wfShellExec() is effectively disabled via php.ini config - * - * @return bool|string False or 'disabled' - * @since 1.22 - * @deprecated since 1.30 use MediaWiki\Shell::isDisabled() - */ -function wfShellExecDisabled() { - wfDeprecated( __FUNCTION__, '1.30' ); - return Shell::isDisabled() ? 'disabled' : false; -} - /** * Execute a shell command, with time and memory limits mirrored from the PHP * configuration if supported. @@ -2327,6 +2317,8 @@ function wfShellExec( $cmd, &$retval = null, $environ = [], ->limits( $limits ) ->includeStderr( $includeStderr ) ->profileMethod( $profileMethod ) + // For b/c + ->restrict( Shell::RESTRICT_NONE ) ->execute(); } catch ( ProcOpenError $ex ) { $retval = -1; @@ -2360,23 +2352,13 @@ function wfShellExecWithStderr( $cmd, &$retval = null, $environ = [], $limits = [ 'duplicateStderr' => true, 'profileMethod' => wfGetCaller() ] ); } -/** - * Formerly set the locale for locale-sensitive operations - * - * This is now done in Setup.php. - * - * @deprecated since 1.30, no longer needed - * @see $wgShellLocale - */ -function wfInitShellLocale() { - wfDeprecated( __FUNCTION__, '1.30' ); -} - /** * Generate a shell-escaped command line string to run a MediaWiki cli script. * Note that $parameters should be a flat array and an option with an argument * should consist of two consecutive items in the array (do not use "--option value"). * + * @deprecated since 1.31, use Shell::makeScriptCommand() + * * @param string $script MediaWiki cli script path * @param array $parameters Arguments and options to the script * @param array $options Associative array of options: @@ -2579,6 +2561,7 @@ function wfDiff( $before, $after, $params = '-u' ) { * @throws MWException */ function wfUsePHP( $req_ver ) { + wfDeprecated( __FUNCTION__, '1.30' ); $php_ver = PHP_VERSION; if ( version_compare( $php_ver, (string)$req_ver, '<' ) ) { @@ -3055,6 +3038,7 @@ function wfWaitForSlaves( * @param int $seconds */ function wfCountDown( $seconds ) { + wfDeprecated( __FUNCTION__, '1.31' ); for ( $i = $seconds; $i >= 0; $i-- ) { if ( $i != $seconds ) { echo str_repeat( "\x08", strlen( $i + 1 ) ); diff --git a/includes/MediaWiki.php b/includes/MediaWiki.php index b3abe7cfca..e6dc0fecd3 100644 --- a/includes/MediaWiki.php +++ b/includes/MediaWiki.php @@ -758,7 +758,7 @@ class MediaWiki { $request = $this->context->getRequest(); // Send Ajax requests to the Ajax dispatcher. - if ( $this->config->get( 'UseAjax' ) && $request->getVal( 'action' ) === 'ajax' ) { + if ( $request->getVal( 'action' ) === 'ajax' ) { // Set a dummy title, because $wgTitle == null might break things $title = Title::makeTitle( NS_SPECIAL, 'Badtitle/performing an AJAX call in ' . __METHOD__ @@ -998,8 +998,14 @@ class MediaWiki { * @param LoggerInterface $runJobsLogger */ private function triggerSyncJobs( $n, LoggerInterface $runJobsLogger ) { - $runner = new JobRunner( $runJobsLogger ); - $runner->run( [ 'maxJobs' => $n ] ); + $trxProfiler = Profiler::instance()->getTransactionProfiler(); + $old = $trxProfiler->setSilenced( true ); + try { + $runner = new JobRunner( $runJobsLogger ); + $runner->run( [ 'maxJobs' => $n ] ); + } finally { + $trxProfiler->setSilenced( $old ); + } } /** diff --git a/includes/NoLocalSettings.php b/includes/NoLocalSettings.php index b8bfd6a4f0..46e9630f34 100644 --- a/includes/NoLocalSettings.php +++ b/includes/NoLocalSettings.php @@ -22,13 +22,11 @@ # T32219 : can not use pathinfo() on URLs since slashes do not match $matches = []; -$ext = 'php'; $path = '/'; foreach ( array_filter( explode( '/', $_SERVER['PHP_SELF'] ) ) as $part ) { - if ( !preg_match( '/\.(php5?)$/', $part, $matches ) ) { + if ( !preg_match( '/\.(php)$/', $part, $matches ) ) { $path .= "$part/"; } else { - $ext = $matches[1] == 'php5' ? 'php5' : 'php'; break; } } @@ -56,7 +54,6 @@ try { [ 'wgVersion' => ( isset( $wgVersion ) ? $wgVersion : 'VERSION' ), 'path' => $path, - 'ext' => $ext, 'localSettingsExists' => file_exists( MW_CONFIG_FILE ), 'installerStarted' => $installerStarted ] diff --git a/includes/OutputHandler.php b/includes/OutputHandler.php index 842922d566..16c37841c8 100644 --- a/includes/OutputHandler.php +++ b/includes/OutputHandler.php @@ -22,9 +22,6 @@ namespace MediaWiki; -use MWTidy; -use Html; - /** * @since 1.31 */ @@ -36,31 +33,10 @@ class OutputHandler { * @return string */ public static function handle( $s ) { - global $wgDisableOutputCompression, $wgValidateAllHtml, $wgMangleFlashPolicy; + global $wgDisableOutputCompression, $wgMangleFlashPolicy; if ( $wgMangleFlashPolicy ) { $s = self::mangleFlashPolicy( $s ); } - if ( $wgValidateAllHtml ) { - $headers = headers_list(); - $isHTML = false; - foreach ( $headers as $header ) { - $parts = explode( ':', $header, 2 ); - if ( count( $parts ) !== 2 ) { - continue; - } - $name = strtolower( trim( $parts[0] ) ); - $value = trim( $parts[1] ); - if ( $name == 'content-type' && ( strpos( $value, 'text/html' ) === 0 - || strpos( $value, 'application/xhtml+xml' ) === 0 ) - ) { - $isHTML = true; - break; - } - } - if ( $isHTML ) { - $s = self::validateAllHtml( $s ); - } - } if ( !$wgDisableOutputCompression && !ini_get( 'zlib.output_compression' ) ) { if ( !defined( 'MW_NO_OUTPUT_COMPRESSION' ) ) { $s = self::handleGzip( $s ); @@ -183,65 +159,4 @@ class OutputHandler { header( "Content-Length: $length" ); } } - - /** - * Replace the output with an error if the HTML is not valid. - * - * @param string $s - * @return string - */ - private static function validateAllHtml( $s ) { - $errors = ''; - if ( MWTidy::checkErrors( $s, $errors ) ) { - return $s; - } - - header( 'Cache-Control: no-cache' ); - - $out = Html::element( 'h1', null, 'HTML validation error' ); - $out .= Html::openElement( 'ul' ); - - $error = strtok( $errors, "\n" ); - $badLines = []; - while ( $error !== false ) { - if ( preg_match( '/^line (\d+)/', $error, $m ) ) { - $lineNum = intval( $m[1] ); - $badLines[$lineNum] = true; - $out .= Html::rawElement( 'li', null, - Html::element( 'a', [ 'href' => "#line-{$lineNum}" ], $error ) ) . "\n"; - } - $error = strtok( "\n" ); - } - - $out .= Html::closeElement( 'ul' ); - $out .= Html::element( 'pre', null, $errors ); - $out .= Html::openElement( 'ol' ) . "\n"; - $line = strtok( $s, "\n" ); - $i = 1; - while ( $line !== false ) { - $attrs = []; - if ( isset( $badLines[$i] ) ) { - $attrs['class'] = 'highlight'; - $attrs['id'] = "line-$i"; - } - $out .= Html::element( 'li', $attrs, $line ) . "\n"; - $line = strtok( "\n" ); - $i++; - } - $out .= Html::closeElement( 'ol' ); - - $style = << 'en', 'dir' => 'ltr' ] ) . - Html::rawElement( 'head', null, - Html::element( 'title', null, 'HTML validation error' ) . - Html::inlineStyle( $style ) ) . - Html::rawElement( 'body', null, $out ) . - Html::closeElement( 'html' ); - - return $out; - } } diff --git a/includes/OutputPage.php b/includes/OutputPage.php index 99dd4a7c0e..fbc7b60439 100644 --- a/includes/OutputPage.php +++ b/includes/OutputPage.php @@ -20,6 +20,7 @@ * @file */ +use MediaWiki\Linker\LinkTarget; use MediaWiki\Logger\LoggerFactory; use MediaWiki\MediaWikiServices; use MediaWiki\Session\SessionManager; @@ -155,9 +156,6 @@ class OutputPage extends ContextSource { /** @var ResourceLoaderContext */ private $rlClientContext; - /** @var string */ - private $rlUserModuleState; - /** @var array */ private $rlExemptStyleModules; @@ -295,6 +293,12 @@ class OutputPage extends ContextSource { /** @var array Profiling data */ private $limitReportJSData = []; + /** @var array Map Title to Content */ + private $contentOverrides = []; + + /** @var callable[] */ + private $contentOverrideCallbacks = []; + /** * Link: header contents */ @@ -546,9 +550,7 @@ class OutputPage extends ContextSource { } /** - * Add one or more modules recognized by ResourceLoader. Modules added - * through this function will be loaded by ResourceLoader when the - * page loads. + * Load one or more ResourceLoader modules on this page. * * @param string|array $modules Module name (string) or array of module names */ @@ -557,7 +559,7 @@ class OutputPage extends ContextSource { } /** - * Get the list of module JS to include on this page + * Get the list of script-only modules to load on this page. * * @param bool $filter * @param string|null $position Unused @@ -570,10 +572,13 @@ class OutputPage extends ContextSource { } /** - * Add only JS of one or more modules recognized by ResourceLoader. Module - * scripts added through this function will be loaded by ResourceLoader when - * the page loads. + * Load the scripts of one or more ResourceLoader modules, on this page. + * + * This method exists purely to provide the legacy behaviour of loading + * a module's scripts in the global scope, and without dependency resolution. + * See . * + * @deprecated since 1.31 Use addModules() instead. * @param string|array $modules Module name (string) or array of module names */ public function addModuleScripts( $modules ) { @@ -581,7 +586,7 @@ class OutputPage extends ContextSource { } /** - * Get the list of module CSS to include on this page + * Get the list of style-only modules to load on this page. * * @param bool $filter * @param string|null $position Unused @@ -594,11 +599,11 @@ class OutputPage extends ContextSource { } /** - * Add only CSS of one or more modules recognized by ResourceLoader. + * Load the styles of one or more ResourceLoader modules on this page. * - * Module styles added through this function will be added using standard link CSS - * tags, rather than as a combined Javascript and CSS package. Thus, they will - * load when JavaScript is disabled (unless CSS also happens to be disabled). + * Module styles added through this function will be loaded as a stylesheet, + * using a standard `` HTML tag, rather than as a combined + * Javascript and CSS package. Thus, they will even load when JavaScript is disabled. * * @param string|array $modules Module name (string) or array of module names */ @@ -622,6 +627,39 @@ class OutputPage extends ContextSource { $this->mTarget = $target; } + /** + * Add a mapping from a LinkTarget to a Content, for things like page preview. + * @see self::addContentOverrideCallback() + * @since 1.32 + * @param LinkTarget $target + * @param Content $content + */ + public function addContentOverride( LinkTarget $target, Content $content ) { + if ( !$this->contentOverrides ) { + // Register a callback for $this->contentOverrides on the first call + $this->addContentOverrideCallback( function ( LinkTarget $target ) { + $key = $target->getNamespace() . ':' . $target->getDBkey(); + return isset( $this->contentOverrides[$key] ) + ? $this->contentOverrides[$key] + : null; + } ); + } + + $key = $target->getNamespace() . ':' . $target->getDBkey(); + $this->contentOverrides[$key] = $content; + } + + /** + * Add a callback for mapping from a Title to a Content object, for things + * like page preview. + * @see ResourceLoaderContext::getContentOverrideCallback() + * @since 1.32 + * @param callable $callback + */ + public function addContentOverrideCallback( callable $callback ) { + $this->contentOverrideCallbacks[] = $callback; + } + /** * Get an array of head items * @@ -752,8 +790,10 @@ class OutputPage extends ContextSource { 'epoch' => $config->get( 'CacheEpoch' ) ]; if ( $config->get( 'UseSquid' ) ) { - // T46570: the core page itself may not change, but resources might - $modifiedTimes['sepoch'] = wfTimestamp( TS_MW, time() - $config->get( 'SquidMaxage' ) ); + $modifiedTimes['sepoch'] = wfTimestamp( TS_MW, $this->getCdnCacheEpoch( + time(), + $config->get( 'SquidMaxage' ) + ) ); } Hooks::run( 'OutputPageCheckLastModified', [ &$modifiedTimes, $this ] ); @@ -815,6 +855,19 @@ class OutputPage extends ContextSource { return true; } + /** + * @param int $reqTime Time of request (eg. now) + * @param int $maxAge Cache TTL in seconds + * @return int Timestamp + */ + private function getCdnCacheEpoch( $reqTime, $maxAge ) { + // Ensure Last-Modified is never more than (wgSquidMaxage) in the past, + // because even if the wiki page content hasn't changed since, static + // resources may have changed (skin HTML, interface messages, urls, etc.) + // and must roll-over in a timely manner (T46570) + return $reqTime - $maxAge; + } + /** * Override the last modified timestamp * @@ -2279,6 +2332,23 @@ class OutputPage extends ContextSource { } } + /** + * Transfer styles and JavaScript modules from skin. + * + * @param Skin $sk to load modules for + */ + public function loadSkinModules( $sk ) { + foreach ( $sk->getDefaultModules() as $group => $modules ) { + if ( $group === 'styles' ) { + foreach ( $modules as $key => $moduleMembers ) { + $this->addModuleStyles( $moduleMembers ); + } + } else { + $this->addModules( $modules ); + } + } + } + /** * Finally, all the text has been munged and accumulated into * the object, let's actually output it: @@ -2372,9 +2442,7 @@ class OutputPage extends ContextSource { } $sk = $this->getSkin(); - foreach ( $sk->getDefaultModules() as $group ) { - $this->addModules( $group ); - } + $this->loadSkinModules( $sk ); MWDebug::addModules( $this ); @@ -2708,6 +2776,18 @@ class OutputPage extends ContextSource { $this->getResourceLoader(), new FauxRequest( $query ) ); + if ( $this->contentOverrideCallbacks ) { + $this->rlClientContext = new DerivativeResourceLoaderContext( $this->rlClientContext ); + $this->rlClientContext->setContentOverrideCallback( function ( Title $title ) { + foreach ( $this->contentOverrideCallbacks as $callback ) { + $content = call_user_func( $callback, $title ); + if ( $content !== null ) { + return $content; + } + } + return null; + } ); + } } return $this->rlClientContext; } @@ -2728,6 +2808,7 @@ class OutputPage extends ContextSource { $context = $this->getRlClientContext(); $rl = $this->getResourceLoader(); $this->addModules( [ + 'user', 'user.options', 'user.tokens', ] ); @@ -2756,11 +2837,6 @@ class OutputPage extends ContextSource { function ( $name ) use ( $rl, $context, &$exemptGroups, &$exemptStates ) { $module = $rl->getModule( $name ); if ( $module ) { - if ( $name === 'user.styles' && $this->isUserCssPreview() ) { - $exemptStates[$name] = 'ready'; - // Special case in buildExemptModules() - return false; - } $group = $module->getGroup(); if ( isset( $exemptGroups[$group] ) ) { $exemptStates[$name] = 'ready'; @@ -2776,18 +2852,6 @@ class OutputPage extends ContextSource { ); $this->rlExemptStyleModules = $exemptGroups; - $isUserModuleFiltered = !$this->filterModules( [ 'user' ] ); - // If this page filters out 'user', makeResourceLoaderLink will drop it. - // Avoid indefinite "loading" state or untrue "ready" state (T145368). - if ( !$isUserModuleFiltered ) { - // Manually handled by getBottomScripts() - $userModule = $rl->getModule( 'user' ); - $userState = $userModule->isKnownEmpty( $context ) && !$this->isUserJsPreview() - ? 'ready' - : 'loading'; - $this->rlUserModuleState = $exemptStates['user'] = $userState; - } - $rlClient = new ResourceLoaderClientHtml( $context, [ 'target' => $this->getTarget(), ] ); @@ -2944,20 +3008,6 @@ class OutputPage extends ContextSource { return WrappedString::join( "\n", $chunks ); } - private function isUserJsPreview() { - return $this->getConfig()->get( 'AllowUserJs' ) - && $this->getTitle() - && $this->getTitle()->isUserJsConfigPage() - && $this->userCanPreview(); - } - - protected function isUserCssPreview() { - return $this->getConfig()->get( 'AllowUserCss' ) - && $this->getTitle() - && $this->getTitle()->isUserCssConfigPage() - && $this->userCanPreview(); - } - /** * JS stuff to put at the bottom of the ``. * These are legacy scripts ($this->mScripts), and user JS. @@ -2971,40 +3021,6 @@ class OutputPage extends ContextSource { // Legacy non-ResourceLoader scripts $chunks[] = $this->mScripts; - // Exempt 'user' module - // - May need excludepages for live preview. (T28283) - // - Must use TYPE_COMBINED so its response is handled by mw.loader.implement() which - // ensures execution is scheduled after the "site" module. - // - Don't load if module state is already resolved as "ready". - if ( $this->rlUserModuleState === 'loading' ) { - if ( $this->isUserJsPreview() ) { - $chunks[] = $this->makeResourceLoaderLink( 'user', ResourceLoaderModule::TYPE_COMBINED, - [ 'excludepage' => $this->getTitle()->getPrefixedDBkey() ] - ); - $chunks[] = ResourceLoader::makeInlineScript( - Xml::encodeJsCall( 'mw.loader.using', [ - [ 'user', 'site' ], - new XmlJsCode( - 'function () {' - . Xml::encodeJsCall( '$.globalEval', [ - $this->getRequest()->getText( 'wpTextbox1' ) - ] ) - . '}' - ) - ] ) - ); - // FIXME: If the user is previewing, say, ./vector.js, his ./common.js will be loaded - // asynchronously and may arrive *after* the inline script here. So the previewed code - // may execute before ./common.js runs. Normally, ./common.js runs before ./vector.js. - // Similarly, when previewing ./common.js and the user module does arrive first, - // it will arrive without common.js and the inline script runs after. - // Thus running common after the excluded subpage. - } else { - // Load normally - $chunks[] = $this->makeResourceLoaderLink( 'user', ResourceLoaderModule::TYPE_COMBINED ); - } - } - if ( $this->limitReportJSData ) { $chunks[] = ResourceLoader::makeInlineScript( ResourceLoader::makeConfigSetScript( @@ -3178,7 +3194,7 @@ class OutputPage extends ContextSource { /** * To make it harder for someone to slip a user a fake - * user-JavaScript or user-CSS preview, a random token + * JavaScript or CSS preview, a random token * is associated with the login session. If it's not * passed back with the preview request, we won't render * the code. @@ -3189,7 +3205,6 @@ class OutputPage extends ContextSource { $request = $this->getRequest(); if ( $request->getVal( 'action' ) !== 'submit' || - !$request->getCheck( 'wpPreview' ) || !$request->wasPosted() ) { return false; @@ -3206,17 +3221,6 @@ class OutputPage extends ContextSource { } $title = $this->getTitle(); - if ( - !$title->isUserJsConfigPage() - && !$title->isUserCssConfigPage() - ) { - return false; - } - if ( !$title->isSubpageOf( $user->getUserPage() ) ) { - // Don't execute another user's CSS or JS on preview (T85855) - return false; - } - $errors = $title->getUserPermissionsErrors( 'edit', $user ); if ( count( $errors ) !== 0 ) { return false; @@ -3337,24 +3341,22 @@ class OutputPage extends ContextSource { 'title' => $this->msg( 'opensearch-desc' )->inContentLanguage()->text(), ] ); - if ( $config->get( 'EnableAPI' ) ) { - # Real Simple Discovery link, provides auto-discovery information - # for the MediaWiki API (and potentially additional custom API - # support such as WordPress or Twitter-compatible APIs for a - # blogging extension, etc) - $tags['rsd'] = Html::element( 'link', [ - 'rel' => 'EditURI', - 'type' => 'application/rsd+xml', - // Output a protocol-relative URL here if $wgServer is protocol-relative. - // Whether RSD accepts relative or protocol-relative URLs is completely - // undocumented, though. - 'href' => wfExpandUrl( wfAppendQuery( - wfScript( 'api' ), - [ 'action' => 'rsd' ] ), - PROTO_RELATIVE - ), - ] ); - } + # Real Simple Discovery link, provides auto-discovery information + # for the MediaWiki API (and potentially additional custom API + # support such as WordPress or Twitter-compatible APIs for a + # blogging extension, etc) + $tags['rsd'] = Html::element( 'link', [ + 'rel' => 'EditURI', + 'type' => 'application/rsd+xml', + // Output a protocol-relative URL here if $wgServer is protocol-relative. + // Whether RSD accepts relative or protocol-relative URLs is completely + // undocumented, though. + 'href' => wfExpandUrl( wfAppendQuery( + wfScript( 'api' ), + [ 'action' => 'rsd' ] ), + PROTO_RELATIVE + ), + ] ); # Language variants if ( !$config->get( 'DisableLangConversion' ) ) { @@ -3557,29 +3559,10 @@ class OutputPage extends ContextSource { * @return string|WrappedStringList HTML */ protected function buildExemptModules() { - global $wgContLang; - $chunks = []; // Things that go after the ResourceLoaderDynamicStyles marker $append = []; - // Exempt 'user' styles module (may need 'excludepages' for live preview) - if ( $this->isUserCssPreview() ) { - $append[] = $this->makeResourceLoaderLink( - 'user.styles', - ResourceLoaderModule::TYPE_STYLES, - [ 'excludepage' => $this->getTitle()->getPrefixedDBkey() ] - ); - - // Load the previewed CSS. Janus it if needed. - // User-supplied CSS is assumed to in the wiki's content language. - $previewedCSS = $this->getRequest()->getText( 'wpTextbox1' ); - if ( $this->getLanguage()->getDir() !== $wgContLang->getDir() ) { - $previewedCSS = CSSJanus::transform( $previewedCSS, true, false ); - } - $append[] = Html::inlineStyle( $previewedCSS ); - } - // We want site, private and user styles to override dynamically added styles from // general modules, but we want dynamically added styles to override statically added // style modules. So the order has to be: diff --git a/includes/PHPVersionCheck.php b/includes/PHPVersionCheck.php index a48d46016c..cfe889f781 100644 --- a/includes/PHPVersionCheck.php +++ b/includes/PHPVersionCheck.php @@ -1,5 +1,4 @@ 'mbstring', - 'utf8_encode' => 'xml', + 'xml_parser_create' => 'xml', 'ctype_digit' => 'ctype', 'json_decode' => 'json', 'iconv' => 'iconv', @@ -54,7 +54,6 @@ class PHPVersionCheck { * - api.php * - mw-config/index.php * - cli - * @return $this */ function setEntryPoint( $entryPoint ) { $this->entryPoint = $entryPoint; @@ -102,8 +101,6 @@ class PHPVersionCheck { /** * Displays an error, if the installed php version does not meet the minimum requirement. - * - * @return $this */ function checkRequiredPHPVersion() { $phpInfo = $this->getPHPInfo(); @@ -118,13 +115,14 @@ class PHPVersionCheck { . "{$otherInfo['minSupported']}, you are using {$phpInfo['implementation']} " . "{$phpInfo['version']}."; - $longText = "Error: You might be using an older {$phpInfo['implementation']} version. \n" + $longText = "Error: You might be using an older {$phpInfo['implementation']} version " + . "({$phpInfo['implementation']} {$phpInfo['version']}). \n" . "MediaWiki $this->mwVersion needs {$phpInfo['implementation']}" . " $minimumVersion or higher or {$otherInfo['implementation']} version " . "{$otherInfo['minSupported']}.\n\nCheck if you have a" . " newer php executable with a different name, such as php5.\n\n"; - // phpcs:ignore Generic.Files.LineLength + // phpcs:disable Generic.Files.LineLength $longHtml = <<upgrading your copy of {$phpInfo['implementation']}. @@ -139,7 +137,7 @@ class PHPVersionCheck { See ourcompatibility page for details of which versions are compatible with prior versions of {$phpInfo['implementation']}. HTML; - // phpcs:enable + // phpcs:enable Generic.Files.LineLength $this->triggerError( "Supported {$phpInfo['implementation']} versions", $shortText, @@ -151,8 +149,6 @@ HTML; /** * Displays an error, if the vendor/autoload.php file could not be found. - * - * @return $this */ function checkVendorExistence() { if ( !file_exists( dirname( __FILE__ ) . '/../vendor/autoload.php' ) ) { @@ -164,14 +160,14 @@ HTML; . "https://www.mediawiki.org/wiki/Download_from_Git#Fetch_external_libraries\n" . "for help on installing the required components."; - // phpcs:ignore Generic.Files.LineLength + // phpcs:disable Generic.Files.LineLength $longHtml = <<mediawiki.org for help on installing the required components. HTML; - // phpcs:enable + // phpcs:enable Generic.Files.LineLength $this->triggerError( 'External dependencies', $shortText, $longText, $longHtml ); } @@ -179,8 +175,6 @@ HTML; /** * Displays an error, if a PHP extension does not exist. - * - * @return $this */ function checkExtensionExistence() { $missingExtensions = array(); diff --git a/includes/Revision.php b/includes/Revision.php index 22eb1150e3..548ef8d720 100644 --- a/includes/Revision.php +++ b/includes/Revision.php @@ -413,7 +413,6 @@ class Revision implements IDBAccessObject { 'ar_id', 'ar_page_id', 'ar_rev_id', - 'ar_text', 'ar_text_id', 'ar_timestamp', 'ar_user_text', @@ -787,17 +786,6 @@ class Revision implements IDBAccessObject { return $user ? $user->getId() : 0; } - /** - * Fetch revision's user id without regard for the current user's permissions - * - * @return int - * @deprecated since 1.25, use getUser( Revision::RAW ) - */ - public function getRawUser() { - wfDeprecated( __METHOD__, '1.25' ); - return $this->getUser( self::RAW ); - } - /** * Fetch revision's username if it's available to the specified audience. * If the specified audience does not have access to the username, an @@ -821,18 +809,6 @@ class Revision implements IDBAccessObject { $user = $this->mRecord->getUser( $audience, $user ); return $user ? $user->getName() : ''; } - - /** - * Fetch revision's username without regard for view restrictions - * - * @return string - * @deprecated since 1.25, use getUserText( Revision::RAW ) - */ - public function getRawUserText() { - wfDeprecated( __METHOD__, '1.25' ); - return $this->getUserText( self::RAW ); - } - /** * Fetch revision comment if it's available to the specified audience. * If the specified audience does not have access to the comment, an @@ -857,17 +833,6 @@ class Revision implements IDBAccessObject { return $comment === null ? null : $comment->text; } - /** - * Fetch revision comment without regard for the current user's permissions - * - * @return string - * @deprecated since 1.25, use getComment( Revision::RAW ) - */ - public function getRawComment() { - wfDeprecated( __METHOD__, '1.25' ); - return $this->getComment( self::RAW ); - } - /** * @return bool */ @@ -1069,7 +1034,9 @@ class Revision implements IDBAccessObject { return false; } - $cacheKey = isset( $row->old_id ) ? ( 'tt:' . $row->old_id ) : null; + $cacheKey = isset( $row->old_id ) + ? SqlBlobStore::makeAddressFromTextId( $row->old_id ) + : null; return self::getBlobStore( $wiki )->expandBlob( $text, $flags, $cacheKey ); } diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php index dd837a848c..ee92cbfecf 100644 --- a/includes/ServiceWiring.php +++ b/includes/ServiceWiring.php @@ -62,7 +62,7 @@ return [ $class = MWLBFactory::getLBFactoryClass( $lbConf ); $instance = new $class( $lbConf ); - MWLBFactory::setSchemaAliases( $instance ); + MWLBFactory::setSchemaAliases( $instance, $mainConfig ); return $instance; }, @@ -404,24 +404,8 @@ return [ }, 'LocalServerObjectCache' => function ( MediaWikiServices $services ) { - $mainConfig = $services->getMainConfig(); - - if ( function_exists( 'apc_fetch' ) ) { - $id = 'apc'; - } elseif ( function_exists( 'apcu_fetch' ) ) { - $id = 'apcu'; - } elseif ( function_exists( 'wincache_ucache_get' ) ) { - $id = 'wincache'; - } else { - $id = CACHE_NONE; - } - - if ( !isset( $mainConfig->get( 'ObjectCaches' )[$id] ) ) { - throw new UnexpectedValueException( - "Cache type \"$id\" is not present in \$wgObjectCaches." ); - } - - return \ObjectCache::newFromParams( $mainConfig->get( 'ObjectCaches' )[$id] ); + $cacheId = \ObjectCache::detectLocalServerCache(); + return \ObjectCache::newFromId( $cacheId ); }, 'VirtualRESTServiceClient' => function ( MediaWikiServices $services ) { @@ -583,7 +567,10 @@ return [ $authManager = AuthManager::singleton(); $linkRenderer = $services->getLinkRendererFactory()->create(); $config = $services->getMainConfig(); - return new DefaultPreferencesFactory( $config, $wgContLang, $authManager, $linkRenderer ); + $factory = new DefaultPreferencesFactory( $config, $wgContLang, $authManager, $linkRenderer ); + $factory->setLogger( LoggerFactory::getInstance( 'preferences' ) ); + + return $factory; }, 'HttpRequestFactory' => function ( MediaWikiServices $services ) { diff --git a/includes/Setup.php b/includes/Setup.php index cc6915a74b..5cc9a96aa3 100644 --- a/includes/Setup.php +++ b/includes/Setup.php @@ -37,10 +37,9 @@ if ( !defined( 'MEDIAWIKI' ) ) { * Pre-config setup: Before loading LocalSettings.php */ -// Get profiler configuraton -$wgProfiler = []; -if ( file_exists( "$IP/StartProfiler.php" ) ) { - require "$IP/StartProfiler.php"; +// Sanity check (T5782, T122807) +if ( ini_get( 'mbstring.func_overload' ) ) { + die( 'MediaWiki does not support installations where mbstring.func_overload is non-zero.' ); } // Start the autoloader, so that extensions can derive classes from core files @@ -85,6 +84,11 @@ MediaWiki\HeaderCallback::register(); * Load LocalSettings.php */ +if ( is_readable( "$IP/StartProfiler.php" ) ) { + // @deprecated since 1.32: Use LocalSettings.php instead. + require "$IP/StartProfiler.php"; +} + if ( defined( 'MW_CONFIG_CALLBACK' ) ) { call_user_func( MW_CONFIG_CALLBACK ); } else { @@ -280,7 +284,6 @@ if ( !$wgLocalFileRepo ) { 'name' => 'local', 'directory' => $wgUploadDirectory, 'scriptDirUrl' => $wgScriptPath, - 'scriptExtension' => '.php', 'url' => $wgUploadBaseUrl ? $wgUploadBaseUrl . $wgUploadPath : $wgUploadPath, 'hashLevels' => $wgHashedUploadDirectory ? 2 : 0, 'thumbScriptUrl' => $wgThumbnailScriptPath, @@ -358,12 +361,6 @@ foreach ( $wgForeignFileRepos as &$repo ) { } unset( $repo ); // no global pollution; destroy reference -// Convert this deprecated setting to modern system -if ( $wgExperimentalHtmlIds ) { - wfDeprecated( '$wgExperimentalHtmlIds', '1.30' ); - $wgFragmentMode = [ 'html5-legacy', 'html5' ]; -} - $rcMaxAgeDays = $wgRCMaxAge / ( 3600 * 24 ); if ( $wgRCFilterByAge ) { // Trim down $wgRCLinkDays so that it only lists links which are valid diff --git a/includes/SiteConfiguration.php b/includes/SiteConfiguration.php index 2d1d961dec..6bd179a9da 100644 --- a/includes/SiteConfiguration.php +++ b/includes/SiteConfiguration.php @@ -20,6 +20,8 @@ * @file */ +use MediaWiki\Shell\Shell; + /** * This is a class for holding configuration settings, particularly for * multi-wiki sites. @@ -546,19 +548,21 @@ class SiteConfiguration { } else { $this->cfgCache[$wiki] = []; } - $retVal = 1; - $cmd = wfShellWikiCmd( + $result = Shell::makeScriptCommand( "$IP/maintenance/getConfiguration.php", [ '--wiki', $wiki, '--settings', implode( ' ', $settings ), - '--format', 'PHP' + '--format', 'PHP', ] - ); - // ulimit5.sh breaks this call - $data = trim( wfShellExec( $cmd, $retVal, [], [ 'memory' => 0, 'filesize' => 0 ] ) ); - if ( $retVal != 0 || !strlen( $data ) ) { - throw new MWException( "Failed to run getConfiguration.php." ); + ) + // limit.sh breaks this call + ->limits( [ 'memory' => 0, 'filesize' => 0 ] ) + ->execute(); + + $data = trim( $result->getStdout() ); + if ( $result->getExitCode() != 0 || !strlen( $data ) ) { + throw new MWException( "Failed to run getConfiguration.php: {$result->getStdout()}" ); } $res = unserialize( $data ); if ( !is_array( $res ) ) { diff --git a/includes/Storage/MutableRevisionSlots.php b/includes/Storage/MutableRevisionSlots.php index 2e675c8937..4cc3730d92 100644 --- a/includes/Storage/MutableRevisionSlots.php +++ b/includes/Storage/MutableRevisionSlots.php @@ -102,36 +102,4 @@ class MutableRevisionSlots extends RevisionSlots { unset( $this->slots[$role] ); } - /** - * Return all slots that are not inherited. - * - * @note This may cause the slot meta-data for the revision to be lazy-loaded. - * - * @return SlotRecord[] - */ - public function getTouchedSlots() { - return array_filter( - $this->getSlots(), - function ( SlotRecord $slot ) { - return !$slot->isInherited(); - } - ); - } - - /** - * Return all slots that are inherited. - * - * @note This may cause the slot meta-data for the revision to be lazy-loaded. - * - * @return SlotRecord[] - */ - public function getInheritedSlots() { - return array_filter( - $this->getSlots(), - function ( SlotRecord $slot ) { - return $slot->isInherited(); - } - ); - } - } diff --git a/includes/Storage/NameTableStore.php b/includes/Storage/NameTableStore.php index a1eba74851..ebce3da965 100644 --- a/includes/Storage/NameTableStore.php +++ b/includes/Storage/NameTableStore.php @@ -138,7 +138,7 @@ class NameTableStore { // RACE: $name was already in the db, probably just inserted, so load from master // Use DBO_TRX to avoid missing inserts due to other threads or REPEATABLE-READs $table = $this->loadTable( - $this->getDBConnection( DB_MASTER, LoadBalancer::CONN_TRX_AUTO ) + $this->getDBConnection( DB_MASTER, LoadBalancer::CONN_TRX_AUTOCOMMIT ) ); $searchResult = array_search( $name, $table, true ); if ( $searchResult === false ) { @@ -321,7 +321,8 @@ class NameTableStore { 'name' => $this->nameField ], [], - __METHOD__ + __METHOD__, + [ 'ORDER BY' => 'id' ] ); $assocArray = []; diff --git a/includes/Storage/RevisionArchiveRecord.php b/includes/Storage/RevisionArchiveRecord.php index 9999179074..213ee3cd1b 100644 --- a/includes/Storage/RevisionArchiveRecord.php +++ b/includes/Storage/RevisionArchiveRecord.php @@ -67,6 +67,9 @@ class RevisionArchiveRecord extends RevisionRecord { parent::__construct( $title, $slots, $wikiId ); Assert::parameterType( 'object', $row, '$row' ); + $timestamp = wfTimestamp( TS_MW, $row->ar_timestamp ); + Assert::parameter( is_string( $timestamp ), '$row->rev_timestamp', 'must be a valid timestamp' ); + $this->mArchiveId = intval( $row->ar_id ); // NOTE: ar_page_id may be different from $this->mTitle->getArticleID() in some cases, @@ -81,11 +84,11 @@ class RevisionArchiveRecord extends RevisionRecord { $this->mId = isset( $row->ar_rev_id ) ? intval( $row->ar_rev_id ) : null; $this->mComment = $comment; $this->mUser = $user; - $this->mTimestamp = wfTimestamp( TS_MW, $row->ar_timestamp ); + $this->mTimestamp = $timestamp; $this->mMinorEdit = boolval( $row->ar_minor_edit ); $this->mDeleted = intval( $row->ar_deleted ); - $this->mSize = intval( $row->ar_len ); - $this->mSha1 = isset( $row->ar_sha1 ) ? $row->ar_sha1 : null; + $this->mSize = isset( $row->ar_len ) ? intval( $row->ar_len ) : null; + $this->mSha1 = !empty( $row->ar_sha1 ) ? $row->ar_sha1 : null; } /** @@ -94,7 +97,7 @@ class RevisionArchiveRecord extends RevisionRecord { * @return int */ public function getArchiveId() { - return $this->mId; + return $this->mArchiveId; } /** diff --git a/includes/Storage/RevisionSlots.php b/includes/Storage/RevisionSlots.php index 7fa5431d38..c7dcd136e0 100644 --- a/includes/Storage/RevisionSlots.php +++ b/includes/Storage/RevisionSlots.php @@ -54,6 +54,8 @@ class RevisionSlots { * @param SlotRecord[] $slots */ private function setSlotsInternal( array $slots ) { + Assert::parameterElementType( SlotRecord::class, $slots, '$slots' ); + $this->slots = []; // re-key the slot array @@ -199,4 +201,71 @@ class RevisionSlots { }, null ); } + /** + * Return all slots that are not inherited. + * + * @note This may cause the slot meta-data for the revision to be lazy-loaded. + * + * @return SlotRecord[] + */ + public function getTouchedSlots() { + return array_filter( + $this->getSlots(), + function ( SlotRecord $slot ) { + return !$slot->isInherited(); + } + ); + } + + /** + * Return all slots that are inherited. + * + * @note This may cause the slot meta-data for the revision to be lazy-loaded. + * + * @return SlotRecord[] + */ + public function getInheritedSlots() { + return array_filter( + $this->getSlots(), + function ( SlotRecord $slot ) { + return $slot->isInherited(); + } + ); + } + + /** + * Checks whether the other RevisionSlots instance has the same content + * as this instance. Note that this does not mean that the slots have to be the same: + * they could for instance belong to different revisions. + * + * @param RevisionSlots $other + * + * @return bool + */ + public function hasSameContent( RevisionSlots $other ) { + if ( $other === $this ) { + return true; + } + + $aSlots = $this->getSlots(); + $bSlots = $other->getSlots(); + + ksort( $aSlots ); + ksort( $bSlots ); + + if ( array_keys( $aSlots ) !== array_keys( $bSlots ) ) { + return false; + } + + foreach ( $aSlots as $role => $s ) { + $t = $bSlots[$role]; + + if ( !$s->hasSameContent( $t ) ) { + return false; + } + } + + return true; + } + } diff --git a/includes/Storage/RevisionSlotsUpdate.php b/includes/Storage/RevisionSlotsUpdate.php new file mode 100644 index 0000000000..0eef90f2bc --- /dev/null +++ b/includes/Storage/RevisionSlotsUpdate.php @@ -0,0 +1,242 @@ +getSlots(); + $removed = []; + + if ( $parentSlots ) { + foreach ( $parentSlots->getSlots() as $role => $slot ) { + if ( !isset( $modified[$role] ) ) { + $removed[] = $role; + } elseif ( $slot->hasSameContent( $modified[$role] ) ) { + // Unset slots that had the same content in the parent revision from $modified. + unset( $modified[$role] ); + } + } + } + + return new RevisionSlotsUpdate( $modified, $removed ); + } + + /** + * @param SlotRecord[] $modifiedSlots + * @param string[] $removedRoles + */ + public function __construct( array $modifiedSlots = [], array $removedRoles = [] ) { + foreach ( $modifiedSlots as $slot ) { + $this->modifySlot( $slot ); + } + + foreach ( $removedRoles as $role ) { + $this->removeSlot( $role ); + } + } + + /** + * Returns a list of modified slot roles, that is, roles modified by calling modifySlot(), + * and not later removed by calling removeSlot(). + * + * @return string[] + */ + public function getModifiedRoles() { + return array_keys( $this->modifiedSlots ); + } + + /** + * Returns a list of removed slot roles, that is, roles removed by calling removeSlot(), + * and not later re-introduced by calling modifySlot(). + * + * @return string[] + */ + public function getRemovedRoles() { + return array_keys( $this->removedRoles ); + } + + /** + * Returns a list of all slot roles that modified or removed. + * + * @return string[] + */ + public function getTouchedRoles() { + return array_merge( $this->getModifiedRoles(), $this->getRemovedRoles() ); + } + + /** + * Sets the given slot to be modified. + * If a slot with the same role is already present, it is replaced. + * + * The roles used with modifySlot() will be returned from getModifiedRoles(), + * unless overwritten with removeSlot(). + * + * @param SlotRecord $slot + */ + public function modifySlot( SlotRecord $slot ) { + $role = $slot->getRole(); + + // XXX: We should perhaps require this to be an unsaved slot! + unset( $this->removedRoles[$role] ); + $this->modifiedSlots[$role] = $slot; + } + + /** + * Sets the content for the slot with the given role to be modified. + * If a slot with the same role is already present, it is replaced. + * + * @param string $role + * @param Content $content + */ + public function modifyContent( $role, Content $content ) { + $slot = SlotRecord::newUnsaved( $role, $content ); + $this->modifySlot( $slot ); + } + + /** + * Remove the slot for the given role, discontinue the corresponding stream. + * + * The roles used with removeSlot() will be returned from getRemovedSlots(), + * unless overwritten with modifySlot(). + * + * @param string $role + */ + public function removeSlot( $role ) { + unset( $this->modifiedSlots[$role] ); + $this->removedRoles[$role] = true; + } + + /** + * Returns the SlotRecord associated with the given role, if the slot with that role + * was modified (and not again removed). + * + * @note If the SlotRecord returned by this method returns a non-inherited slot, + * the content of that slot may or may not already have PST applied. Methods + * that take a RevisionSlotsUpdate as a parameter should specify whether they + * expect PST to already have been applied to all slots. Inherited slots + * should never have PST applied again. + * + * @param string $role The role name of the desired slot + * + * @throws RevisionAccessException if the slot does not exist or was removed. + * @return SlotRecord + */ + public function getModifiedSlot( $role ) { + if ( isset( $this->modifiedSlots[$role] ) ) { + return $this->modifiedSlots[$role]; + } else { + throw new RevisionAccessException( 'No such slot: ' . $role ); + } + } + + /** + * Returns whether getModifiedSlot() will return a SlotRecord for the given role. + * + * Will return true for the role names returned by getModifiedRoles(), false otherwise. + * + * @param string $role The role name of the desired slot + * + * @return bool + */ + public function isModifiedSlot( $role ) { + return isset( $this->modifiedSlots[$role] ); + } + + /** + * Returns whether the given role is to be removed from the page. + * + * Will return true for the role names returned by getRemovedRoles(), false otherwise. + * + * @param string $role The role name of the desired slot + * + * @return bool + */ + public function isRemovedSlot( $role ) { + return isset( $this->removedRoles[$role] ); + } + + /** + * Returns true if $other represents the same update - that is, + * if all methods defined by RevisionSlotsUpdate when called on $this or $other + * will yield the same result when called with the same parameters. + * + * SlotRecords for the same role are compared based on their model and content. + * + * @param RevisionSlotsUpdate $other + * @return bool + */ + public function hasSameUpdates( RevisionSlotsUpdate $other ) { + // NOTE: use != not !==, since the order of entries is not significant! + + if ( $this->getModifiedRoles() != $other->getModifiedRoles() ) { + return false; + } + + if ( $this->getRemovedRoles() != $other->getRemovedRoles() ) { + return false; + } + + foreach ( $this->getModifiedRoles() as $role ) { + $s = $this->getModifiedSlot( $role ); + $t = $other->getModifiedSlot( $role ); + + if ( !$s->hasSameContent( $t ) ) { + return false; + } + } + + return true; + } + +} diff --git a/includes/Storage/RevisionStore.php b/includes/Storage/RevisionStore.php index 584142bfc1..5b3daf45a4 100644 --- a/includes/Storage/RevisionStore.php +++ b/includes/Storage/RevisionStore.php @@ -283,7 +283,7 @@ class RevisionStore * @param mixed $value * @param string $name * - * @throw IncompleteRevisionException if $value is null + * @throws IncompleteRevisionException if $value is null * @return mixed $value, if $value is not null */ private function failOnNull( $value, $name ) { @@ -300,7 +300,7 @@ class RevisionStore * @param mixed $value * @param string $name * - * @throw IncompleteRevisionException if $value is empty + * @throws IncompleteRevisionException if $value is empty * @return mixed $value, if $value is not null */ private function failOnEmpty( $value, $name ) { @@ -391,7 +391,7 @@ class RevisionStore // getTextIdFromAddress() is free to insert something into the text table, so $textId // may be a new value, not anything already contained in $blobAddress. - $blobAddress = 'tt:' . $textId; + $blobAddress = SqlBlobStore::makeAddressFromTextId( $textId ); $comment = $this->failOnNull( $rev->getComment( RevisionRecord::RAW ), 'comment' ); $user = $this->failOnNull( $rev->getUser( RevisionRecord::RAW ), 'user' ); @@ -636,7 +636,7 @@ class RevisionStore */ public function getRcIdIfUnpatrolled( RevisionRecord $rev ) { $rc = $this->getRecentChange( $rev ); - if ( $rc && $rc->getAttribute( 'rc_patrolled' ) == 0 ) { + if ( $rc && $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED ) { return $rc->getAttribute( 'rc_id' ); } else { return 0; @@ -724,11 +724,6 @@ class RevisionStore 'ar_content_model' => 'rev_content_model', ]; - if ( empty( $archiveRow->ar_text_id ) ) { - $fieldMap['ar_text'] = 'old_text'; - $fieldMap['ar_flags'] = 'old_flags'; - } - $revRow = new stdClass(); foreach ( $fieldMap as $arKey => $revKey ) { if ( property_exists( $archiveRow, $arKey ) ) { @@ -769,7 +764,9 @@ class RevisionStore if ( isset( $row->rev_text_id ) && $row->rev_text_id > 0 ) { $mainSlotRow->slot_content_id = $row->rev_text_id; - $mainSlotRow->content_address = 'tt:' . $row->rev_text_id; + $mainSlotRow->content_address = SqlBlobStore::makeAddressFromTextId( + $row->rev_text_id + ); } // This is used by null-revisions @@ -808,7 +805,7 @@ class RevisionStore ? intval( $row['slot_origin'] ) : null; $mainSlotRow->content_address = isset( $row['text_id'] ) - ? 'tt:' . intval( $row['text_id'] ) + ? SqlBlobStore::makeAddressFromTextId( intval( $row['text_id'] ) ) : null; $mainSlotRow->content_size = isset( $row['len'] ) ? intval( $row['len'] ) : null; $mainSlotRow->content_sha1 = isset( $row['sha1'] ) ? strval( $row['sha1'] ) : null; @@ -892,7 +889,7 @@ class RevisionStore * @param string|null $blobFormat MIME type indicating how $dataBlob is encoded * @param int $queryFlags * - * @throw RevisionAccessException + * @throws RevisionAccessException * @return Content */ private function loadSlotContent( @@ -1704,7 +1701,6 @@ class RevisionStore 'ar_namespace', 'ar_title', 'ar_rev_id', - 'ar_text', 'ar_text_id', 'ar_timestamp', 'ar_minor_edit', diff --git a/includes/Storage/RevisionStoreRecord.php b/includes/Storage/RevisionStoreRecord.php index e8efcfa1b0..d092f22ed9 100644 --- a/includes/Storage/RevisionStoreRecord.php +++ b/includes/Storage/RevisionStoreRecord.php @@ -81,7 +81,7 @@ class RevisionStoreRecord extends RevisionRecord { // allows rev_parent_id to be NULL. $this->mParentId = isset( $row->rev_parent_id ) ? intval( $row->rev_parent_id ) : null; $this->mSize = isset( $row->rev_len ) ? intval( $row->rev_len ) : null; - $this->mSha1 = isset( $row->rev_sha1 ) ? $row->rev_sha1 : null; + $this->mSha1 = !empty( $row->rev_sha1 ) ? $row->rev_sha1 : null; // NOTE: we must not call $this->mTitle->getLatestRevID() here, since the state of // page_latest may be in limbo during revision creation. In that case, calling diff --git a/includes/Storage/SlotRecord.php b/includes/Storage/SlotRecord.php index 50d1100547..9462518ffe 100644 --- a/includes/Storage/SlotRecord.php +++ b/includes/Storage/SlotRecord.php @@ -565,4 +565,50 @@ class SlotRecord { return \Wikimedia\base_convert( sha1( $blob ), 16, 36, 31 ); } + /** + * Returns true if $other has the same content as this slot. + * The check is performed based on the model, address size, and hash. + * Two slots can have the same content if they use different content addresses, + * but if they have the same address and the same model, they have the same content. + * Two slots can have the same content if they belong to different + * revisions or pages. + * + * Note that hasSameContent() may return false even if Content::equals returns true for + * the content of two slots. This may happen if the two slots have different serializations + * representing equivalent Content. Such false negatives are considered acceptable. Code + * that has to be absolutely sure the Content is really not the same if hasSameContent() + * returns false should call getContent() and compare the Content objects directly. + * + * @since 1.32 + * + * @param SlotRecord $other + * @return bool + */ + public function hasSameContent( SlotRecord $other ) { + if ( $other === $this ) { + return true; + } + + if ( $this->getModel() !== $other->getModel() ) { + return false; + } + + if ( $this->hasAddress() + && $other->hasAddress() + && $this->getAddress() == $other->getAddress() + ) { + return true; + } + + if ( $this->getSize() !== $other->getSize() ) { + return false; + } + + if ( $this->getSha1() !== $other->getSha1() ) { + return false; + } + + return true; + } + } diff --git a/includes/Storage/SqlBlobStore.php b/includes/Storage/SqlBlobStore.php index 0ff7c13343..72de2c961a 100644 --- a/includes/Storage/SqlBlobStore.php +++ b/includes/Storage/SqlBlobStore.php @@ -244,7 +244,7 @@ class SqlBlobStore implements IDBAccessObject, BlobStore { $textId = $dbw->insertId(); - return 'tt:' . $textId; + return self::makeAddressFromTextId( $textId ); } catch ( MWException $e ) { throw new BlobAccessException( $e->getMessage(), 0, $e ); } @@ -292,7 +292,7 @@ class SqlBlobStore implements IDBAccessObject, BlobStore { * @param string $blobAddress * @param int $queryFlags * - * @throw BlobAccessException + * @throws BlobAccessException * @return string|false */ private function fetchBlob( $blobAddress, $queryFlags ) { @@ -542,11 +542,12 @@ class SqlBlobStore implements IDBAccessObject, BlobStore { * Currently, $address must start with 'tt:' followed by a decimal integer representing * the old_id; if $address does not start with 'tt:', null is returned. However, * the implementation may change to insert rows into the text table on the fly. + * This implies that this method cannot be static. * * @note This method exists for use with the text table based storage schema. * It should not be assumed that is will function with all future kinds of content addresses. * - * @deprecated since 1.31, so not assume that all blob addresses refer to a row in the text + * @deprecated since 1.31, so don't assume that all blob addresses refer to a row in the text * table. This method should become private once the relevant refactoring in WikiPage is * complete. * @@ -570,6 +571,22 @@ class SqlBlobStore implements IDBAccessObject, BlobStore { return $textId; } + /** + * Returns an address referring to content stored in the text table row with the given ID. + * The address schema for blobs stored in the text table is "tt:" followed by an integer + * that corresponds to a value of the old_id field. + * + * @deprecated since 1.31. This method should become private once the relevant refactoring + * in WikiPage is complete. + * + * @param int $id + * + * @return string + */ + public static function makeAddressFromTextId( $id ) { + return 'tt:' . $id; + } + /** * Splits a blob address into three parts: the schema, the ID, and parameters/flags. * diff --git a/includes/Title.php b/includes/Title.php index 58e688589f..b771477fad 100644 --- a/includes/Title.php +++ b/includes/Title.php @@ -1027,12 +1027,11 @@ class Title implements LinkTarget { */ public function getNsText() { if ( $this->isExternal() ) { - // This probably shouldn't even happen, - // but for interwiki transclusion it sometimes does. - // Use the canonical namespaces if possible to try to - // resolve a foreign namespace. - if ( MWNamespace::exists( $this->mNamespace ) ) { - return MWNamespace::getCanonicalName( $this->mNamespace ); + // This probably shouldn't even happen, except for interwiki transclusion. + // If possible, use the canonical name for the foreign namespace. + $nsText = MWNamespace::getCanonicalName( $this->mNamespace ); + if ( $nsText !== false ) { + return $nsText; } } @@ -4795,14 +4794,12 @@ class Title implements LinkTarget { */ public function getNamespaceKey( $prepend = 'nstab-' ) { global $wgContLang; - // Gets the subject namespace if this title - $namespace = MWNamespace::getSubject( $this->getNamespace() ); - // Checks if canonical namespace name exists for namespace - if ( MWNamespace::exists( $this->getNamespace() ) ) { - // Uses canonical namespace name - $namespaceKey = MWNamespace::getCanonicalName( $namespace ); - } else { - // Uses text of namespace + // Gets the subject namespace of this title + $subjectNS = MWNamespace::getSubject( $this->getNamespace() ); + // Prefer canonical namespace name for HTML IDs + $namespaceKey = MWNamespace::getCanonicalName( $subjectNS ); + if ( $namespaceKey === false ) { + // Fallback to localised text $namespaceKey = $this->getSubjectNsText(); } // Makes namespace key lowercase diff --git a/includes/TitleArray.php b/includes/TitleArray.php index bf2344bbb7..a1eabe5b25 100644 --- a/includes/TitleArray.php +++ b/includes/TitleArray.php @@ -24,7 +24,7 @@ * @file */ -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; /** * The TitleArray class only exists to provide the newFromResult method at pre- @@ -32,7 +32,7 @@ use Wikimedia\Rdbms\ResultWrapper; */ abstract class TitleArray implements Iterator { /** - * @param ResultWrapper $res A SQL result including at least page_namespace and + * @param IResultWrapper $res A SQL result including at least page_namespace and * page_title -- also can have page_id, page_len, page_is_redirect, * page_latest (if those will be used). See Title::newFromRow. * @return TitleArrayFromResult @@ -49,7 +49,7 @@ abstract class TitleArray implements Iterator { } /** - * @param ResultWrapper $res + * @param IResultWrapper $res * @return TitleArrayFromResult */ protected static function newFromResult_internal( $res ) { diff --git a/includes/TitleArrayFromResult.php b/includes/TitleArrayFromResult.php index 189fb40549..ee60f7b967 100644 --- a/includes/TitleArrayFromResult.php +++ b/includes/TitleArrayFromResult.php @@ -24,10 +24,10 @@ * @file */ -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; class TitleArrayFromResult extends TitleArray implements Countable { - /** @var ResultWrapper */ + /** @var IResultWrapper */ public $res; public $key; @@ -41,7 +41,7 @@ class TitleArrayFromResult extends TitleArray implements Countable { } /** - * @param bool|ResultWrapper $row + * @param bool|IResultWrapper $row * @return void */ protected function setCurrent( $row ) { diff --git a/includes/WebRequest.php b/includes/WebRequest.php index 6f0307da8d..c6ddf81697 100644 --- a/includes/WebRequest.php +++ b/includes/WebRequest.php @@ -141,7 +141,7 @@ class WebRequest { $router->add( "$wgScript/$1" ); if ( isset( $_SERVER['SCRIPT_NAME'] ) - && preg_match( '/\.php5?/', $_SERVER['SCRIPT_NAME'] ) + && preg_match( '/\.php/', $_SERVER['SCRIPT_NAME'] ) ) { # Check for SCRIPT_NAME, we handle index.php explicitly # But we do have some other .php files such as img_auth.php @@ -432,7 +432,7 @@ class WebRequest { * selected by a drop-down menu). For freeform input, see getText(). * * @param string $name - * @param string $default Optional default (or null) + * @param string|null $default Optional default (or null) * @return string|null */ public function getVal( $name, $default = null ) { diff --git a/includes/WebStart.php b/includes/WebStart.php index c9aecce4e0..878dd3eca5 100644 --- a/includes/WebStart.php +++ b/includes/WebStart.php @@ -25,24 +25,11 @@ * @file */ -if ( ini_get( 'mbstring.func_overload' ) ) { - die( 'MediaWiki does not support installations where mbstring.func_overload is non-zero.' ); -} - # T17461: Make IE8 turn off content sniffing. Everybody else should ignore this # We're adding it here so that it's *always* set, even for alternate entry # points and when $wgOut gets disabled or overridden. header( 'X-Content-Type-Options: nosniff' ); -/** - * @var float Request start time as fractional seconds since epoch - * @deprecated since 1.25; use $_SERVER['REQUEST_TIME_FLOAT'] or - * WebRequest::getElapsedTime() instead. - */ -$wgRequestTime = $_SERVER['REQUEST_TIME_FLOAT']; - -unset( $IP ); - # Valid web server entry point, enable includes. # Please don't move this line to includes/Defines.php. This line essentially # defines a valid entry point. If you put it in includes/Defines.php, then diff --git a/includes/api/ApiBase.php b/includes/api/ApiBase.php index 22202c0c11..7fafa1f1af 100644 --- a/includes/api/ApiBase.php +++ b/includes/api/ApiBase.php @@ -692,7 +692,7 @@ abstract class ApiBase extends ContextSource { * Set the continuation manager * @param ApiContinuationManager|null $manager */ - public function setContinuationManager( $manager ) { + public function setContinuationManager( ApiContinuationManager $manager = null ) { // Main module has setContinuationManager() method overridden // Safety - avoid infinite loop: if ( $this->isMain() ) { @@ -1129,8 +1129,8 @@ abstract class ApiBase extends ContextSource { ) { $type = array_merge( $type, $paramSettings[self::PARAM_EXTRA_NAMESPACES] ); } - // By default, namespace parameters allow ALL_DEFAULT_STRING to be used to specify - // all namespaces. + // Namespace parameters allow ALL_DEFAULT_STRING to be used to + // specify all namespaces irrespective of PARAM_ALL. $allowAll = true; } if ( isset( $value ) && $type == 'submodule' ) { @@ -1436,22 +1436,15 @@ abstract class ApiBase extends ContextSource { return $value; } - if ( is_array( $allowedValues ) ) { - $values = array_map( function ( $v ) { - return '' . wfEscapeWikiText( $v ) . ''; - }, $allowedValues ); - $this->dieWithError( [ - 'apierror-multival-only-one-of', - $valueName, - Message::listParam( $values ), - count( $values ), - ], "multival_$valueName" ); - } else { - $this->dieWithError( [ - 'apierror-multival-only-one', - $valueName, - ], "multival_$valueName" ); - } + $values = array_map( function ( $v ) { + return '' . wfEscapeWikiText( $v ) . ''; + }, $allowedValues ); + $this->dieWithError( [ + 'apierror-multival-only-one-of', + $valueName, + Message::listParam( $values ), + count( $values ), + ], "multival_$valueName" ); } if ( is_array( $allowedValues ) ) { @@ -1537,7 +1530,7 @@ abstract class ApiBase extends ContextSource { } /** - * Validate and normalize of parameters of type 'timestamp' + * Validate and normalize parameters of type 'timestamp' * @param string $value Parameter value * @param string $encParamName Parameter name * @return string Validated and normalized parameter @@ -1559,15 +1552,15 @@ abstract class ApiBase extends ContextSource { return wfTimestamp( TS_MW ); } - $unixTimestamp = wfTimestamp( TS_UNIX, $value ); - if ( $unixTimestamp === false ) { + $timestamp = wfTimestamp( TS_MW, $value ); + if ( $timestamp === false ) { $this->dieWithError( [ 'apierror-badtimestamp', $encParamName, wfEscapeWikiText( $value ) ], "badtimestamp_{$encParamName}" ); } - return wfTimestamp( TS_MW, $unixTimestamp ); + return $timestamp; } /** @@ -1609,7 +1602,7 @@ abstract class ApiBase extends ContextSource { } /** - * Validate and normalize of parameters of type 'user' + * Validate and normalize parameters of type 'user' * @param string $value Parameter value * @param string $encParamName Parameter name * @return string Validated and normalized parameter @@ -1619,15 +1612,32 @@ abstract class ApiBase extends ContextSource { return $value; } - $title = Title::makeTitleSafe( NS_USER, $value ); - if ( $title === null || $title->hasFragment() ) { + $titleObj = Title::makeTitleSafe( NS_USER, $value ); + + if ( $titleObj ) { + $value = $titleObj->getText(); + } + + if ( + !User::isValidUserName( $value ) && + // We allow ranges as well, for blocks. + !IP::isIPAddress( $value ) && + // See comment for User::isIP. We don't just call that function + // here because it also returns true for things like + // 300.300.300.300 that are neither valid usernames nor valid IP + // addresses. + !preg_match( + '/^' . RE_IP_BYTE . '\.' . RE_IP_BYTE . '\.' . RE_IP_BYTE . '\.xxx$/', + $value + ) + ) { $this->dieWithError( [ 'apierror-baduser', $encParamName, wfEscapeWikiText( $value ) ], "baduser_{$encParamName}" ); } - return $title->getText(); + return $value; } /**@}*/ @@ -2589,16 +2599,6 @@ abstract class ApiBase extends ContextSource { return false; } - /** - * @deprecated since 1.25, always returns empty string - * @param IDatabase|bool $db - * @return string - */ - public function getModuleProfileName( $db = false ) { - wfDeprecated( __METHOD__, '1.25' ); - return ''; - } - /** * @deprecated since 1.25 */ @@ -2622,15 +2622,6 @@ abstract class ApiBase extends ContextSource { wfDeprecated( __METHOD__, '1.25' ); } - /** - * @deprecated since 1.25, always returns 0 - * @return float - */ - public function getProfileTime() { - wfDeprecated( __METHOD__, '1.25' ); - return 0; - } - /** * @deprecated since 1.25 */ @@ -2645,15 +2636,6 @@ abstract class ApiBase extends ContextSource { wfDeprecated( __METHOD__, '1.25' ); } - /** - * @deprecated since 1.25, always returns 0 - * @return float - */ - public function getProfileDBTime() { - wfDeprecated( __METHOD__, '1.25' ); - return 0; - } - /** * Call wfTransactionalTimeLimit() if this request was POSTed * @since 1.26 diff --git a/includes/api/ApiFormatJson.php b/includes/api/ApiFormatJson.php index 1aa9e159a3..2f63faffe8 100644 --- a/includes/api/ApiFormatJson.php +++ b/includes/api/ApiFormatJson.php @@ -124,8 +124,8 @@ class ApiFormatJson extends ApiFormatBase { ApiBase::PARAM_HELP_MSG => 'apihelp-json-param-ascii', ], 'formatversion' => [ - ApiBase::PARAM_TYPE => [ 1, 2, 'latest' ], - ApiBase::PARAM_DFLT => 1, + ApiBase::PARAM_TYPE => [ '1', '2', 'latest' ], + ApiBase::PARAM_DFLT => '1', ApiBase::PARAM_HELP_MSG => 'apihelp-json-param-formatversion', ], ]; diff --git a/includes/api/ApiFormatPhp.php b/includes/api/ApiFormatPhp.php index cc0f159eef..45bdb6d436 100644 --- a/includes/api/ApiFormatPhp.php +++ b/includes/api/ApiFormatPhp.php @@ -73,8 +73,8 @@ class ApiFormatPhp extends ApiFormatBase { public function getAllowedParams() { $ret = parent::getAllowedParams() + [ 'formatversion' => [ - ApiBase::PARAM_TYPE => [ 1, 2, 'latest' ], - ApiBase::PARAM_DFLT => 1, + ApiBase::PARAM_TYPE => [ '1', '2', 'latest' ], + ApiBase::PARAM_DFLT => '1', ApiBase::PARAM_HELP_MSG => 'apihelp-php-param-formatversion', ], ]; diff --git a/includes/api/ApiLogin.php b/includes/api/ApiLogin.php index e4c4429eb3..0248f25ef6 100644 --- a/includes/api/ApiLogin.php +++ b/includes/api/ApiLogin.php @@ -130,7 +130,10 @@ class ApiLogin extends ApiBase { $session = $status->getValue(); $authRes = 'Success'; $loginType = 'BotPassword'; - } elseif ( !$botLoginData[2] || $status->hasMessage( 'login-throttled' ) ) { + } elseif ( !$botLoginData[2] || + $status->hasMessage( 'login-throttled' ) || + $status->hasMessage( 'botpasswords-needs-reset' ) + ) { $authRes = 'Failed'; $message = $status->getMessage(); LoggerFactory::getInstance( 'authentication' )->info( diff --git a/includes/api/ApiMain.php b/includes/api/ApiMain.php index a7e3c1bd54..b7b13c5dfc 100644 --- a/includes/api/ApiMain.php +++ b/includes/api/ApiMain.php @@ -368,19 +368,12 @@ class ApiMain extends ApiBase { * Set the continuation manager * @param ApiContinuationManager|null $manager */ - public function setContinuationManager( $manager ) { - if ( $manager !== null ) { - if ( !$manager instanceof ApiContinuationManager ) { - throw new InvalidArgumentException( __METHOD__ . ': Was passed ' . - is_object( $manager ) ? get_class( $manager ) : gettype( $manager ) - ); - } - if ( $this->mContinuationManager !== null ) { - throw new UnexpectedValueException( - __METHOD__ . ': tried to set manager from ' . $manager->getSource() . - ' when a manager is already set from ' . $this->mContinuationManager->getSource() - ); - } + public function setContinuationManager( ApiContinuationManager $manager = null ) { + if ( $manager !== null && $this->mContinuationManager !== null ) { + throw new UnexpectedValueException( + __METHOD__ . ': tried to set manager from ' . $manager->getSource() . + ' when a manager is already set from ' . $this->mContinuationManager->getSource() + ); } $this->mContinuationManager = $manager; } @@ -1199,9 +1192,12 @@ class ApiMain extends ApiBase { // Instantiate the module requested by the user $module = $this->mModuleMgr->getModule( $this->mAction, 'action' ); if ( $module === null ) { + // Probably can't happen + // @codeCoverageIgnoreStart $this->dieWithError( [ 'apierror-unknownaction', wfEscapeWikiText( $this->mAction ) ], 'unknown_action' ); + // @codeCoverageIgnoreEnd } $moduleParams = $module->extractRequestParams(); @@ -1220,7 +1216,10 @@ class ApiMain extends ApiBase { } if ( !isset( $moduleParams['token'] ) ) { + // Probably can't happen + // @codeCoverageIgnoreStart $module->dieWithError( [ 'apierror-missingparam', 'token' ] ); + // @codeCoverageIgnoreEnd } $module->requirePostedParameters( [ 'token' ] ); @@ -1433,7 +1432,7 @@ class ApiMain extends ApiBase { } // Allow extensions to stop execution for arbitrary reasons. - $message = false; + $message = 'hookaborted'; if ( !Hooks::run( 'ApiCheckCanExecute', [ $module, $user, &$message ] ) ) { $this->dieWithError( $message ); } @@ -1720,8 +1719,8 @@ class ApiMain extends ApiBase { /** * Get a request value, and register the fact that it was used, for logging. * @param string $name - * @param mixed $default - * @return mixed + * @param string|null $default + * @return string|null */ public function getVal( $name, $default = null ) { $this->mParamsUsed[$name] = true; diff --git a/includes/api/ApiMove.php b/includes/api/ApiMove.php index 281456434a..f6b6b35df2 100644 --- a/includes/api/ApiMove.php +++ b/includes/api/ApiMove.php @@ -99,7 +99,6 @@ class ApiMove extends ApiBase { // a redirect to the new title. This is not safe, but what we did before was // even worse: we just determined whether a redirect should have been created, // and reported that it was created if it should have, without any checks. - // Also note that isRedirect() is unreliable because of T39209. $r['redirectcreated'] = $fromTitle->exists(); $r['moveoverredirect'] = $toTitleExists; @@ -152,10 +151,6 @@ class ApiMove extends ApiBase { $watch = 'preferences'; if ( isset( $params['watchlist'] ) ) { $watch = $params['watchlist']; - } elseif ( $params['watch'] ) { - $watch = 'watch'; - } elseif ( $params['unwatch'] ) { - $watch = 'unwatch'; } // Watch pages @@ -250,14 +245,6 @@ class ApiMove extends ApiBase { 'movetalk' => false, 'movesubpages' => false, 'noredirect' => false, - 'watch' => [ - ApiBase::PARAM_DFLT => false, - ApiBase::PARAM_DEPRECATED => true, - ], - 'unwatch' => [ - ApiBase::PARAM_DFLT => false, - ApiBase::PARAM_DEPRECATED => true, - ], 'watchlist' => [ ApiBase::PARAM_DFLT => 'preferences', ApiBase::PARAM_TYPE => [ diff --git a/includes/api/ApiParse.php b/includes/api/ApiParse.php index cbd62a97df..096122d186 100644 --- a/includes/api/ApiParse.php +++ b/includes/api/ApiParse.php @@ -243,12 +243,6 @@ class ApiParse extends ApiBase { if ( $params['onlypst'] ) { // Build a result and bail out $result_array = []; - if ( $this->contentIsDeleted ) { - $result_array['textdeleted'] = true; - } - if ( $this->contentIsSuppressed ) { - $result_array['textsuppressed'] = true; - } $result_array['text'] = $this->pstContent->serialize( $format ); $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'text'; if ( isset( $prop['wikitext'] ) ) { @@ -320,16 +314,19 @@ class ApiParse extends ApiBase { $outputPage = new OutputPage( $context ); $outputPage->addParserOutputMetadata( $p_result ); + if ( $this->content ) { + $outputPage->addContentOverride( $titleObj, $this->content ); + } $context->setOutput( $outputPage ); if ( $skin ) { // Based on OutputPage::headElement() $skin->setupSkinUserCss( $outputPage ); // Based on OutputPage::output() - foreach ( $skin->getDefaultModules() as $group ) { - $outputPage->addModules( $group ); - } + $outputPage->loadSkinModules( $skin ); } + + Hooks::run( 'ApiParseMakeOutputPage', [ $this, $outputPage ] ); } if ( !is_null( $oldid ) ) { @@ -400,8 +397,8 @@ class ApiParse extends ApiBase { } if ( isset( $prop['displaytitle'] ) ) { - $result_array['displaytitle'] = $p_result->getDisplayTitle() ?: - $titleObj->getPrefixedText(); + $result_array['displaytitle'] = $p_result->getDisplayTitle() !== false + ? $p_result->getDisplayTitle() : $titleObj->getPrefixedText(); } if ( isset( $prop['headitems'] ) ) { @@ -490,12 +487,7 @@ class ApiParse extends ApiBase { } $wgParser->startExternalParse( $titleObj, $popts, Parser::OT_PREPROCESS ); - $dom = $wgParser->preprocessToDom( $this->content->getNativeData() ); - if ( is_callable( [ $dom, 'saveXML' ] ) ) { - $xml = $dom->saveXML(); - } else { - $xml = $dom->__toString(); - } + $xml = $wgParser->preprocessToDom( $this->content->getNativeData() )->__toString(); $result_array['parsetree'] = $xml; $result_array[ApiResult::META_BC_SUBELEMENTS][] = 'parsetree'; } @@ -578,7 +570,7 @@ class ApiParse extends ApiBase { } else { $this->content = $page->getContent( Revision::FOR_THIS_USER, $this->getUser() ); if ( !$this->content ) { - $this->dieWithError( [ 'apierror-missingcontent-pageid', $pageId ] ); + $this->dieWithError( [ 'apierror-missingcontent-pageid', $page->getId() ] ); } } $this->contentIsDeleted = $isDeleted; @@ -602,7 +594,7 @@ class ApiParse extends ApiBase { $pout = $page->getParserOutput( $popts, $revId, $suppressCache ); } if ( !$pout ) { - $this->dieWithError( [ 'apierror-nosuchrevid', $revId ?: $page->getLatest() ] ); + $this->dieWithError( [ 'apierror-nosuchrevid', $revId ?: $page->getLatest() ] ); // @codeCoverageIgnore } return $pout; diff --git a/includes/api/ApiPurge.php b/includes/api/ApiPurge.php index b7cfc2c6a2..bb0be68484 100644 --- a/includes/api/ApiPurge.php +++ b/includes/api/ApiPurge.php @@ -26,7 +26,7 @@ use MediaWiki\MediaWikiServices; * @ingroup API */ class ApiPurge extends ApiBase { - private $mPageSet; + private $mPageSet = null; /** * Purges the cache of a page @@ -132,7 +132,7 @@ class ApiPurge extends ApiBase { * @return ApiPageSet */ private function getPageSet() { - if ( !isset( $this->mPageSet ) ) { + if ( $this->mPageSet === null ) { $this->mPageSet = new ApiPageSet( $this ); } diff --git a/includes/api/ApiQueryAllDeletedRevisions.php b/includes/api/ApiQueryAllDeletedRevisions.php index f885b729b1..be12977994 100644 --- a/includes/api/ApiQueryAllDeletedRevisions.php +++ b/includes/api/ApiQueryAllDeletedRevisions.php @@ -136,16 +136,11 @@ class ApiQueryAllDeletedRevisions extends ApiQueryRevisionsBase { } if ( $this->fetchContent ) { - // Modern MediaWiki has the content for deleted revs in the 'text' - // table using fields old_text and old_flags. But revisions deleted - // pre-1.5 store the content in the 'archive' table directly using - // fields ar_text and ar_flags, and no corresponding 'text' row. So - // we have to LEFT JOIN and fetch all four fields. $this->addTables( 'text' ); $this->addJoinConds( [ 'text' => [ 'LEFT JOIN', [ 'ar_text_id=old_id' ] ] ] ); - $this->addFields( [ 'ar_text', 'ar_flags', 'old_text', 'old_flags' ] ); + $this->addFields( [ 'old_text', 'old_flags' ] ); // This also means stricter restrictions $this->checkUserRightsAny( [ 'deletedtext', 'undelete' ] ); diff --git a/includes/api/ApiQueryDeletedRevisions.php b/includes/api/ApiQueryDeletedRevisions.php index b7fd8d4ddc..1a1e8f7ac5 100644 --- a/includes/api/ApiQueryDeletedRevisions.php +++ b/includes/api/ApiQueryDeletedRevisions.php @@ -88,16 +88,11 @@ class ApiQueryDeletedRevisions extends ApiQueryRevisionsBase { } if ( $this->fetchContent ) { - // Modern MediaWiki has the content for deleted revs in the 'text' - // table using fields old_text and old_flags. But revisions deleted - // pre-1.5 store the content in the 'archive' table directly using - // fields ar_text and ar_flags, and no corresponding 'text' row. So - // we have to LEFT JOIN and fetch all four fields. $this->addTables( 'text' ); $this->addJoinConds( [ 'text' => [ 'LEFT JOIN', [ 'ar_text_id=old_id' ] ] ] ); - $this->addFields( [ 'ar_text', 'ar_flags', 'old_text', 'old_flags' ] ); + $this->addFields( [ 'old_text', 'old_flags' ] ); // This also means stricter restrictions $this->checkUserRightsAny( [ 'deletedtext', 'undelete' ] ); diff --git a/includes/api/ApiQueryDeletedrevs.php b/includes/api/ApiQueryDeletedrevs.php index 2d5074178e..83d00a9330 100644 --- a/includes/api/ApiQueryDeletedrevs.php +++ b/includes/api/ApiQueryDeletedrevs.php @@ -144,17 +144,11 @@ class ApiQueryDeletedrevs extends ApiQueryBase { } if ( $fld_content ) { - // Modern MediaWiki has the content for deleted revs in the 'text' - // table using fields old_text and old_flags. But revisions deleted - // pre-1.5 store the content in the 'archive' table directly using - // fields ar_text and ar_flags, and no corresponding 'text' row. So - // we have to LEFT JOIN and fetch all four fields, plus ar_text_id - // to be able to tell the difference. $this->addTables( 'text' ); $this->addJoinConds( [ 'text' => [ 'LEFT JOIN', [ 'ar_text_id=old_id' ] ] ] ); - $this->addFields( [ 'ar_text', 'ar_flags', 'ar_text_id', 'old_text', 'old_flags' ] ); + $this->addFields( [ 'ar_text_id', 'old_text', 'old_flags' ] ); // This also means stricter restrictions $this->checkUserRightsAny( [ 'deletedtext', 'undelete' ] ); @@ -370,12 +364,7 @@ class ApiQueryDeletedrevs extends ApiQueryBase { $anyHidden = true; } if ( Revision::userCanBitfield( $row->ar_deleted, Revision::DELETED_TEXT, $user ) ) { - if ( isset( $row->ar_text ) && !$row->ar_text_id ) { - // Pre-1.5 ar_text row (if condition from Revision::newFromArchiveRow) - ApiResult::setContentValue( $rev, 'text', Revision::getRevisionText( $row, 'ar_' ) ); - } else { - ApiResult::setContentValue( $rev, 'text', Revision::getRevisionText( $row ) ); - } + ApiResult::setContentValue( $rev, 'text', Revision::getRevisionText( $row ) ); } } diff --git a/includes/api/ApiQueryInfo.php b/includes/api/ApiQueryInfo.php index 5294b1d8e7..1d3c110c3a 100644 --- a/includes/api/ApiQueryInfo.php +++ b/includes/api/ApiQueryInfo.php @@ -34,7 +34,7 @@ class ApiQueryInfo extends ApiQueryBase { $fld_readable = false, $fld_watched = false, $fld_watchers = false, $fld_visitingwatchers = false, $fld_notificationtimestamp = false, - $fld_preload = false, $fld_displaytitle = false; + $fld_preload = false, $fld_displaytitle = false, $fld_varianttitles = false; private $params; @@ -49,7 +49,7 @@ class ApiQueryInfo extends ApiQueryBase { $pageLatest, $pageLength; private $protections, $restrictionTypes, $watched, $watchers, $visitingwatchers, - $notificationtimestamps, $talkids, $subjectids, $displaytitles; + $notificationtimestamps, $talkids, $subjectids, $displaytitles, $variantTitles; private $showZeroWatchers = false; private $tokenFunctions; @@ -306,6 +306,7 @@ class ApiQueryInfo extends ApiQueryBase { $this->fld_readable = isset( $prop['readable'] ); $this->fld_preload = isset( $prop['preload'] ); $this->fld_displaytitle = isset( $prop['displaytitle'] ); + $this->fld_varianttitles = isset( $prop['varianttitles'] ); } $pageSet = $this->getPageSet(); @@ -368,6 +369,10 @@ class ApiQueryInfo extends ApiQueryBase { $this->getDisplayTitle(); } + if ( $this->fld_varianttitles ) { + $this->getVariantTitles(); + } + /** @var Title $title */ foreach ( $this->everything as $pageid => $title ) { $pageInfo = $this->extractPageInfo( $pageid, $title ); @@ -510,6 +515,12 @@ class ApiQueryInfo extends ApiQueryBase { } } + if ( $this->fld_varianttitles ) { + if ( isset( $this->variantTitles[$pageid] ) ) { + $pageInfo['varianttitles'] = $this->variantTitles[$pageid]; + } + } + if ( $this->params['testactions'] ) { $limit = $this->getMain()->canApiHighLimits() ? self::LIMIT_SML1 : self::LIMIT_SML2; if ( $this->countTestedActions >= $limit ) { @@ -740,6 +751,32 @@ class ApiQueryInfo extends ApiQueryBase { } } + private function getVariantTitles() { + if ( !count( $this->titles ) ) { + return; + } + $this->variantTitles = []; + foreach ( $this->titles as $pageId => $t ) { + $this->variantTitles[$pageId] = isset( $this->displaytitles[$pageId] ) + ? $this->getAllVariants( $this->displaytitles[$pageId] ) + : $this->getAllVariants( $t->getText(), $t->getNamespace() ); + } + } + + private function getAllVariants( $text, $ns = NS_MAIN ) { + global $wgContLang; + $result = []; + foreach ( $wgContLang->getVariants() as $variant ) { + $convertTitle = $wgContLang->autoConvert( $text, $variant ); + if ( $ns !== NS_MAIN ) { + $convertNs = $wgContLang->convertNamespace( $ns, $variant ); + $convertTitle = $convertNs . ':' . $convertTitle; + } + $result[$variant] = $convertTitle; + } + return $result; + } + /** * Get information about watched status and put it in $this->watched * and $this->notificationtimestamps @@ -879,6 +916,7 @@ class ApiQueryInfo extends ApiQueryBase { 'url', 'preload', 'displaytitle', + 'varianttitles', ]; if ( array_diff( (array)$params['prop'], $publicProps ) ) { return 'private'; @@ -912,6 +950,7 @@ class ApiQueryInfo extends ApiQueryBase { 'readable', # private 'preload', 'displaytitle', + 'varianttitles', // If you add more properties here, please consider whether they // need to be added to getCacheMode() ], diff --git a/includes/api/ApiQueryRecentChanges.php b/includes/api/ApiQueryRecentChanges.php index 9ff41498a9..326debc0e0 100644 --- a/includes/api/ApiQueryRecentChanges.php +++ b/includes/api/ApiQueryRecentChanges.php @@ -235,15 +235,21 @@ class ApiQueryRecentChanges extends ApiQueryGeneratorBase { if ( isset( $show['unpatrolled'] ) ) { // See ChangesList::isUnpatrolled if ( $user->useRCPatrol() ) { - $this->addWhere( 'rc_patrolled = 0' ); + $this->addWhere( 'rc_patrolled = ' . RecentChange::PRC_UNPATROLLED ); } elseif ( $user->useNPPatrol() ) { - $this->addWhere( 'rc_patrolled = 0' ); + $this->addWhere( 'rc_patrolled = ' . RecentChange::PRC_UNPATROLLED ); $this->addWhereFld( 'rc_type', RC_NEW ); } } - $this->addWhereIf( 'rc_patrolled != 2', isset( $show['!autopatrolled'] ) ); - $this->addWhereIf( 'rc_patrolled = 2', isset( $show['autopatrolled'] ) ); + $this->addWhereIf( + 'rc_patrolled != ' . RecentChange::PRC_AUTOPATROLLED, + isset( $show['!autopatrolled'] ) + ); + $this->addWhereIf( + 'rc_patrolled = ' . RecentChange::PRC_AUTOPATROLLED, + isset( $show['autopatrolled'] ) + ); // Don't throw log entries out the window here $this->addWhereIf( @@ -552,9 +558,9 @@ class ApiQueryRecentChanges extends ApiQueryGeneratorBase { /* Add the patrolled flag */ if ( $this->fld_patrolled ) { - $vals['patrolled'] = $row->rc_patrolled != 0; + $vals['patrolled'] = $row->rc_patrolled != RecentChange::PRC_UNPATROLLED; $vals['unpatrolled'] = ChangesList::isUnpatrolled( $row, $user ); - $vals['autopatrolled'] = $row->rc_patrolled == 2; + $vals['autopatrolled'] = $row->rc_patrolled == RecentChange::PRC_AUTOPATROLLED; } if ( $this->fld_loginfo && $row->rc_type == RC_LOG ) { diff --git a/includes/api/ApiQuerySiteinfo.php b/includes/api/ApiQuerySiteinfo.php index 3048273267..3f2d510343 100644 --- a/includes/api/ApiQuerySiteinfo.php +++ b/includes/api/ApiQuerySiteinfo.php @@ -223,7 +223,7 @@ class ApiQuerySiteinfo extends ApiQueryBase { if ( $data['readonly'] ) { $data['readonlyreason'] = wfReadOnlyReason(); } - $data['writeapi'] = (bool)$config->get( 'EnableWriteAPI' ); + $data['writeapi'] = true; // Deprecated since MW 1.32 $data['maxarticlesize'] = $config->get( 'MaxArticleSize' ) * 1024; diff --git a/includes/api/ApiQueryUserContributions.php b/includes/api/ApiQueryUserContributions.php index f6bc8cb6b9..12f42edc48 100644 --- a/includes/api/ApiQueryUserContributions.php +++ b/includes/api/ApiQueryUserContributions.php @@ -540,10 +540,22 @@ class ApiQueryContributions extends ApiQueryBase { $this->addWhereIf( 'rev_minor_edit = 0', isset( $show['!minor'] ) ); $this->addWhereIf( 'rev_minor_edit != 0', isset( $show['minor'] ) ); - $this->addWhereIf( 'rc_patrolled = 0', isset( $show['!patrolled'] ) ); - $this->addWhereIf( 'rc_patrolled != 0', isset( $show['patrolled'] ) ); - $this->addWhereIf( 'rc_patrolled != 2', isset( $show['!autopatrolled'] ) ); - $this->addWhereIf( 'rc_patrolled = 2', isset( $show['autopatrolled'] ) ); + $this->addWhereIf( + 'rc_patrolled = ' . RecentChange::PRC_UNPATROLLED, + isset( $show['!patrolled'] ) + ); + $this->addWhereIf( + 'rc_patrolled != ' . RecentChange::PRC_UNPATROLLED, + isset( $show['patrolled'] ) + ); + $this->addWhereIf( + 'rc_patrolled != ' . RecentChange::PRC_AUTOPATROLLED, + isset( $show['!autopatrolled'] ) + ); + $this->addWhereIf( + 'rc_patrolled = ' . RecentChange::PRC_AUTOPATROLLED, + isset( $show['autopatrolled'] ) + ); $this->addWhereIf( $idField . ' != page_latest', isset( $show['!top'] ) ); $this->addWhereIf( $idField . ' = page_latest', isset( $show['top'] ) ); $this->addWhereIf( 'rev_parent_id != 0', isset( $show['!new'] ) ); diff --git a/includes/api/ApiQueryWatchlist.php b/includes/api/ApiQueryWatchlist.php index 52ad26cdcb..bb09838efc 100644 --- a/includes/api/ApiQueryWatchlist.php +++ b/includes/api/ApiQueryWatchlist.php @@ -375,9 +375,9 @@ class ApiQueryWatchlist extends ApiQueryGeneratorBase { /* Add the patrolled flag */ if ( $this->fld_patrol ) { - $vals['patrolled'] = $recentChangeInfo['rc_patrolled'] != 0; + $vals['patrolled'] = $recentChangeInfo['rc_patrolled'] != RecentChange::PRC_UNPATROLLED; $vals['unpatrolled'] = ChangesList::isUnpatrolled( (object)$recentChangeInfo, $user ); - $vals['autopatrolled'] = $recentChangeInfo['rc_patrolled'] == 2; + $vals['autopatrolled'] = $recentChangeInfo['rc_patrolled'] == RecentChange::PRC_AUTOPATROLLED; } if ( $this->fld_loginfo && $recentChangeInfo['rc_type'] == RC_LOG ) { diff --git a/includes/api/ApiSetNotificationTimestamp.php b/includes/api/ApiSetNotificationTimestamp.php index 5e7a63316c..f7dc4a78e9 100644 --- a/includes/api/ApiSetNotificationTimestamp.php +++ b/includes/api/ApiSetNotificationTimestamp.php @@ -30,7 +30,7 @@ use MediaWiki\MediaWikiServices; */ class ApiSetNotificationTimestamp extends ApiBase { - private $mPageSet; + private $mPageSet = null; public function execute() { $user = $this->getUser(); @@ -187,7 +187,7 @@ class ApiSetNotificationTimestamp extends ApiBase { * @return ApiPageSet */ private function getPageSet() { - if ( !isset( $this->mPageSet ) ) { + if ( $this->mPageSet === null ) { $this->mPageSet = new ApiPageSet( $this ); } diff --git a/includes/api/ApiUserrights.php b/includes/api/ApiUserrights.php index 3813aba7a1..47f3bc5a4d 100644 --- a/includes/api/ApiUserrights.php +++ b/includes/api/ApiUserrights.php @@ -58,14 +58,16 @@ class ApiUserrights extends ApiBase { $params = $this->extractRequestParams(); // Figure out expiry times from the input - // $params['expiry'] may not be set in subclasses + // $params['expiry'] is not set in CentralAuth's ApiGlobalUserRights subclass if ( isset( $params['expiry'] ) ) { $expiry = (array)$params['expiry']; } else { $expiry = [ 'infinity' ]; } $add = (array)$params['add']; - if ( count( $expiry ) !== count( $add ) ) { + if ( !$add ) { + $expiry = []; + } elseif ( count( $expiry ) !== count( $add ) ) { if ( count( $expiry ) === 1 ) { $expiry = array_fill( 0, count( $add ), $expiry[0] ); } else { @@ -98,7 +100,7 @@ class ApiUserrights extends ApiBase { $tags = $params['tags']; // Check if user can add tags - if ( !is_null( $tags ) ) { + if ( $tags !== null ) { $ableToTag = ChangeTags::canAddTagsAccompanyingChange( $tags, $pUser ); if ( !$ableToTag->isOK() ) { $this->dieStatus( $ableToTag ); @@ -110,8 +112,9 @@ class ApiUserrights extends ApiBase { $r['user'] = $user->getName(); $r['userid'] = $user->getId(); list( $r['added'], $r['removed'] ) = $form->doSaveUserGroups( + // Don't pass null to doSaveUserGroups() for array params, cast to empty array $user, (array)$add, (array)$params['remove'], - $params['reason'], $tags, $groupExpiries + $params['reason'], (array)$tags, $groupExpiries ); $result = $this->getResult(); @@ -186,6 +189,7 @@ class ApiUserrights extends ApiBase { ApiBase::PARAM_ISMULTI => true ], ]; + // CentralAuth's ApiGlobalUserRights subclass can't handle expiries if ( !$this->getUserRightsPage()->canProcessExpiries() ) { unset( $a['expiry'] ); } diff --git a/includes/api/i18n/de.json b/includes/api/i18n/de.json index 0d30fa5997..b1eee12d6d 100644 --- a/includes/api/i18n/de.json +++ b/includes/api/i18n/de.json @@ -72,6 +72,8 @@ "apihelp-compare-param-totitle": "Zweiter zu vergleichender Titel.", "apihelp-compare-param-toid": "Zweite zu vergleichende Seitennummer.", "apihelp-compare-param-torev": "Zweite zu vergleichende Version.", + "apihelp-compare-paramvalue-prop-diff": "Das Unterschieds-HTML.", + "apihelp-compare-paramvalue-prop-diffsize": "Die Größe des Unterschieds-HTML in Bytes.", "apihelp-compare-paramvalue-prop-title": "Die Seitentitel der Versionen „Von“ und „Nach“.", "apihelp-compare-example-1": "Unterschied zwischen Version 1 und 2 abrufen", "apihelp-createaccount-summary": "Erstellt ein neues Benutzerkonto.", @@ -664,7 +666,7 @@ "apihelp-query+exturlusage-param-namespace": "Die aufzulistenden Seiten-Namensräume.", "apihelp-query+exturlusage-param-limit": "Wie viele Seiten zurückgegeben werden sollen.", "apihelp-query+exturlusage-param-expandurl": "Expandiert protokollrelative URLs mit dem kanonischen Protokoll.", - "apihelp-query+exturlusage-example-simple": "Zeigt Seiten, die auf http://www.mediawiki.org verlinken.", + "apihelp-query+exturlusage-example-simple": "Zeigt Seiten, die auf https://www.mediawiki.org verlinken.", "apihelp-query+filearchive-summary": "Alle gelöschten Dateien der Reihe nach auflisten.", "apihelp-query+filearchive-param-from": "Der Bildertitel, bei dem die Auflistung beginnen soll.", "apihelp-query+filearchive-param-to": "Der Bildertitel, bei dem die Auflistung enden soll.", @@ -749,7 +751,10 @@ "apihelp-query+info-paramvalue-prop-subjectid": "Die Seitenkennung der Elternseite jeder Diskussionsseite.", "apihelp-query+info-paramvalue-prop-readable": "Ob der Benutzer diese Seite betrachten darf.", "apihelp-query+info-paramvalue-prop-displaytitle": "Gibt die Art und Weise an, in der der Seitentitel tatsächlich angezeigt wird.", + "apihelp-query+info-paramvalue-prop-varianttitles": "Gibt den Anzeigetitel in allen Varianten der Sprache des Websiteinhalts aus.", "apihelp-query+info-param-testactions": "Überprüft, ob der aktuelle Benutzer gewisse Aktionen auf der Seite ausführen kann.", + "apihelp-query+info-example-simple": "Ruft Informationen über die Seite Hauptseite ab.", + "apihelp-query+iwbacklinks-summary": "Findet alle Seiten, die auf einen angegebenen Interwikilink verlinken.", "apihelp-query+iwbacklinks-param-prefix": "Präfix für das Interwiki.", "apihelp-query+iwbacklinks-param-limit": "Wie viele Seiten insgesamt zurückgegeben werden sollen.", "apihelp-query+iwbacklinks-param-prop": "Zurückzugebende Eigenschaften:", @@ -764,6 +769,7 @@ "apihelp-query+iwlinks-param-dir": "Die Auflistungsrichtung.", "apihelp-query+langbacklinks-param-limit": "Wie viele Gesamtseiten zurückgegeben werden sollen.", "apihelp-query+langbacklinks-param-prop": "Zurückzugebende Eigenschaften:", + "apihelp-query+langbacklinks-paramvalue-prop-lltitle": "Ergänzt den Titel des Sprachlinks.", "apihelp-query+langbacklinks-param-dir": "Die Auflistungsrichtung.", "apihelp-query+langbacklinks-example-simple": "Ruft Seiten ab, die auf [[:fr:Test]] verlinken.", "apihelp-query+langlinks-param-limit": "Wie viele Sprachlinks zurückgegeben werden sollen.", @@ -780,6 +786,7 @@ "apihelp-query+linkshere-param-prop": "Zurückzugebende Eigenschaften:", "apihelp-query+linkshere-paramvalue-prop-pageid": "Die Seitenkennung jeder Seite.", "apihelp-query+linkshere-paramvalue-prop-title": "Titel jeder Seite.", + "apihelp-query+linkshere-paramvalue-prop-redirect": "Markieren, falls die Seite eine Weiterleitung ist.", "apihelp-query+linkshere-param-limit": "Wie viel zurückgegeben werden soll.", "apihelp-query+linkshere-example-simple": "Holt eine Liste von Seiten, die auf [[Main Page]] verlinken.", "apihelp-query+logevents-summary": "Ruft Ereignisse von Logbüchern ab.", @@ -788,8 +795,10 @@ "apihelp-query+logevents-paramvalue-prop-title": "Ergänzt den Titel der Seite für das Logbuchereignis.", "apihelp-query+logevents-paramvalue-prop-type": "Ergänzt den Typ des Logbuchereignisses.", "apihelp-query+logevents-paramvalue-prop-user": "Ergänzt den verantwortlichen Benutzer für das Logbuchereignis.", + "apihelp-query+logevents-paramvalue-prop-timestamp": "Ergänzt den Zeitstempel des Logbucheintrags.", "apihelp-query+logevents-paramvalue-prop-comment": "Ergänzt den Kommentar des Logbuchereignisses.", "apihelp-query+logevents-paramvalue-prop-tags": "Listet Markierungen für das Logbuchereignis auf.", + "apihelp-query+logevents-param-type": "Filtert nur Logbucheinträge mit diesem Typ heraus.", "apihelp-query+logevents-param-start": "Der Zeitstempel, bei dem die Aufzählung beginnen soll.", "apihelp-query+logevents-param-end": "Der Zeitstempel, bei dem die Aufzählung enden soll.", "apihelp-query+logevents-param-prefix": "Filtert Einträge, die mit diesem Präfix beginnen.", @@ -803,6 +812,7 @@ "apihelp-query+prefixsearch-param-limit": "Maximale Anzahl zurückzugebender Ergebnisse.", "apihelp-query+prefixsearch-param-offset": "Anzahl der zu überspringenden Ergebnisse.", "apihelp-query+prefixsearch-param-profile": "Zu verwendendes Suchprofil.", + "apihelp-query+protectedtitles-summary": "Listet alle Titel auf, die vor einer Erstellung geschützt sind.", "apihelp-query+protectedtitles-param-limit": "Wie viele Seiten insgesamt zurückgegeben werden sollen.", "apihelp-query+protectedtitles-param-prop": "Zurückzugebende Eigenschaften:", "apihelp-query+protectedtitles-paramvalue-prop-level": "Ergänzt den Schutzstatus.", @@ -817,6 +827,7 @@ "apihelp-query+recentchanges-paramvalue-prop-flags": "Ergänzt Markierungen für die Bearbeitung.", "apihelp-query+recentchanges-paramvalue-prop-timestamp": "Ergänzt den Zeitstempel für die Bearbeitung.", "apihelp-query+recentchanges-paramvalue-prop-title": "Ergänzt den Seitentitel der Bearbeitung.", + "apihelp-query+recentchanges-paramvalue-prop-autopatrolled": "Markiert kontrollierbare Bearbeitungen als automatisch kontrolliert oder nicht.", "apihelp-query+recentchanges-paramvalue-prop-tags": "Listet Markierungen für den Eintrag auf.", "apihelp-query+recentchanges-example-simple": "Listet die letzten Änderungen auf.", "apihelp-query+redirects-param-prop": "Zurückzugebende Eigenschaften:", @@ -876,6 +887,7 @@ "apihelp-query+usercontribs-paramvalue-prop-size": "Ergänzt die neue Größe der Bearbeitung.", "apihelp-query+usercontribs-paramvalue-prop-flags": "Ergänzt Markierungen der Bearbeitung.", "apihelp-query+usercontribs-paramvalue-prop-patrolled": "Markiert kontrollierte Bearbeitungen.", + "apihelp-query+usercontribs-paramvalue-prop-autopatrolled": "Markiert automatisch kontrollierte Bearbeitungen.", "apihelp-query+usercontribs-paramvalue-prop-tags": "Listet die Markierungen für die Bearbeitung auf.", "apihelp-query+userinfo-paramvalue-prop-blockinfo": "Markiert, ob der aktuelle Benutzer gesperrt ist, von wem und aus welchem Grund.", "apihelp-query+userinfo-paramvalue-prop-options": "Listet alle Einstellungen auf, die der aktuelle Benutzer festgelegt hat.", @@ -905,6 +917,7 @@ "apihelp-query+watchlist-paramvalue-prop-parsedcomment": "Ergänzt den geparsten Kommentar der Bearbeitung.", "apihelp-query+watchlist-paramvalue-prop-timestamp": "Ergänzt den Zeitstempel der Bearbeitung.", "apihelp-query+watchlist-paramvalue-prop-patrol": "Markiert Bearbeitungen, die kontrolliert sind.", + "apihelp-query+watchlist-paramvalue-prop-autopatrol": "Markiert Bearbeitungen, die automatisch kontrolliert sind.", "apihelp-query+watchlist-paramvalue-prop-sizes": "Ergänzt die alten und neuen Längen der Seite.", "apihelp-query+watchlist-paramvalue-prop-tags": "Listet Markierungen für den Eintrag auf.", "apihelp-query+watchlist-paramvalue-type-edit": "Normale Seitenbearbeitungen.", diff --git a/includes/api/i18n/en.json b/includes/api/i18n/en.json index d158b2c678..6838e545d6 100644 --- a/includes/api/i18n/en.json +++ b/includes/api/i18n/en.json @@ -2,7 +2,8 @@ "@metadata": { "authors": [ "Anomie", - "Siebrand" + "Siebrand", + "Zoranzoki21" ] }, @@ -789,7 +790,7 @@ "apihelp-query+exturlusage-param-namespace": "The page namespaces to enumerate.", "apihelp-query+exturlusage-param-limit": "How many pages to return.", "apihelp-query+exturlusage-param-expandurl": "Expand protocol-relative URLs with the canonical protocol.", - "apihelp-query+exturlusage-example-simple": "Show pages linking to http://www.mediawiki.org.", + "apihelp-query+exturlusage-example-simple": "Show pages linking to https://www.mediawiki.org.", "apihelp-query+filearchive-summary": "Enumerate all deleted files sequentially.", "apihelp-query+filearchive-param-from": "The image title to start enumerating from.", @@ -897,6 +898,7 @@ "apihelp-query+info-paramvalue-prop-readable": "Whether the user can read this page.", "apihelp-query+info-paramvalue-prop-preload": "Gives the text returned by EditFormPreloadText.", "apihelp-query+info-paramvalue-prop-displaytitle": "Gives the manner in which the page title is actually displayed.", + "apihelp-query+info-paramvalue-prop-varianttitles": "Gives the display title in all variants of the site content language.", "apihelp-query+info-param-testactions": "Test whether the current user can perform certain actions on the page.", "apihelp-query+info-param-token": "Use [[Special:ApiHelp/query+tokens|action=query&meta=tokens]] instead.", "apihelp-query+info-example-simple": "Get information about the page Main Page.", @@ -1739,7 +1741,6 @@ "apierror-missingtitle-byname": "The page $1 doesn't exist.", "apierror-moduledisabled": "The $1 module has been disabled.", "apierror-multival-only-one-of": "{{PLURAL:$3|Only|Only one of}} $2 is allowed for parameter $1.", - "apierror-multival-only-one": "Only one value is allowed for parameter $1.", "apierror-multpages": "$1 may only be used with a single page.", "apierror-mustbeloggedin-changeauth": "You must be logged in to change authentication data.", "apierror-mustbeloggedin-generic": "You must be logged in.", diff --git a/includes/api/i18n/es.json b/includes/api/i18n/es.json index 4733de4ad6..5be47036d1 100644 --- a/includes/api/i18n/es.json +++ b/includes/api/i18n/es.json @@ -131,7 +131,7 @@ "apihelp-edit-param-tags": "Cambia las etiquetas para aplicarlas a la revisión.", "apihelp-edit-param-minor": "Edición menor.", "apihelp-edit-param-notminor": "Edición no menor.", - "apihelp-edit-param-bot": "Marcar esta edición como edición de bot.", + "apihelp-edit-param-bot": "Marcar esta como una edición de robot.", "apihelp-edit-param-basetimestamp": "Marca de tiempo de la revisión base, usada para detectar conflictos de edición. Se puede obtener mediante [[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]]", "apihelp-edit-param-starttimestamp": "Marca de tiempo de cuando empezó el proceso de edición, usada para detectar conflictos de edición. Se puede obtener un valor apropiado usando [[Special:ApiHelp/main|curtimestamp]] cuando comiences el proceso de edición (por ejemplo, al cargar el contenido de la página por editar).", "apihelp-edit-param-recreate": "Reemplazar los errores acerca de la página de haber sido eliminados en el ínterin.", @@ -736,7 +736,7 @@ "apihelp-query+exturlusage-param-namespace": "Los espacios de nombres que enumerar.", "apihelp-query+exturlusage-param-limit": "Cuántas páginas se devolverán.", "apihelp-query+exturlusage-param-expandurl": "Expandir las URL relativas a un protocolo con el protocolo canónico.", - "apihelp-query+exturlusage-example-simple": "Mostrar páginas que enlacen con http://www.mediawiki.org.", + "apihelp-query+exturlusage-example-simple": "Mostrar páginas que enlacen con https://www.mediawiki.org.", "apihelp-query+filearchive-summary": "Enumerar todos los archivos borrados de forma secuencial.", "apihelp-query+filearchive-param-from": "El título de imagen para comenzar la enumeración", "apihelp-query+filearchive-param-to": "El título de imagen para detener la enumeración.", @@ -1539,7 +1539,6 @@ "apierror-missingtitle-byname": "La página $1 no existe.", "apierror-moduledisabled": "El módulo $1 ha sido deshabilitado.", "apierror-multival-only-one-of": "Solo {{PLURAL:$3|se permite el valor|se permiten los valores}} $2 para el parámetro $1.", - "apierror-multival-only-one": "Solo se permite un valor para el parámetro $1.", "apierror-multpages": "$1 no se puede utilizar más que con una sola página.", "apierror-mustbeloggedin-changeauth": "Debes estar conectado para poder cambiar los datos de autentificación.", "apierror-mustbeloggedin-generic": "Debes estar conectado.", @@ -1581,6 +1580,7 @@ "apierror-promised-nonwrite-api": "La cabecera HTTP Promise-Non-Write-API-Action no se puede enviar a módulos de la API en modo escritura.", "apierror-protect-invalidaction": "Tipo de protección «$1» no válido.", "apierror-protect-invalidlevel": "Nivel de protección «$1» no válido.", + "apierror-ratelimited": "Has excedido tu límite de frecuencia. Aguarda unos minutos y vuelve a intentarlo.", "apierror-readapidenied": "Necesitas permiso de lectura para utilizar este módulo.", "apierror-readonly": "El wiki está actualmente en modo de solo lectura.", "apierror-reauthenticate": "No te has autentificado recientemente en esta sesión. Por favor, vuelve a autentificarte.", diff --git a/includes/api/i18n/fr.json b/includes/api/i18n/fr.json index 6c8546bd0a..5ce23316b5 100644 --- a/includes/api/i18n/fr.json +++ b/includes/api/i18n/fr.json @@ -30,10 +30,11 @@ "Umherirrender", "Thibaut120094", "KATRINE1992", - "Kenjiraw" + "Kenjiraw", + "Framawiki" ] }, - "apihelp-main-extended-description": "
\n* [[mw:Special:MyLanguage/API:Main_page|Documentation]]\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Liste de diffusion]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Annonces de l’API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bogues et demandes]\n
\nÉtat :L’API MédiaWiki est une interface stable et mature qui est supportée et améliorée de façon active. Bien que nous essayions de l’éviter, nous pouvons avoir parfois besoin de faire des modifications impactantes ; inscrivez-vous à [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ la liste de diffusion mediawiki-api-announce] pour être informé des mises à jour.\n\nRequêtes erronées : Si des requêtes erronées sont envoyées à l’API, un entête HTTP sera renvoyé avec la clé « MediaWiki-API-Error ». La valeur de cet entête et le code d’erreur renvoyé prendront la même valeur. Pour plus d’information, voyez [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Errors and warnings]].\n\n

Test : Pour faciliter le test des requêtes de l’API, voyez [[Special:ApiSandbox]].

", + "apihelp-main-extended-description": "
\n* [[mw:Special:MyLanguage/API:Main_page|Documentation]]\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Liste de diffusion]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Annonces de l’API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bogues et demandes]\n
\nÉtat : L’API MédiaWiki est une interface stable et mature qui est supportée et améliorée de façon active. Bien que nous essayions de l’éviter, nous pouvons avoir parfois besoin de faire des modifications impactantes ; inscrivez-vous à [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ la liste de diffusion mediawiki-api-announce] pour être informé des mises à jour.\n\nRequêtes erronées : Si des requêtes erronées sont envoyées à l’API, un entête HTTP sera renvoyé avec la clé « MediaWiki-API-Error ». La valeur de cet entête et le code d’erreur renvoyé prendront la même valeur. Pour plus d’information, voyez [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Errors and warnings]].\n\n

Test : Pour faciliter le test des requêtes de l’API, voyez [[Special:ApiSandbox]].

", "apihelp-main-param-action": "Quelle action effectuer.", "apihelp-main-param-format": "Le format de sortie.", "apihelp-main-param-maxlag": "La latence maximale peut être utilisée quand MédiaWiki est installé sur un cluster de base de données répliqué. Pour éviter des actions provoquant un supplément de latence de réplication de site, ce paramètre peut faire attendre le client jusqu’à ce que la latence de réplication soit inférieure à une valeur spécifiée. En cas de latence excessive, le code d’erreur maxlag est renvoyé avec un message tel que Attente de $host : $lag secondes de délai.
Voyez [[mw:Special:MyLanguage/Manual:Maxlag_parameter|Manuel: Maxlag parameter]] pour plus d’information.", @@ -755,7 +756,7 @@ "apihelp-query+exturlusage-param-namespace": "Les espaces de nom à énumérer.", "apihelp-query+exturlusage-param-limit": "Combien de pages renvoyer.", "apihelp-query+exturlusage-param-expandurl": "Étendre les URLs relatives au protocole avec le protocole canonique.", - "apihelp-query+exturlusage-example-simple": "Afficher les pages avec un lien vers http://www.mediawiki.org.", + "apihelp-query+exturlusage-example-simple": "Afficher les pages avec un lien vers https://www.mediawiki.org.", "apihelp-query+filearchive-summary": "Énumérer séquentiellement tous les fichiers supprimés.", "apihelp-query+filearchive-param-from": "Le titre de l’image auquel démarrer l’énumération.", "apihelp-query+filearchive-param-to": "Le titre de l’image auquel arrêter l’énumération.", @@ -856,6 +857,7 @@ "apihelp-query+info-paramvalue-prop-readable": "Si l’utilisateur peut lire cette page.", "apihelp-query+info-paramvalue-prop-preload": "Fournit le texte renvoyé par EditFormPreloadText.", "apihelp-query+info-paramvalue-prop-displaytitle": "Fournit la manière dont le titre de la page est réellement affiché.", + "apihelp-query+info-paramvalue-prop-varianttitles": "Donne le titre affiché dans toutes les variantes de la langue de contenu du site.", "apihelp-query+info-param-testactions": "Tester si l’utilisateur actuel peut effectuer certaines actions sur la page.", "apihelp-query+info-param-token": "Utiliser plutôt [[Special:ApiHelp/query+tokens|action=query&meta=tokens]].", "apihelp-query+info-example-simple": "Obtenir des informations sur la page Main Page.", @@ -1015,6 +1017,7 @@ "apihelp-query+recentchanges-paramvalue-prop-sizes": "Ajoute l’ancienne et la nouvelle taille de la page en octets.", "apihelp-query+recentchanges-paramvalue-prop-redirect": "Marque la modification si la page est une redirection.", "apihelp-query+recentchanges-paramvalue-prop-patrolled": "Marque les modifications à relire comme relues ou pas.", + "apihelp-query+recentchanges-paramvalue-prop-autopatrolled": "Marque les modifications patrouillables comme patrouillée automatiquement ou non.", "apihelp-query+recentchanges-paramvalue-prop-loginfo": "Ajoute les informations du journal (Id du journal, type de trace, etc.) aux entrées du journal.", "apihelp-query+recentchanges-paramvalue-prop-tags": "Liste les balises de l’entrée.", "apihelp-query+recentchanges-paramvalue-prop-sha1": "Ajoute la somme de contrôle du contenu pour les entrées associées à une révision.", @@ -1193,6 +1196,7 @@ "apihelp-query+usercontribs-paramvalue-prop-sizediff": "Ajoute le delta de taille de la modification par rapport à son parent.", "apihelp-query+usercontribs-paramvalue-prop-flags": "Ajoute les marques de la modification.", "apihelp-query+usercontribs-paramvalue-prop-patrolled": "Marque les modifications relues.", + "apihelp-query+usercontribs-paramvalue-prop-autopatrolled": "Marque les modifications patrouillées automatiquement.", "apihelp-query+usercontribs-paramvalue-prop-tags": "Liste les balises de la modification.", "apihelp-query+usercontribs-param-show": "Afficher uniquement les éléments correspondant à ces critères, par ex. les modifications non mineures uniquement : $2show=!minor.\n\nSi $2show=patrolled ou $2show=!patrolled est positionné, les révisions plus anciennes que [[mw:Special:MyLanguage/Manual:$wgRCMaxAge|$wgRCMaxAge]] ($1 {{PLURAL:$1|seconde|secondes}}) ne seront pas affichées.", "apihelp-query+usercontribs-param-tag": "Lister uniquement les révisions marquées avec cette balise.", @@ -1257,6 +1261,7 @@ "apihelp-query+watchlist-paramvalue-prop-parsedcomment": "Ajoute le commentaire analysé de la modification.", "apihelp-query+watchlist-paramvalue-prop-timestamp": "Ajoute l’horodatage de la modification.", "apihelp-query+watchlist-paramvalue-prop-patrol": "Marque les modifications relues.", + "apihelp-query+watchlist-paramvalue-prop-autopatrol": "Marque les modifications qui sont patrouillées automatiquement.", "apihelp-query+watchlist-paramvalue-prop-sizes": "Ajoute les tailles ancienne et nouvelle de la page.", "apihelp-query+watchlist-paramvalue-prop-notificationtimestamp": "Ajoute l’horodatage de la dernière notification de la modification à l’utilisateur.", "apihelp-query+watchlist-paramvalue-prop-loginfo": "Ajoute l’information de trace le cas échéant.", @@ -1634,7 +1639,6 @@ "apierror-missingtitle-byname": "La page $1 n’existe pas.", "apierror-moduledisabled": "Le module $1 a été désactivé.", "apierror-multival-only-one-of": "{{PLURAL:$3|Seul|Seul un des}} $2 est autorisé pour le paramètre $1.", - "apierror-multival-only-one": "Une seule valeur est autorisée pour le paramètre $1.", "apierror-multpages": "$1 ne peut être utilisé qu’avec une seule page.", "apierror-mustbeloggedin-changeauth": "Vous devez être connecté pour modifier les données d’authentification.", "apierror-mustbeloggedin-generic": "Vous devez être connecté.", diff --git a/includes/api/i18n/gl.json b/includes/api/i18n/gl.json index 4c66794c79..1f2b028b47 100644 --- a/includes/api/i18n/gl.json +++ b/includes/api/i18n/gl.json @@ -718,7 +718,7 @@ "apihelp-query+exturlusage-param-namespace": "Espazo de nomes a enumerar.", "apihelp-query+exturlusage-param-limit": "Cantas páxinas devolver.", "apihelp-query+exturlusage-param-expandurl": "Expandir as URLs relativas a un protocolo co protocolo canónico.", - "apihelp-query+exturlusage-example-simple": "Mostrar páxinas ligando a http://www.mediawiki.org.", + "apihelp-query+exturlusage-example-simple": "Amosar páxinas que ligan con https://www.mediawiki.org.", "apihelp-query+filearchive-summary": "Enumerar secuencialmente todos os ficheiros borrados.", "apihelp-query+filearchive-param-from": "Título da imaxe coa que comezar a enumeración.", "apihelp-query+filearchive-param-to": "Título da imaxe coa que rematar a enumeración.", @@ -1582,7 +1582,6 @@ "apierror-missingtitle-byname": "A páxina $1 non existe.", "apierror-moduledisabled": "O módulo $1 foi deshabilitado.", "apierror-multival-only-one-of": "Só {{PLURAL:$3|se permite o valor|se permiten os valores}} $2 para o parámetro $1.", - "apierror-multival-only-one": "Só se permite un valor para o parámetro $1.", "apierror-multpages": "$1 non se pode utilizar máis que con unha soa páxina.", "apierror-mustbeloggedin-changeauth": "Debe estar conectado para poder cambiar os datos de autentificación.", "apierror-mustbeloggedin-generic": "Debe estar conectado.", diff --git a/includes/api/i18n/he.json b/includes/api/i18n/he.json index 2064b7b652..a748b52210 100644 --- a/includes/api/i18n/he.json +++ b/includes/api/i18n/he.json @@ -18,7 +18,7 @@ "Umherirrender" ] }, - "apihelp-main-extended-description": "
\n* [[mw:Special:MyLanguage/API:Main_page|תיעוד]]\n* [[mw:Special:MyLanguage/API:FAQ|שו\"ת]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api רשימת דיוור]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce הודעות על API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R באגים ובקשות]\n
\nמצב: כל האפשרויות שמוצגות בדף הזה אמורות לעבוד, אבל ה־API עדיין בפיתוח פעיל, ויכול להשתנות בכל זמן. עשו מינוי ל[https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ רשימת הדיוור mediawiki-api-announce] להודעות על עדכונים.\n\nבקשות שגויות: כשבקשות שגויות נשלחות ל־API, תישלח כותרת HTTP עם המפתח \"MediaWiki-API-Error\" ואז גם הערך של הכותרת וגם קוד השגיאה יוגדרו לאותו ערך. למידע נוסף ר' [[mw:Special:MyLanguage/API:Errors_and_warnings|API: שגיאות ואזהרות]].\n\nבדיקה: לבדיקה קלה יותר של בקשות ר' [[Special:ApiSandbox]].", + "apihelp-main-extended-description": "
\n* [[mw:Special:MyLanguage/API:Main_page|תיעוד]]\n* [[mw:Special:MyLanguage/API:FAQ|שאלות נפוצות]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api רשימת דיוור]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce הודעות על API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R באגים ובקשות]\n
\nמצב: ה־API של מדיה־ויקי הוא ממשק ותיק ויציב שנתמך ומשתפר באופן סדיר. למרות שאנחנו משתדלים להימנע מכך, לעתים עלינו לבצע שינויים שעלולים לשבש דברים בפונקציונליות הזו; באפשרותך לעשות מינוי ל[https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ רשימת הדיוור mediawiki-api-announce] כדי לקבל הודעות על עדכונים.\n\nבקשות שגויות: כשבקשות שגויות נשלחות ל־API, תישלח כותרת HTTP עם המפתח \"MediaWiki-API-Error\", ואז גם הערך של הכותרת וגם קוד השגיאה יוגדרו לאותו ערך. למידע נוסף, אפשר לעיין בדף [[mw:Special:MyLanguage/API:Errors_and_warnings|API: שגיאות ואזהרות]].\n\n

בדיקה: לבדיקה קלה יותר של בקשות, אפשר להשתמש ב[[Special:ApiSandbox|ארגז החול של API]].

", "apihelp-main-param-action": "איזו פעולה לבצע.", "apihelp-main-param-format": "תסדיר הפלט.", "apihelp-main-param-maxlag": "שיהוי מרבי יכול לשמש כשמדיה־ויקי מותקנת בצביר עם מסד נתונים משוכפל. כדי לחסוך בפעולות שגורמות יותר שיהוי בשכפול אתר, הפרמטר הזה יכול לגרום ללקוח להמתין עד ששיהוי השכפול יורד מתחת לערך שצוין. במקרה של שיהוי מוגזם, קוד השגיאה maxlag מוחזר עם הודעה כמו Waiting for $host: $lag seconds lagged.
ר' [[mw:Special:MyLanguage/Manual:Maxlag_parameter|מדריך למשתמש: פרמטר maxlag]] למידע נוסף.", @@ -69,6 +69,7 @@ "apihelp-compare-param-fromid": "מס׳ זיהוי של הדף הראשון להשוואה.", "apihelp-compare-param-fromrev": "גרסה ראשונה להשוואה.", "apihelp-compare-param-fromtext": "להשתמש בטקסט הזה במקום תוכן הגרסה שהוגדרה על־ידי fromtitle, fromid או fromrev.", + "apihelp-compare-param-fromsection": "יש להשתמש רק בפסקה שצוינה בתוכן של הפרמטר 'from'.", "apihelp-compare-param-frompst": "לעשות התמרה לפני שמירה ב־fromtext.", "apihelp-compare-param-fromcontentmodel": "מודל התוכן של fromtext. אם זה לא סופק, ייעשה ניחוש על סמך פרמטרים אחרים.", "apihelp-compare-param-fromcontentformat": "תסדיר הסדרת תוכן של fromtext.", @@ -77,6 +78,7 @@ "apihelp-compare-param-torev": "גרסה שנייה להשוואה.", "apihelp-compare-param-torelative": "להשתמש בגרסה יחסית לגרסה שהוסקה מfromtitle, fromid או fromrev. לכל אפשריות ה־\"to\" האחרות לא תהיה השפעה.", "apihelp-compare-param-totext": "להשתמש בטקסט הזה במקום התוכן של הגרסה שהוגדר ב־totitle, toid or torev.", + "apihelp-compare-param-tosection": "יש להשתמש רק בפסקה שצוינה בתוכן של הפרמטר 'to'.", "apihelp-compare-param-topst": "לעשות התמרה לפני שמירה ב־totext.", "apihelp-compare-param-tocontentmodel": "מודל התוכן של totext. אם זה לא סופק, ייעשה ניחוש על סמך פרמטרים אחרים.", "apihelp-compare-param-tocontentformat": "תסדיר הסדרת תוכן של fromtext.", @@ -133,7 +135,7 @@ "apihelp-edit-param-bot": "סימון עריכה זו כעריכת בוט.", "apihelp-edit-param-basetimestamp": "חותם־זמן של גרסת הבסיס, משמש לזיהוי התנגשויות עריכה. אפשר לקבל אותו באמצעות [[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]].", "apihelp-edit-param-starttimestamp": "חותם־הזמן של תחילת תהליך העריכה, משמש לזיהוי התנגשויות. אפשר לקבל ערך מתאים באמצעות [[Special:ApiHelp/main|curtimestamp]] בעת תחילת תהליך העריכה (למשל בזמן טעינת תוכן הדף לעריכה).", - "apihelp-edit-param-recreate": "לעקוב את כל הטעויות על כך שהדף נמחק בינתיים.", + "apihelp-edit-param-recreate": "לעקוף את כל השגיאות על כך שהדף נמחק בינתיים.", "apihelp-edit-param-createonly": "לא לערוך את הדף אם הוא כבר קיים.", "apihelp-edit-param-nocreate": "לזרוק שגיאה אם הדף אינו קיים.", "apihelp-edit-param-watch": "הוספת הדף לרשימת המעקב של המשתמש הנוכחי.", @@ -239,9 +241,11 @@ "apihelp-import-extended-description": "יש לשים לב לכך שפעולת HTTP POST צריכה להיעשות בתור העלאת קובץ (כלומר, עם multipart/form-data) בזמן שליחת קובץ לפרמטר xml.", "apihelp-import-param-summary": "תקציר ייבוא עיולי יומן.", "apihelp-import-param-xml": "קובץ XML שהועלה.", + "apihelp-import-param-interwikiprefix": "לייבוא באמצעות העלאת קבצים: תחילית הבינוויקי שתוצג עבור שמות משתמשים שאינם מוכרים (וגם עבור שמות משתמשים מוכרים אם $1assignknownusers מוגדר).", + "apihelp-import-param-assignknownusers": "הקצאת העריכות למשתמשים המקומיים כאשר משתמשים בשמות זהים קיימים באתר המקומי.", "apihelp-import-param-interwikisource": "ליבוא בין אתרי ויקי: מאיזה ויקי לייבא.", - "apihelp-import-param-interwikipage": "ליבוא בין אתרי ויקי: איזה דף לייבא.", - "apihelp-import-param-fullhistory": "ליבוא בין אתרי ויקי: לייבר את ההיסטוריה המלאה, לא רק את הגרסה הנוכחית.", + "apihelp-import-param-interwikipage": "לייבוא בין אתרי ויקי: איזה דף לייבא.", + "apihelp-import-param-fullhistory": "לייבוא בין אתרי ויקי: לייבא את ההיסטוריה המלאה, לא רק את הגרסה הנוכחית.", "apihelp-import-param-templates": "ליבוא בין אתרי ויקי: לייבא גם את כל התבניות המוכללות.", "apihelp-import-param-namespace": "לייבא למרחב השם הזה. לא ניתן להשתמש בזה יחד עם $1rootpage.", "apihelp-import-param-rootpage": "לייבא בתור תת־משנה של הדף הזה. לא ניתן להשתמש בזה יחד עם $1namespace.", @@ -296,7 +300,7 @@ "apihelp-opensearch-summary": "חיפוש בוויקי בפרוטוקול OpenSearch.", "apihelp-opensearch-param-search": "מחרוזת לחיפוש.", "apihelp-opensearch-param-limit": "המספר המרבי של התוצאות שתוחזרנה.", - "apihelp-opensearch-param-namespace": "שמות מתחם לחיפוש.", + "apihelp-opensearch-param-namespace": "מרחבי השם שבהם יתבצע החיפוש. לשדה זה אין משמעות אם $1search מתחיל עם תחילית תקינה של מרחב שם.", "apihelp-opensearch-param-suggest": "לא לעשות דבר אם [[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]] הוא false.", "apihelp-opensearch-param-redirects": "איך לטפל בהפניות:\n;return:להחזיר את ההפניה עצמה.\n;resolve:להחזיר את דף היעד. יכול להחזיר פחות מ־$1limit תוצאות.\nמסיבות היסטוריות, בררת המחדל היא \"return\" עבור $1format=json ו־\"resolve\" עבור תסדירים אחרים.", "apihelp-opensearch-param-format": "תסדיר הפלט.", @@ -366,6 +370,7 @@ "apihelp-parse-param-disablepp": "יש להשתמש ב־$1disablelimitreport במקום.", "apihelp-parse-param-disableeditsection": "להשמיט את קישורי עריכת הפסקאות מפלט המפענח.", "apihelp-parse-param-disabletidy": "לא להריץ ניקוי HTML (למשל tidy) על פלט המפענח.", + "apihelp-parse-param-disablestylededuplication": "לא להסיר סגנונות כפולים בפלט של המפענח.", "apihelp-parse-param-generatexml": "יצירת עץ פענוח של XML (נדרש מודל תוכן $1; מוחלף ב־$2prop=parsetree).", "apihelp-parse-param-preview": "לפענח במצב תצוגה מקדימה.", "apihelp-parse-param-sectionpreview": "לפענח במצב תצוגה מקדימה של פסקה (מדליק גם את מצב תצוגה מקדימה).", @@ -470,7 +475,7 @@ "apihelp-query+allimages-param-sha1base36": "גיבוב SHA1 של התמונה בבסיס 36 (הבסיס בו נעשה שימוש במדיה־ויקי).", "apihelp-query+allimages-param-user": "להחזיר רק קבצים שהועלו על־ידי המשתמש הזה. יכול לשמש רק עם $1sort=timestamp. לא יכול לשמש יחד עם $1filterbots.", "apihelp-query+allimages-param-filterbots": "איך לסנן קבצים שמעלים בוטים. יכול לשמש רק עם $1sort=timestamp. לא יכול לשמש יחד עם $1user.", - "apihelp-query+allimages-param-mime": "אילו סוגי MIME לחבפש, למשל image/jpeg.", + "apihelp-query+allimages-param-mime": "אילו סוגי MIME לחפש, למשל image/jpeg.", "apihelp-query+allimages-param-limit": "כמה תמונות להחזיר בסך הכול.", "apihelp-query+allimages-example-B": "הצגת רשימה של קבצים שמתחילים באות B.", "apihelp-query+allimages-example-recent": "הצגת רשימת קבצים שהועלו לאחרונה, דומה ל־[[Special:NewFiles]].", @@ -637,7 +642,7 @@ "apihelp-query+categories-paramvalue-prop-hidden": "תיוג קטגוריות שהוסתרו באמצעות __HIDDENCAT__.", "apihelp-query+categories-param-show": "איזה סוג של קטגוריות להציג.", "apihelp-query+categories-param-limit": "כמה קטגוריות להחזיר.", - "apihelp-query+categories-param-categories": "לרשום רק את הקטגוריות האלו. שימושי לבדיקה עם דף מסוים נמצא בקטגוריה מסוימת.", + "apihelp-query+categories-param-categories": "לרשום רק את הקטגוריות האלו. שימושי לבדיקה אם דף מסוים נמצא בקטגוריה מסוימת.", "apihelp-query+categories-param-dir": "באיזה כיוון לרשום.", "apihelp-query+categories-example-simple": "קבלת רשימת קטגוריות שהם Albert Einstein שייך אליהן.", "apihelp-query+categories-example-generator": "קבלת מידע על כל הקטגוריות שמשמשות בדף Albert Einstein.", @@ -735,7 +740,7 @@ "apihelp-query+exturlusage-param-namespace": "איזה מרחב שם למנות.", "apihelp-query+exturlusage-param-limit": "כמה דפים להחזיר.", "apihelp-query+exturlusage-param-expandurl": "הרחבת URL־ים בעלי פרוטוקול יחסי בפרוטוקול קנוני.", - "apihelp-query+exturlusage-example-simple": "הצגת דפים שמקשרים ל־http://www.mediawiki.org.", + "apihelp-query+exturlusage-example-simple": "הצגת דפים שמקשרים ל־https://www.mediawiki.org.", "apihelp-query+filearchive-summary": "למנות את כל הקבצים המחוקים לפי הסדר.", "apihelp-query+filearchive-param-from": "מאיזו כותרת תמונה להתחיל למנות.", "apihelp-query+filearchive-param-to": "באיזו כותרת תמונה להפסיק למנות.", @@ -794,7 +799,7 @@ "apihelp-query+imageinfo-paramvalue-prop-uploadwarning": "משמש את Special:Upload כדי לקבל מידע על קובץ קיים. לא נועד לשימוש מחוץ לליבת MediaWiki.", "apihelp-query+imageinfo-paramvalue-prop-badfile": "מוסיף האם הקובץ נמצא ב־[[MediaWiki:Bad image list]]", "apihelp-query+imageinfo-param-limit": "כמה גרסאות של קובץ לכל קובץ.", - "apihelp-query+imageinfo-param-start": "מאיז חותם־זמן להתחיל רשימה.", + "apihelp-query+imageinfo-param-start": "מאילו תאריך ושעה להתחיל את הרשימה.", "apihelp-query+imageinfo-param-end": "באיזה חותם־זמן לסיים את הרשימה.", "apihelp-query+imageinfo-param-urlwidth": "אם מוגדר $2prop=url, יוחזר URL לתמונה שגודלה הותאם לרוחב הזה.\nמסיבות של ביצועים, אם האפשרות הזאת משמשת, לא יוחזרו יותר מ־$1 תמונות.", "apihelp-query+imageinfo-param-urlheight": "דומה ל־$1urlwidth.", @@ -836,6 +841,7 @@ "apihelp-query+info-paramvalue-prop-readable": "האם המשתמש יכול להציג דף זה.", "apihelp-query+info-paramvalue-prop-preload": "נותן את הטקסט שמוחזר על־ידי EditFormPreloadText.", "apihelp-query+info-paramvalue-prop-displaytitle": "נותן את האופן שבה שם הדף באמת מוצג.", + "apihelp-query+info-paramvalue-prop-varianttitles": "כותרת התצוגה בכל הגרסאות של שפת התוכן של האתר.", "apihelp-query+info-param-testactions": "בדיקה האם המשתמש הנוכחי יכול לבצע פעולות מסוימות על הדף.", "apihelp-query+info-param-token": "להשתמש ב־[[Special:ApiHelp/query+tokens|action=query&meta=tokens]] במקום.", "apihelp-query+info-example-simple": "קבלת מידע על הדף Main Page", @@ -943,7 +949,7 @@ "apihelp-query+prefixsearch-summary": "ביצוע חיפוש תחילית של כותרות דפים.", "apihelp-query+prefixsearch-extended-description": "למרות הדמיון בשם, המודול הזה אינו אמור להיות שווה ל־[[Special:PrefixIndex]] (\"מיוחד:דפים המתחילים ב\"); לדבר כזה, ר' [[Special:ApiHelp/query+allpages|action=query&list=allpages]] עם הפרמטר apprefix. מטרת המודול הזה דומה ל־[[Special:ApiHelp/opensearch|action=opensearch]]: לקבל קלט ממשתמש ולספק את הכותרות המתאימות ביותר. בהתאם לשרת מנוע החיפוש, זה יכול לכלול תיקון שגיאות כתיב, הימנעות מדפי הפניה והירסטיקות אחרות.", "apihelp-query+prefixsearch-param-search": "מחרוזת לחיפוש.", - "apihelp-query+prefixsearch-param-namespace": "שמות מתחם לחיפוש.", + "apihelp-query+prefixsearch-param-namespace": "מרחבי השם שבהם יתבצע החיפוש. לשדה זה אין משמעות אם $1search מתחיל עם תחילית תקינה של מרחב שם.", "apihelp-query+prefixsearch-param-limit": "מספר התוצאות המרבי להחזרה.", "apihelp-query+prefixsearch-param-offset": "מספר תוצאות לדילוג.", "apihelp-query+prefixsearch-example-simple": "חיפוש שםות דפים שמתחילים ב־meaning.", @@ -981,7 +987,7 @@ "apihelp-query+recentchanges-param-end": "באיזה חותם זמן להפסיק לרשום.", "apihelp-query+recentchanges-param-namespace": "לסנן את השינויים רק למרחבי השם האלה.", "apihelp-query+recentchanges-param-user": "לרשום רק שינויים של המשתמש הזה.", - "apihelp-query+recentchanges-param-excludeuser": "Don't list changes by this user", + "apihelp-query+recentchanges-param-excludeuser": "לא לרשום שינויים ממשתמש זה.", "apihelp-query+recentchanges-param-tag": "לרשום רק שינויים שמתויגים עם התג הזה.", "apihelp-query+recentchanges-param-prop": "לכלול פריטי מידע נוספים:", "apihelp-query+recentchanges-paramvalue-prop-user": "הוספת המשתמש האחראי על העריכה ותיוג אם זאת כתובת IP.", @@ -995,6 +1001,7 @@ "apihelp-query+recentchanges-paramvalue-prop-sizes": "הוספת אורך הדף החדש והישן בבייטים.", "apihelp-query+recentchanges-paramvalue-prop-redirect": "מתייג שהדף הוא הפניה.", "apihelp-query+recentchanges-paramvalue-prop-patrolled": "מתייג עריכה בת־בדיקה בתור בדוקה או בלתי־בדוקה.", + "apihelp-query+recentchanges-paramvalue-prop-autopatrolled": "ציון האם עריכות הניתנות לבדיקה נבדקו אוטומטית או לא.", "apihelp-query+recentchanges-paramvalue-prop-loginfo": "הוספת מידע יומן (זהה יומן, סוג יומן וכו') לעיולי יומן.", "apihelp-query+recentchanges-paramvalue-prop-tags": "רשימת תגים עבור העיול.", "apihelp-query+recentchanges-paramvalue-prop-sha1": "הוספת סיכום־ביקורת תוכן לעיולים שמשויכים לגרסה.", @@ -1074,6 +1081,7 @@ "apihelp-query+search-paramvalue-prop-sectiontitle": "הוספת שם הפסקה התואמת.", "apihelp-query+search-paramvalue-prop-categorysnippet": "הוספת קטע קצר מפוענח של הקטגוריה התואמת.", "apihelp-query+search-paramvalue-prop-isfilematch": "הוספת בוליאני שמציין אם החיפוש תאם לתוכן של קובץ.", + "apihelp-query+search-paramvalue-prop-extensiondata": "הוספת נתונים נוספים שנוצרים על־ידי הרחבות.", "apihelp-query+search-paramvalue-prop-score": "חסר־השפעה.", "apihelp-query+search-paramvalue-prop-hasrelated": "חסר־השפעה.", "apihelp-query+search-param-limit": "כמה דפים להחזיר בסך הכול.", @@ -1090,7 +1098,7 @@ "apihelp-query+siteinfo-paramvalue-prop-namespacealiases": "רשימת כינויי מרחבי שם רשומים.", "apihelp-query+siteinfo-paramvalue-prop-specialpagealiases": "רשימת כינויים דפים מיוחדים.", "apihelp-query+siteinfo-paramvalue-prop-magicwords": "רשימות מילות קסם וכינוייהן.", - "apihelp-query+siteinfo-paramvalue-prop-statistics": "החזרזת סטטיסטיקות אתר.", + "apihelp-query+siteinfo-paramvalue-prop-statistics": "החזרת סטטיסטיקות של האתר.", "apihelp-query+siteinfo-paramvalue-prop-interwikimap": "החזרת מפת בינוויקי (אפשר שתהיה מסוננת, אפשר שתהיה מותאמת מקומית באמצעות $1inlanguagecode).", "apihelp-query+siteinfo-paramvalue-prop-dbrepllag": "החזרת שרת מסד־נתונים עם שיהוי השכפול הגבוה ביותר.", "apihelp-query+siteinfo-paramvalue-prop-usergroups": "החזרת קבוצות משתמשים וההרשאות המשויכות.", @@ -1172,6 +1180,7 @@ "apihelp-query+usercontribs-paramvalue-prop-sizediff": "הוספת ההפרש של העריכה אל מול ההורה שלה.", "apihelp-query+usercontribs-paramvalue-prop-flags": "הוספת הדגלים של העריכה.", "apihelp-query+usercontribs-paramvalue-prop-patrolled": "מתייג עריכות בדוקות.", + "apihelp-query+usercontribs-paramvalue-prop-autopatrolled": "תיוג עריכות שנבדקו אוטומטית.", "apihelp-query+usercontribs-paramvalue-prop-tags": "רשימת תגים עבור עריכות.", "apihelp-query+usercontribs-param-show": "הצגה רק של פריטים שמתאימים לאמות המידה האלה, למשל רק עריכות לא־משניות.\n\nאם מוגדר $2show=patrolled או $2show=!patrolled, גרסאות ישנות מ־[[mw:Special:MyLanguage/Manual:$wgRCMaxAge|$wgRCMaxAge]]‏ ({{PLURAL:$1|שנייה אחת|$1 שניות}}) לא תוצגנה.", "apihelp-query+usercontribs-param-tag": "לרשום רק גרסאות עם התג הזה.", @@ -1224,7 +1233,7 @@ "apihelp-query+watchlist-param-end": "באיזה חותם זמן להפסיק לרשום.", "apihelp-query+watchlist-param-namespace": "סינון שינויים רק למרחבי השם שניתנו.", "apihelp-query+watchlist-param-user": "לרשום רק שינויים של המשתמש הזה.", - "apihelp-query+watchlist-param-excludeuser": "Don't list changes by this user", + "apihelp-query+watchlist-param-excludeuser": "לא לרשום שינויים ממשתמש זה.", "apihelp-query+watchlist-param-limit": "כמה תוצאות סך הכול להחזיר בכל בקשה.", "apihelp-query+watchlist-param-prop": "אילו מאפיינים נוספים לקבל:", "apihelp-query+watchlist-paramvalue-prop-ids": "הוספת מזהי גסה ומזהי דף.", @@ -1236,9 +1245,11 @@ "apihelp-query+watchlist-paramvalue-prop-parsedcomment": "הוספת ההערכה המפוענחת של העריכה.", "apihelp-query+watchlist-paramvalue-prop-timestamp": "הוספת חותם־זמן של העריכה.", "apihelp-query+watchlist-paramvalue-prop-patrol": "תיוג עריכות שנבדקו.", + "apihelp-query+watchlist-paramvalue-prop-autopatrol": "תיוג עריכות המסומנות כבדוקות באופן אוטומטי.", "apihelp-query+watchlist-paramvalue-prop-sizes": "הוספת האורך החדש והישן של הדף.", "apihelp-query+watchlist-paramvalue-prop-notificationtimestamp": "הוספת חותם־זמן של ההודעה האחרונה למשתמש על העריכה.", "apihelp-query+watchlist-paramvalue-prop-loginfo": "הוספת מידע מהיומן איפה שמתאים.", + "apihelp-query+watchlist-paramvalue-prop-tags": "רשימת תגיות עבור הפעולה.", "apihelp-query+watchlist-param-show": "הצגה רק של פריטים שמתאימים לאמות המידה האלו. למשל, כדי לראות רק עריכות משניות שעשו משתמשים שנכנסו לחשבון, יש להגדיר $1show=minor|!anon.", "apihelp-query+watchlist-param-type": "אולי סוגי שינויים להציג:", "apihelp-query+watchlist-paramvalue-type-edit": "עריכות דף רגילות.", @@ -1291,7 +1302,7 @@ "apihelp-rollback-param-title": "שם הדף לשחזור. לא יכול לשמש יחד עם $1pageid.", "apihelp-rollback-param-pageid": "מזהה הדף לשחזור. לא יכול לשמש יחד עם $1title.", "apihelp-rollback-param-tags": "אילו תגים להחיל על השחזור.", - "apihelp-rollback-param-user": "שם המשתמשים שהעריכות שלו תשוחזרנה.", + "apihelp-rollback-param-user": "שם המשתמש שהעריכות שלו תשוחזרנה.", "apihelp-rollback-param-summary": "תקציר עריכה מותאם. אם ריק, ישמש תקציר לפי בררת מחדל.", "apihelp-rollback-param-markbot": "לסמן את העריכות ששוחזרו ואת השחזור בתור עריכות בוט.", "apihelp-rollback-param-watchlist": "הוספה או הסרה של הדף ללא תנאי מרשימת המעקב של המשתמש הנוכחי, להשתמש בהעדפות או לא לשנות את המעקב.", @@ -1309,8 +1320,8 @@ "apihelp-setnotificationtimestamp-example-page": "אתחול מצב ההודעה עבור Main Page.", "apihelp-setnotificationtimestamp-example-pagetimestamp": "הגדרת חותם־הזמן להודעה ל־Main page כך שכל העריכות מאז 1 בינואר 2012 מוגדרות בתור כאלה שלא נצפו.", "apihelp-setnotificationtimestamp-example-allpages": "אתחול מצב ההודעה עבור דפים במרחב השם {{ns:user}}.", - "apihelp-setpagelanguage-summary": "שנה את השפה של דף", - "apihelp-setpagelanguage-extended-description-disabled": "שינוי השפה של דף לא מורשה בוויקי זה.\n\nהפעל את [[mw:Special:MyLanguage/Manual:$wgPageLanguageUseDB|$wgPageLanguageUseDB]] על מנת להשתמש בפעולה זו", + "apihelp-setpagelanguage-summary": "שינוי השפה של דף.", + "apihelp-setpagelanguage-extended-description-disabled": "לא ניתן לשנות שפות של דפים באתר הוויקי הזה.\n\nיש להפעיל את [[mw:Special:MyLanguage/Manual:$wgPageLanguageUseDB|$wgPageLanguageUseDB]] על־מנת להשתמש בפעולה זו.", "apihelp-setpagelanguage-param-title": "כותרת הדף שאת שפתו ברצונך לשנות. לא אפשרי להשתמש באפשרות עם $1pageid.", "apihelp-setpagelanguage-param-pageid": "מזהה הדף שאת שפתו ברצונך לשנות. לא אפשרי להשתמש באפשרות עם $1title.", "apihelp-setpagelanguage-param-lang": "קוד השפה של השפה שאליה צריך לשנות את הדף. יש להשתמש ב־default כדי לאתחל את הדף לשפת בררת המחדל של הוויקי.", @@ -1424,7 +1435,7 @@ "apihelp-phpfm-summary": "לפלוט נתונים בתסדיר PHP מוסדר (עם הדפסה יפה ב־HTML).", "apihelp-rawfm-summary": "לפלוט את הנתונים, כולל אלמנטים לניפוי שגיאות, בתסדיר JSON (עם הדפסה יפה ב־HTML).", "apihelp-xml-summary": "לפלוט נתונים בתסדיר XML.", - "apihelp-xml-param-xslt": "אם צוין, יש להוסיף את שם הדף כגיליון עיצוב XSL. על הערך להיות כותרת ב {{ns:MediaWiki}} במרחב שם המשתמש, המסתיים ב- .xsl.", + "apihelp-xml-param-xslt": "אם צוין, הדף יתווסף כגיליון XSL. הערך חייב להיות כותרת במרחב השם \"{{ns:MediaWiki}}\" שמסתיימת ב־.xsl.", "apihelp-xml-param-includexmlnamespace": "אם זה צוין, מוסיף מרחב שם של XML.", "apihelp-xmlfm-summary": "לפלוט נתונים בתסדיר XML (עם הדפסה יפה ב־HTML).", "api-format-title": "תוצאה של API של מדיה־ויקי", @@ -1472,9 +1483,9 @@ "api-help-param-list-can-be-empty": "{{PLURAL:$1|0=חייב להיות ריק|יכול להיות ריק או $2}}", "api-help-param-limit": "מספר הפרמטרים לא יכול להיות גדול מ־$1.", "api-help-param-limit2": "המספר המרבי המותר הוא $1 (עבור בוטים – $2).", - "api-help-param-integer-min": "ה{{PLURAL:$1|1=ערך|2=ערכים}} לא יכולים להיות קטנים מ־$2.", - "api-help-param-integer-max": "ה{{PLURAL:$1|1=ערך לא יכול להיות גדול|2=ערכים לא יכולים להיות גדולים}} מ־$3.", - "api-help-param-integer-minmax": "ה{{PLURAL:$1|1=ערך חייב|2=ערכים חייבים}} להיות בין $2 ל־$3.", + "api-help-param-integer-min": "{{PLURAL:$1|1=הערך לא יכול להיות קטן|2=הערכים לא יכולים להיות קטנים}} מ־$2.", + "api-help-param-integer-max": "{{PLURAL:$1|1=הערך לא יכול להיות גדול|2=הערכים לא יכולים להיות גדולים}} מ־$3.", + "api-help-param-integer-minmax": "{{PLURAL:$1|1=הערך חייב|2=הערכים חייבים}} להיות בין $2 ל־$3.", "api-help-param-upload": "חייב להישלח (posted) בתור העלאת קובץ באמצעות multipart/form-data.", "api-help-param-multi-separate": "הפרדה בין ערכים נעשית באמצעות | או [[Special:ApiHelp/main#main/datatypes|תו חלופי]].", "api-help-param-multi-max": "מספר הערכים המרבי הוא {{PLURAL:$1|$1}} (עבור בוטים – {{PLURAL:$2|$2}}).", @@ -1547,12 +1558,14 @@ "apierror-cantimport-upload": "אין לך הרשאה לייבא דפים מוּעלים.", "apierror-cantimport": "אין לך הרשאה לייבא דפים.", "apierror-cantoverwrite-sharedfile": "קובץ היעד קיים במאגר משותף ואין לך הרשאה לעקוף אותו.", - "apierror-cantsend": "לא נכנסת לחשבון, אין לך חשבון דואר אלקטרוני מאושר, או שאסור לך לשלוח דואר אלקטרוני למשתמשים אחרים, אז אינך לך אפשרות לשלוח דואר אלקטרוני.", + "apierror-cantsend": "לא נכנסת לחשבון, אין לך חשבון דואר אלקטרוני מאושר, או שאסור לך לשלוח דואר אלקטרוני למשתמשים אחרים, ולכן אין לך אפשרות לשלוח דואר אלקטרוני.", "apierror-cantundelete": "לא היה אפשר לשחזר ממחיקה: אולי הגרסאות המבוקשות אינן קיימות, ואולי הן כבר נמחקו.", "apierror-changeauth-norequest": "יצירת בקשת השינוי נכשלה.", "apierror-chunk-too-small": "גודל הפלח המזערי הוא {{PLURAL:$1|בית אחד|$1 בתים}} בשביל פלחים לא סופיים.", "apierror-cidrtoobroad": "טווחי CIDR של $1 שרחבים יותר מ־/$2 אינם קבילים.", "apierror-compare-no-title": "לא ניתן לעשות התמרה לפני שמירה ללא כותרת. נא לנסות לציין fromtitle או totitle.", + "apierror-compare-nosuchfromsection": "הפסקה $1 אינה קיימת בתוכן של 'from'.", + "apierror-compare-nosuchtosection": "הפסקה $1 אינה קיימת בתוכן של 'to'.", "apierror-compare-relative-to-nothing": "אין גרסת \"from\" עבור torelative שתהיה יחסית.", "apierror-contentserializationexception": "הסדרת התוכן נכשלה: $1", "apierror-contenttoobig": "התוכן שסיפקת חורג מגודל הערך המרבי של {{PLURAL:$1|קילובייט אחד|$1 קילובייטים}}.", @@ -1586,13 +1599,15 @@ "apierror-invalidparammix-mustusewith": "הפרמטר $1 יכול לשמש רק עם $2.", "apierror-invalidparammix-parse-new-section": "לא ניתן לשלב את section=new עם הפרמטרים oldid‏, pageid או page. נא להשתמש ב־title ו־text.", "apierror-invalidparammix": "{{PLURAL:$2|הפרמטרים}} $1 אינם יכולים לשמש יחדיו.", - "apierror-invalidsection": "הפרמטר section להיות מזהה מקטע תקין או new.", + "apierror-invalidsection": "הפרמטר section חייב להיות מזהה מקטע תקין או new.", "apierror-invalidsha1base36hash": "גיבוב ה־SHA1Base36 שסופק אינו תקין.", "apierror-invalidsha1hash": "גיבוב ה־SHA1 שסופק אינו תקין.", "apierror-invalidtitle": "כותרת רעה \"$1\".", "apierror-invalidurlparam": "ערך בלתי־תקין עבור $1urlparam (ערך: $2=$3).", "apierror-invaliduser": "שם משתמש בלתי־תקין \"$1\".", "apierror-invaliduserid": "מזהה המשתמש $1 אינו תקין.", + "apierror-maxbytes": "הפרמטר $1 לא יכול להיות ארוך יותר {{PLURAL:$2|מבייט אחד|מ־$2 בייטים}}", + "apierror-maxchars": "הפרמטר $1 לא יכול להיות ארוך יותר {{PLURAL:$2|מתו אחד|מ־$2 תווים}}", "apierror-maxlag-generic": "ממתין לשרת מסד נתונים: עיכוב של {{PLURAL:$1|שנייה אחת|$1 שניות}}.", "apierror-maxlag": "ממתין ל־$2: שיהוי של {{PLURAL:$1|שנייה אחת|$1 שניות}}.", "apierror-mimesearchdisabled": "חיפוש MIME כבוי במצב קמצן.", @@ -1608,7 +1623,6 @@ "apierror-missingtitle-byname": "הדף $1 אינו קיים.", "apierror-moduledisabled": "המודול $1 כובה.", "apierror-multival-only-one-of": "{{PLURAL:$3|רק הערך|רק אחד מתוך הערכים}} $2 מותר עבור הפרמטר $1.", - "apierror-multival-only-one": "רק ערך אחד מותר עבור הפרמטר $1.", "apierror-multpages": "$1 יכול לשמש רק בדף בודד.", "apierror-mustbeloggedin-changeauth": "יש להיכנס לחשבון כדי לשנות נתוני אימות.", "apierror-mustbeloggedin-generic": "חובה להיכנס.", @@ -1683,7 +1697,7 @@ "apierror-stashnosuchfilekey": "אין מפתח קובץ כזה: $1.", "apierror-stashpathinvalid": "מפתח קובץ מתסדיר בלתי־הולם או בלתי־תקין באופן אחר: $1.", "apierror-stashwrongowner": "בעלים בלתי־תקין: $1", - "apierror-stashzerolength": "קובץ באורך אפס, ואל יכול משוחזר בסליק: $1.", + "apierror-stashzerolength": "קובץ באורך אפס, ולא יכול להיות משוחזר בסליק: $1.", "apierror-systemblocked": "נחסמת אוטומטית על־ידי מדיה־ויקי.", "apierror-templateexpansion-notwikitext": "הרחבת תבניות נתמכת רק בתוכן קוד ויקי (wikitext). $1 משתמש במודל התוכן $2.", "apierror-timeout": "השרת לא השיב בזמן המצופה.", diff --git a/includes/api/i18n/hu.json b/includes/api/i18n/hu.json index f6f813d452..4451f194e9 100644 --- a/includes/api/i18n/hu.json +++ b/includes/api/i18n/hu.json @@ -636,7 +636,7 @@ "apihelp-query+exturlusage-param-protocol": "Az URL protokollja. Ha üres és az $1query paraméter meg van adva, a protokoll http. Hagyd ezt és az $1query paramétert is üresen az összes külső link listázásához.", "apihelp-query+exturlusage-param-namespace": "A listázandó névtér.", "apihelp-query+exturlusage-param-limit": "A visszaadandó lapok száma.", - "apihelp-query+exturlusage-example-simple": "A http://www.mediawiki.org URL-re hivatkozó lapok megjelenítése.", + "apihelp-query+exturlusage-example-simple": "A https://www.mediawiki.org URL-re hivatkozó lapok megjelenítése.", "apihelp-query+filearchive-summary": "Az összes törölt fájl visszaadása.", "apihelp-query+filearchive-param-from": "A fájlok listázása ettől a címtől.", "apihelp-query+filearchive-param-to": "A fájlok listázása eddig a címig.", diff --git a/includes/api/i18n/ja.json b/includes/api/i18n/ja.json index 314552384d..84e10464d6 100644 --- a/includes/api/i18n/ja.json +++ b/includes/api/i18n/ja.json @@ -45,6 +45,7 @@ "apihelp-block-param-tags": "ブロック記録の項目に適用する変更タグ。", "apihelp-block-example-ip-simple": "IPアドレス 192.0.2.5 を First strike という理由で3日ブロックする", "apihelp-block-example-user-complex": "利用者 Vandal を Vandalism という理由で無期限ブロックし、新たなアカウント作成とメールの送信を禁止する。", + "apihelp-changeauthenticationdata-summary": "現在の利用者の認証データを変更します。", "apihelp-changeauthenticationdata-example-password": "現在の利用者のパスワードを ExamplePassword に変更する。", "apihelp-checktoken-summary": "[[Special:ApiHelp/query+tokens|action=query&meta=tokens]] のトークンの妥当性を確認します。", "apihelp-checktoken-param-type": "調べるトークンの種類。", @@ -53,17 +54,33 @@ "apihelp-checktoken-example-simple": "csrf トークンの妥当性を調べる。", "apihelp-clearhasmsg-summary": "現在の利用者の hasmsg フラグを消去します。", "apihelp-clearhasmsg-example-1": "現在の利用者の hasmsg フラグを消去する。", + "apihelp-clientlogin-summary": "インタラクティブフローを使用してウィキにログインします。", "apihelp-clientlogin-example-login": "利用者 Example としてのログイン処理をパスワード ExamplePassword で開始する", + "apihelp-clientlogin-example-login2": "987654のOATHTokenを提供する2段階認証のUIレスポンスの後にログインを続けます。", "apihelp-compare-summary": "2つの版間の差分を取得します。", "apihelp-compare-extended-description": "\"from\" と \"to\" の両方の版番号、ページ名、もしくはページIDを渡す必要があります。", "apihelp-compare-param-fromtitle": "比較する1つ目のページ名。", "apihelp-compare-param-fromid": "比較する1つ目のページID。", "apihelp-compare-param-fromrev": "比較する1つ目の版。", + "apihelp-compare-param-frompst": "fromtextに保存前変換を行います。", + "apihelp-compare-param-fromcontentmodel": "fromtextのコンテンツモデル。指定されていない場合は、他のパラメータに基づいて推測されます。", "apihelp-compare-param-totitle": "比較する2つ目のページ名。", "apihelp-compare-param-toid": "比較する2つ目のページID。", "apihelp-compare-param-torev": "比較する2つ目の版。", + "apihelp-compare-param-topst": "totextに保存前変換を行います。", + "apihelp-compare-param-prop": "どの情報を取得するか:", + "apihelp-compare-paramvalue-prop-diff": "差分HTML。", + "apihelp-compare-paramvalue-prop-diffsize": "差分HTMLのサイズ (バイト数)。", + "apihelp-compare-paramvalue-prop-rel": "存在する場合、'from' の直前、および 'to' の直後の版ID。", + "apihelp-compare-paramvalue-prop-ids": "'from' および 'to' の版のページIDおよび版ID。", + "apihelp-compare-paramvalue-prop-title": "'from' および 'to' の版のページ名。", + "apihelp-compare-paramvalue-prop-user": "'from' および 'to' の版の投稿者の利用者名およびID。", + "apihelp-compare-paramvalue-prop-comment": "'from' および 'to' の版のコメント。", + "apihelp-compare-paramvalue-prop-parsedcomment": "'from' および 'to' の版の整形済みコメント。", + "apihelp-compare-paramvalue-prop-size": "'from' および 'to' の版のサイズ。", "apihelp-compare-example-1": "版1と2の差分を生成する。", "apihelp-createaccount-summary": "新しい利用者アカウントを作成します。", + "apihelp-createaccount-example-create": "利用者 Example を作成する処理をパスワード ExamplePassword で開始する", "apihelp-createaccount-param-name": "利用者名。", "apihelp-createaccount-param-password": "パスワード ($1mailpassword が設定されると無視されます)。", "apihelp-createaccount-param-domain": "外部認証のドメイン (省略可能)。", @@ -75,6 +92,7 @@ "apihelp-createaccount-param-language": "利用者の言語コードの既定値 (省略可能, 既定ではコンテンツ言語)。", "apihelp-createaccount-example-pass": "利用者 testuser をパスワード test123 として作成する。", "apihelp-createaccount-example-mail": "利用者 testmailuserを作成し、無作為に生成されたパスワードをメールで送る。", + "apihelp-cspreport-param-source": "このレポートをトリガしたCSPヘッダを生成した内容", "apihelp-delete-summary": "ページを削除します。", "apihelp-delete-param-title": "削除するページ名です。$1pageid とは同時に使用できません。", "apihelp-delete-param-pageid": "削除するページIDです。$1title とは同時に使用できません。", @@ -99,6 +117,7 @@ "apihelp-edit-param-bot": "この編集をボットの編集としてマークする。", "apihelp-edit-param-basetimestamp": "編集前の版のタイムスタンプ。編集競合を検出するために使用されます。\n[[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]] で取得できます。", "apihelp-edit-param-starttimestamp": "編集作業を開始したときのタイムスタンプ。編集競合を検出するために使用されます。適切な値は [[Special:ApiHelp/main|curtimestamp]] を使用して編集作業を開始するとき (たとえば、編集するページの本文を読み込んだとき) に取得できます。", + "apihelp-edit-param-recreate": "その間に削除されたページに関するエラーを上書きします。", "apihelp-edit-param-createonly": "すでにそのページが存在する場合は編集を行いません。", "apihelp-edit-param-nocreate": "そのページが存在しない場合にエラーを返します。", "apihelp-edit-param-watch": "そのページを現在の利用者のウォッチリストに追加します。", @@ -108,6 +127,7 @@ "apihelp-edit-param-undo": "この版を取り消します。$1text, $1prependtext および $1appendtext をオーバーライドします。", "apihelp-edit-param-undoafter": "$1undo からこの版までのすべての版を取り消します。設定しない場合、ひとつの版のみ取り消されます。", "apihelp-edit-param-redirect": "自動的に転送を解決します。", + "apihelp-edit-param-contentmodel": "新しいコンテンツのコンテンツ・モデル。", "apihelp-edit-param-token": "このトークンは常に最後のパラメーターとして、または少なくとも $1text パラメーターより後に送信されるべきです。", "apihelp-edit-example-edit": "ページを編集", "apihelp-edit-example-prepend": "__NOTOC__ をページの先頭に挿入する。", @@ -122,6 +142,8 @@ "apihelp-expandtemplates-param-title": "ページの名前です。", "apihelp-expandtemplates-param-text": "変換するウィキテキストです。", "apihelp-expandtemplates-paramvalue-prop-wikitext": "展開されたウィキテキスト。", + "apihelp-expandtemplates-paramvalue-prop-jsconfigvars": "ページに固有のJavaScriptの設定変数を提供します。", + "apihelp-expandtemplates-paramvalue-prop-encodedjsconfigvars": "JSON文字列としてページに固有のJavaScriptの設定変数を提供します。", "apihelp-expandtemplates-paramvalue-prop-parsetree": "入力のXML構文解析ツリー。", "apihelp-expandtemplates-param-includecomments": "HTMLコメントを出力に含めるかどうか。", "apihelp-expandtemplates-param-generatexml": "XMLの構文解析ツリーを生成します (replaced by $1prop=parsetree)", @@ -144,6 +166,7 @@ "apihelp-feedrecentchanges-param-namespace": "この名前空間の結果のみに絞り込む。", "apihelp-feedrecentchanges-param-invert": "選択されたものを除く、すべての名前空間。", "apihelp-feedrecentchanges-param-associated": "関連する(トークまたはメイン)名前空間を含めます。", + "apihelp-feedrecentchanges-param-days": "結果を絞り込む日数。", "apihelp-feedrecentchanges-param-limit": "返す結果の最大数。", "apihelp-feedrecentchanges-param-from": "これ以降の編集を表示する。", "apihelp-feedrecentchanges-param-hideminor": "細部の変更を隠す。", @@ -152,8 +175,10 @@ "apihelp-feedrecentchanges-param-hideliu": "登録利用者による変更を隠す。", "apihelp-feedrecentchanges-param-hidepatrolled": "巡回済みの変更を隠す。", "apihelp-feedrecentchanges-param-hidemyself": "現在の利用者による編集を非表示にする。", + "apihelp-feedrecentchanges-param-hidecategorization": "カテゴリのメンバーの変更を非表示にする。", "apihelp-feedrecentchanges-param-tagfilter": "タグにより絞り込む。", "apihelp-feedrecentchanges-param-target": "このページからリンクされているページの変更のみを表示する。", + "apihelp-feedrecentchanges-param-showlinkedto": "選択したページへのリンク元での変更の表示に切り替え", "apihelp-feedrecentchanges-example-simple": "最近の更新を表示する。", "apihelp-feedrecentchanges-example-30days": "最近30日間の変更を表示する。", "apihelp-feedwatchlist-summary": "ウォッチリストのフィードを返します。", @@ -178,12 +203,14 @@ "apihelp-help-example-query": "2つの下位モジュールのヘルプ", "apihelp-imagerotate-summary": "1つ以上の画像を回転させます。", "apihelp-imagerotate-param-rotation": "画像を回転させる時計回りの角度。", + "apihelp-imagerotate-param-tags": "アップロード記録の項目に適用するタグ。", "apihelp-imagerotate-example-simple": "File:Example.png を 90 度回転させる。", "apihelp-imagerotate-example-generator": "Category:Flip 内のすべての画像を 180 度回転させる。", "apihelp-import-summary": "他のWikiまたはXMLファイルからページを取り込む。", "apihelp-import-extended-description": "xml パラメーターでファイルを送信する場合、ファイルのアップロードとしてHTTP POSTされなければならない (例えば、multipart/form-dataを使用する) 点に注意してください。", "apihelp-import-param-summary": "記録されるページ取り込みの要約。", "apihelp-import-param-xml": "XMLファイルをアップロード", + "apihelp-import-param-assignknownusers": "指定されたユーザーがこのウィキに存在する場合そのユーザーに編集を割り当てる", "apihelp-import-param-interwikisource": "ウィキ間の取り込みの場合: 取り込み元のウィキ。", "apihelp-import-param-interwikipage": "ウィキ間の取り込みの場合: 取り込むページ。", "apihelp-import-param-fullhistory": "ウィキ間の取り込みの場合: 現在の版のみではなく完全な履歴を取り込む。", @@ -192,7 +219,7 @@ "apihelp-import-param-rootpage": "このページの下位ページとして取り込む。$1namespace パラメータとは同時に使用できません。", "apihelp-import-example-import": "[[meta:Help:ParserFunctions]] をすべての履歴とともに名前空間100に取り込む。", "apihelp-login-summary": "ログインして認証クッキーを取得します。", - "apihelp-login-extended-description": "ログインが成功した場合、必要なクッキーは HTTP 応答ヘッダに含まれます。ログインに失敗した場合、自動化のパスワード推定攻撃を制限するために、追加の試行は速度制限されることがあります。", + "apihelp-login-extended-description": "このアクションは、[[Special:BotPasswords]]と組み合わせて使用する必要があります。メインアカウントのログインに使用することは推奨されなくなり、警告なく失敗する可能性があります。メインアカウントに安全にログインするには、[[Special:ApiHelp/clientlogin|action=clientlogin]]を使用します。", "apihelp-login-param-name": "利用者名。", "apihelp-login-param-password": "パスワード。", "apihelp-login-param-domain": "ドメイン (省略可能)", @@ -205,6 +232,7 @@ "apihelp-managetags-param-tag": "作成、削除、有効化、または無効化するタグ。タグの作成の場合、そのタグは存在しないものでなければなりません。タグの削除の場合、そのタグが存在しなければなりません。タグの有効化の場合、そのタグが存在し、かつ拡張機能によって使用されていないものでなければなりません。タグの無効化の場合、そのタグが現在有効であって手動で定義されたものでなければなりません。", "apihelp-managetags-param-reason": "タグを作成、削除、有効化、または無効化する追加の理由。", "apihelp-managetags-param-ignorewarnings": "操作中に発生したすべての警告を無視するかどうか。", + "apihelp-managetags-param-tags": "タグを変更し、タグ管理記録の項目に適用します。", "apihelp-managetags-example-create": "spam という名前のタグを For use in edit patrolling という理由で作成する", "apihelp-managetags-example-delete": "vandlaism タグを Misspelt という理由で削除する", "apihelp-managetags-example-activate": "spam という名前のタグを For use in edit patrolling という理由で有効化する", @@ -284,8 +312,8 @@ "apihelp-parse-param-disableeditsection": "構文解析の出力で節リンクを省略する。", "apihelp-parse-param-disabletidy": "構文解析の出力にHTMLのクリーンアップ (例えば整頓) を適用しない。", "apihelp-parse-param-preview": "プレビューモードでのパース", - "apihelp-parse-example-page": "ページをパース", - "apihelp-parse-example-text": "ウィキテキストをパース", + "apihelp-parse-example-page": "ページを構文解析する。", + "apihelp-parse-example-text": "ウィキテキストを構文解析", "apihelp-parse-example-summary": "要約を構文解析します。", "apihelp-patrol-summary": "ページまたは版を巡回済みにする。", "apihelp-patrol-param-rcid": "巡回済みにする最近の更新ID。", @@ -308,6 +336,7 @@ "apihelp-purge-param-forcelinkupdate": "リンクテーブルを更新します。", "apihelp-purge-example-simple": "ページ Main Page および API をパージする。", "apihelp-purge-example-generator": "標準名前空間にある最初の10ページをパージする。", + "apihelp-query-summary": "MediaWikiからデータを取得します。", "apihelp-query-param-prop": "照会ページ用に、どのプロパティを取得するか。", "apihelp-query-param-list": "どの一覧を取得するか。", "apihelp-query-param-meta": "どのメタデータを取得するか。", @@ -346,6 +375,7 @@ "apihelp-query+allfileusages-param-from": "列挙を開始するファイルのページ名。", "apihelp-query+allfileusages-param-to": "列挙を終了するファイルのページ名。", "apihelp-query+allfileusages-param-prefix": "この値で始まるページ名のすべてのファイルを検索する。", + "apihelp-query+allfileusages-param-unique": "ファイル名を一度だけ表示します。$1prop=ids とは同時に使用できません。ジェネレーターとして使用される場合、リンク元ではなくリンク先のページを生成します。", "apihelp-query+allfileusages-param-prop": "どの情報を結果に含めるか:", "apihelp-query+allfileusages-paramvalue-prop-ids": "使用しているページのページIDを追加します ($1unique とは同時に使用できません)。", "apihelp-query+allfileusages-paramvalue-prop-title": "ファイルのページ名を追加します。", @@ -402,6 +432,7 @@ "apihelp-query+allpages-param-to": "列挙を終了するページ名。", "apihelp-query+allpages-param-prefix": "この値で始まるすべてのページ名を検索します。", "apihelp-query+allpages-param-namespace": "列挙する名前空間。", + "apihelp-query+allpages-param-filterredir": "リストするページ", "apihelp-query+allpages-param-minsize": "ページの最低バイト数を制限する。", "apihelp-query+allpages-param-maxsize": "ページの最大バイト数を制限する。", "apihelp-query+allpages-param-prtype": "保護されているページに絞り込む。", @@ -417,11 +448,15 @@ "apihelp-query+allredirects-param-prefix": "この値で始まるすべてのページを検索する。", "apihelp-query+allredirects-param-unique": "転送先ページ名を一度だけ表示します。$1prop=ids|fragment|interwiki とは同時に使用できません。ジェネレーターとして使用される場合、転送元ではなく転送先のページを生成します。", "apihelp-query+allredirects-param-prop": "どの情報を結果に含めるか:", + "apihelp-query+allredirects-paramvalue-prop-ids": "転送ページのページIDを追加します ($1unique とは同時に使用できません)。", "apihelp-query+allredirects-paramvalue-prop-title": "転送ページのページ名を追加します。", "apihelp-query+allredirects-param-namespace": "列挙する名前空間。", "apihelp-query+allredirects-param-limit": "返す項目の総数。", "apihelp-query+allredirects-param-dir": "一覧表示する方向。", "apihelp-query+allredirects-example-B": "B で始まる転送先ページ (存在しないページも含む)を、転送元のページIDとともに表示する。", + "apihelp-query+allredirects-example-unique": "一意のターゲットページを一覧表示します。", + "apihelp-query+allredirects-example-unique-generator": "存在しないものに印をつけて、すべて取得する。", + "apihelp-query+allredirects-example-generator": "リダイレクトを含むページを取得します。", "apihelp-query+allrevisions-summary": "すべての版を一覧表示する。", "apihelp-query+allrevisions-param-start": "列挙の始点となるタイムスタンプ。", "apihelp-query+allrevisions-param-end": "列挙の終点となるタイムスタンプ。", @@ -566,7 +601,7 @@ "apihelp-query+exturlusage-param-query": "プロトコルを除いた検索文字列。[[Special:LinkSearch]] も参照してください。すべての外部リンクを一覧表示するには空欄にしてください。", "apihelp-query+exturlusage-param-namespace": "列挙するページ名前空間。", "apihelp-query+exturlusage-param-limit": "返すページの数。", - "apihelp-query+exturlusage-example-simple": "http://www.mediawiki.org にリンクしているページを一覧表示する。", + "apihelp-query+exturlusage-example-simple": "https://www.mediawiki.org にリンクしているページを一覧表示する。", "apihelp-query+filearchive-summary": "削除されたファイルをすべて順に列挙します。", "apihelp-query+filearchive-param-from": "列挙の始点となる画像のページ名。", "apihelp-query+filearchive-param-to": "列挙の終点となる画像のページ名。", @@ -701,6 +736,7 @@ "apihelp-query+prefixsearch-param-namespace": "検索する名前空間。$1searchが有効な名前空間接頭辞で始まる場合は無視されます。", "apihelp-query+prefixsearch-param-limit": "返す結果の最大数。", "apihelp-query+prefixsearch-example-simple": "meaning で始まるページ名を検索する。", + "apihelp-query+prefixsearch-param-profile": "使用するプロファイルを検索します。", "apihelp-query+protectedtitles-summary": "作成保護が掛けられているページを一覧表示します。", "apihelp-query+protectedtitles-param-namespace": "この名前空間に含まれるページのみを一覧表示します。", "apihelp-query+protectedtitles-param-level": "この保護レベルのページのみを一覧表示します。", @@ -714,6 +750,7 @@ "apihelp-query+querypage-param-page": "特別ページの名前です。これは大文字小文字を区別することに注意。", "apihelp-query+querypage-param-limit": "返す結果の数。", "apihelp-query+querypage-example-ancientpages": "[[Special:Ancientpages]] の結果を返す。", + "apihelp-query+random-summary": "ランダムなページのセットを取得します。", "apihelp-query+random-param-namespace": "この名前空間にあるページのみを返します。", "apihelp-query+random-param-limit": "返す無作為なページの数を制限する。", "apihelp-query+random-param-redirect": "代わりに $1filterredir=redirects を使用してください。", @@ -736,6 +773,7 @@ "apihelp-query+recentchanges-paramvalue-prop-redirect": "編集されたページが転送ページである場合、印を付けます。", "apihelp-query+recentchanges-paramvalue-prop-patrolled": "巡回可能な編集について、巡回済みかどうか印を付けます。", "apihelp-query+recentchanges-paramvalue-prop-loginfo": "記録項目に記録の情報 (記録ID, 記録タイプなど) を追加します。", + "apihelp-query+recentchanges-paramvalue-prop-tags": "エントリのタグを一覧表示します。", "apihelp-query+recentchanges-param-token": "代わりに [[Special:ApiHelp/query+tokens|action=query&meta=tokens]] を使用してください。", "apihelp-query+recentchanges-param-limit": "返す変更の総数。", "apihelp-query+recentchanges-param-toponly": "最新の版である変更のみを一覧表示する。", @@ -772,6 +810,7 @@ "apihelp-query+search-param-search": "この値を含むページ名または本文を検索します。Wikiの検索バックエンド実装に応じて、あなたは特別な検索機能を呼び出すための文字列を検索することができます。", "apihelp-query+search-param-namespace": "この名前空間内のみを検索します。", "apihelp-query+search-param-what": "実行する検索の種類です。", + "apihelp-query+search-param-info": "どのメタデータを返すか。", "apihelp-query+search-param-prop": "返すプロパティ:", "apihelp-query+search-paramvalue-prop-size": "バイト単位のページのサイズを追加します。", "apihelp-query+search-paramvalue-prop-wordcount": "ページのワード数を追加します。", @@ -798,6 +837,7 @@ "apihelp-query+templates-summary": "与えられたページでトランスクルードされているすべてのページを返します。", "apihelp-query+templates-param-namespace": "この名前空間のテンプレートのみ表示する。", "apihelp-query+templates-param-limit": "返すテンプレートの数。", + "apihelp-query+templates-param-dir": "一覧表示する方向。", "apihelp-query+templates-example-simple": "Main Page で使用されているテンプレートを取得する。", "apihelp-query+templates-example-generator": "Main Page で使用されているテンプレートに関する情報を取得する。", "apihelp-query+templates-example-namespaces": "Main Page でトランスクルードされている {{ns:user}} および {{ns:template}} 名前空間のページを取得する。", @@ -809,6 +849,9 @@ "apihelp-query+transcludedin-param-prop": "取得するプロパティ:", "apihelp-query+transcludedin-paramvalue-prop-pageid": "各ページのページID。", "apihelp-query+transcludedin-paramvalue-prop-title": "各ページのページ名。", + "apihelp-query+transcludedin-paramvalue-prop-redirect": "ページがリダイレクトである場合マークします。", + "apihelp-query+transcludedin-param-namespace": "この名前空間に含まれるページのみを一覧表示します。", + "apihelp-query+transcludedin-param-limit": "返す数。", "apihelp-query+transcludedin-example-simple": "Main Page をトランスクルードしているページの一覧を取得する。", "apihelp-query+transcludedin-example-generator": "Main Page をトランスクルードしているページに関する情報を取得する。", "apihelp-query+usercontribs-summary": "利用者によるすべての編集を取得します。", @@ -861,6 +904,7 @@ "apihelp-revisiondelete-summary": "版の削除および復元を行います。", "apihelp-revisiondelete-param-reason": "削除または復元の理由。", "apihelp-revisiondelete-example-revision": "Main Page の版 12345 の本文を隠す。", + "apihelp-rollback-summary": "ページの最後の編集を取り消す。", "apihelp-rollback-param-title": "巻き戻すページ名です。$1pageid とは同時に使用できません。", "apihelp-rollback-param-pageid": "巻き戻すページのページIDです。$1title とは同時に使用できません。", "apihelp-rollback-param-tags": "巻き戻しに適用するタグ。", @@ -887,7 +931,7 @@ "apihelp-tokens-example-edit": "編集トークンを取得する (既定)。", "apihelp-unblock-summary": "利用者のブロックを解除します。", "apihelp-unblock-param-id": "解除するブロックのID (list=blocksで取得できます)。$1user または $1userid とは同時に使用できません。", - "apihelp-unblock-param-user": "ブロックを解除する利用者名、IPアドレスまたはIPレンジ。$1idとは同時に使用できません。", + "apihelp-unblock-param-user": "ブロックを解除する利用者名、IPアドレスまたはIPアドレスレンジ。$1idまたは$1useridとは同時に使用できません。", "apihelp-unblock-param-reason": "ブロック解除の理由。", "apihelp-unblock-param-tags": "ブロック記録の項目に適用する変更タグ。", "apihelp-unblock-example-id": "ブロックID #105 を解除する。", @@ -943,6 +987,7 @@ "api-help-flag-writerights": "このモジュールは書き込みの権限を必要とします。", "api-help-flag-mustbeposted": "このモジュールは POST リクエストのみを受け付けます。", "api-help-flag-generator": "このモジュールはジェネレーターとして使用できます。", + "api-help-source": "ソース: $1", "api-help-parameters": "{{PLURAL:$1|パラメーター}}:", "api-help-param-deprecated": "廃止予定です。", "api-help-param-required": "このパラメーターは必須です。", @@ -968,10 +1013,27 @@ "api-help-permissions": "{{PLURAL:$1|権限}}:", "api-help-permissions-granted-to": "{{PLURAL:$1|権限を持つグループ}}: $2", "api-help-open-in-apisandbox": "[サンドボックスで開く]", + "apierror-filedoesnotexist": "ファイルが存在しません。", + "apierror-invaliduser": "無効なユーザー名「$1」。", "apierror-missingparam": "パラメーター $1 を設定してください。", + "apierror-mustbeloggedin": "$1にログインしている必要があります。", + "apierror-noimageredirect": "画像のリダイレクトを作成する権限がありません。", + "apierror-nosuchpageid": "ID $1のページはありません。", + "apierror-permissiondenied": "$1に必要な権限がありません。", + "apierror-permissiondenied-generic": "アクセスが拒否されました。", + "apierror-readonly": "ウィキは現在読み取り専用モードです。", "apierror-timeout": "サーバーが決められた時間内に応答しませんでした。", + "apierror-unknownerror-editpage": "不明な編集ページのエラー:$1", + "apierror-unknownerror-nocode": "不明なエラーです。", + "apierror-unknownerror": "不明なエラー:「$1」", "apiwarn-invalidcategory": "「$1」はカテゴリではありません。", "apiwarn-notfile": "「$1」はファイルではありません。", + "apiwarn-validationfailed-cannotset": "このモジュールでは設定できません。", + "apiwarn-validationfailed-keytoolong": "キーが長すぎます($1バイト以上は許可されません)。", + "apiwarn-wgDebugAPI": "セキュリティ警告:$wgDebugAPIが有効です。", + "api-feed-error-title": "エラー ($1)", + "api-usage-docref": "APIの使用については$1を参照してください。", + "api-exception-trace": "$2の$1($3)\n$4", "api-credits-header": "クレジット", "api-credits": "API の開発者:\n* Roan Kattouw (2007年9月-2009年の主任開発者)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Yuri Astrakhan (作成者、2006年9月-2007年9月の主任開発者)\n* Brad Jorsch (2013年-現在の主任開発者)\n\nコメント、提案、質問は mediawiki-api@lists.wikimedia.org にお送りください。\nバグはこちらへご報告ください: https://phabricator.wikimedia.org/" } diff --git a/includes/api/i18n/ko.json b/includes/api/i18n/ko.json index 4b5ce564ce..354e75cf03 100644 --- a/includes/api/i18n/ko.json +++ b/includes/api/i18n/ko.json @@ -69,12 +69,33 @@ "apihelp-compare-param-fromtitle": "비교할 첫 이름.", "apihelp-compare-param-fromid": "비교할 첫 문서 ID.", "apihelp-compare-param-fromrev": "비교할 첫 판.", + "apihelp-compare-param-fromtext": "fromtitle, fromid 또는 fromrev로 지정된 판의 내용 대신 이 텍스트를 사용합니다.", + "apihelp-compare-param-fromsection": "지정된 'from' 내용의 지정된 문단만 사용합니다.", + "apihelp-compare-param-frompst": "fromtext에 사전 저장 변환을 수행합니다.", + "apihelp-compare-param-fromcontentmodel": "fromtext의 콘텐츠 모델입니다. 지정하지 않으면 다른 변수를 참고하여 추정합니다.", + "apihelp-compare-param-fromcontentformat": "fromtext의 콘텐츠 직렬화 포맷입니다.", "apihelp-compare-param-totitle": "비교할 두 번째 제목.", "apihelp-compare-param-toid": "비교할 두 번째 문서 ID.", "apihelp-compare-param-torev": "비교할 두 번째 판.", + "apihelp-compare-param-torelative": "fromtitle, fromid 또는 fromrev에서 결정된 판과 상대적인 판을 사용합니다. 다른 'to' 옵션들은 모두 무시됩니다.", + "apihelp-compare-param-totext": "totitle, toid 또는 torev로 지정된 판의 내용 대신 이 텍스트를 사용합니다.", + "apihelp-compare-param-tosection": "지정된 'to' 내용의 지정된 문단만 사용합니다.", + "apihelp-compare-param-topst": "totext에 사전 저장 변환을 수행합니다.", + "apihelp-compare-param-tocontentmodel": "totext의 콘텐츠 모델입니다. 지정하지 않으면 다른 변수를 참고하여 추정합니다.", + "apihelp-compare-param-tocontentformat": "totext의 콘텐츠 직렬화 포맷입니다.", "apihelp-compare-param-prop": "가져올 정보입니다.", + "apihelp-compare-paramvalue-prop-diff": "HTML의 차이입니다.", + "apihelp-compare-paramvalue-prop-diffsize": "HTML 차이의 크기(바이트 단위)입니다.", + "apihelp-compare-paramvalue-prop-rel": "해당하는 경우 'from' 이전과 'to' 이후 판의 판 ID입니다.", + "apihelp-compare-paramvalue-prop-ids": "'from'과 'to' 판의 문서와 판 ID입니다.", + "apihelp-compare-paramvalue-prop-title": "'from'과 'to' 판의 문서 제목입니다.", + "apihelp-compare-paramvalue-prop-user": "'from'과 'to' 판의 사용자 이름과 ID입니다.", + "apihelp-compare-paramvalue-prop-comment": "'from'과 'to' 판의 설명입니다.", + "apihelp-compare-paramvalue-prop-parsedcomment": "'from'과 to' 판의 구문 분석된 설명입니다.", + "apihelp-compare-paramvalue-prop-size": "'from'과 'to' 판의 크기입니다.", "apihelp-compare-example-1": "판 1과 2의 차이를 생성합니다.", "apihelp-createaccount-summary": "새 사용자 계정을 만듭니다.", + "apihelp-createaccount-param-preservestate": "[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]가 hasprimarypreservedstate에 대해 참을 반환하면 primary-required로 표시된 요청은 생략됩니다. preservedusername에 대해 비어있지 않은 값이 반환되면 해당 사용자 이름은 username 변수를 위해 사용됩니다.", "apihelp-createaccount-example-create": "비밀번호 ExamplePassword로 된 사용자 Example의 생성 과정을 시작합니다.", "apihelp-createaccount-param-name": "사용자 이름", "apihelp-createaccount-param-password": "비밀번호입니다. ($1mailpassword가 설정되어 있으면 무시됩니다)", @@ -89,18 +110,22 @@ "apihelp-createaccount-example-mail": "사용자 testmailuser를 만들고 자동 생성된 비밀번호를 이메일로 보냅니다.", "apihelp-cspreport-summary": "브라우저가 콘텐츠 보안 정책의 위반을 보고하기 위해 사용합니다. 이 모듈은 SCP를 준수하는 웹 브라우저에 의해 자동으로 사용될 때를 제외하고는 사용해서는 안 됩니다.", "apihelp-cspreport-param-reportonly": "강제적 정책이 아닌, 모니터링 정책에서 나온 보고서인 것으로 표시합니다", - "apihelp-delete-summary": "문서 삭제", + "apihelp-cspreport-param-source": "이 보고서를 작동시킨 CSP 헤더를 생성한 원본입니다", + "apihelp-delete-summary": "문서를 삭제합니다.", "apihelp-delete-param-title": "삭제할 문서의 제목. $1pageid과 함께 사용할 수 없습니다.", "apihelp-delete-param-pageid": "삭제할 문서의 ID. $1title과 함께 사용할 수 없습니다.", "apihelp-delete-param-reason": "삭제의 이유. 설정하지 않으면 자동 생성되는 이유를 사용합니다.", + "apihelp-delete-param-tags": "삭제 기록의 항목에 적용할 변경 태그입니다.", "apihelp-delete-param-watch": "문서를 현재 사용자의 주시문서 목록에 추가합니다.", "apihelp-delete-param-watchlist": "현재 사용자의 주시목록에서 문서를 무조건적으로 추가하거나 제거하거나, 환경 설정을 사용하거나 주시를 변경하지 않습니다.", "apihelp-delete-param-unwatch": "문서를 현재 사용자의 주시문서 목록에서 제거합니다.", + "apihelp-delete-param-oldimage": "[[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]]에 지정된 바대로 삭제할 오래된 그림의 이름입니다.", "apihelp-delete-example-simple": "Main Page를 삭제합니다.", "apihelp-delete-example-reason": "Preparing for move 라는 이유로 Main Page를 삭제하기.", "apihelp-disabled-summary": "이 모듈은 해제되었습니다.", "apihelp-edit-summary": "문서를 만들고 편집합니다.", "apihelp-edit-param-title": "편집할 문서의 제목. $1pageid과 같이 사용할 수 없습니다.", + "apihelp-edit-param-pageid": "편집할 문서의 문서 ID입니다. $1title과 함께 사용할 수 없습니다.", "apihelp-edit-param-section": "문단 번호입니다. 0은 최상위 문단, new는 새 문단입니다.", "apihelp-edit-param-sectiontitle": "새 문단을 위한 제목.", "apihelp-edit-param-text": "문서 내용.", @@ -109,6 +134,9 @@ "apihelp-edit-param-minor": "사소한 편집.", "apihelp-edit-param-notminor": "사소하지 않은 편집.", "apihelp-edit-param-bot": "이 편집을 봇 편집으로 표시.", + "apihelp-edit-param-basetimestamp": "기본 판의 타임스탬프이며, 편집 충돌을 발견하기 위해 사용됩니다. [[Special:ApiHelp/query+revisions|action=query&prop=revisions&rvprop=timestamp]]를 통해 가져올 수 있습니다.", + "apihelp-edit-param-starttimestamp": "편집 과정을 시작할 때의 타임스탬프이며 편집 충돌을 발견하기 위해 사용됩니다. 편집 과정을 시작할 때(예: 문서 내용을 편집으로 불러올 때) [[Special:ApiHelp/main|curtimestamp]]를 사용하여 적절한 값을 가져올 수 있습니다.", + "apihelp-edit-param-recreate": "중간에 삭제되는 문서에 관한 오류를 모두 무시합니다.", "apihelp-edit-param-createonly": "이 페이지가 이미 존재하면 편집하지 않습니다.", "apihelp-edit-param-nocreate": "페이지가 존재하지 않으면 오류를 출력합니다.", "apihelp-edit-param-watch": "문서를 현재 사용자의 주시문서 목록에 추가합니다.", @@ -245,6 +273,7 @@ "apihelp-parse-paramvalue-prop-iwlinks": "구문 분석된 위키텍스트의 인터위키 링크를 제공합니다.", "apihelp-parse-paramvalue-prop-wikitext": "구문 분석된 위키텍스트 원문을 제공합니다.", "apihelp-parse-paramvalue-prop-properties": "구문 분석된 위키텍스트에 정의된 다양한 속성을 제공합니다.", + "apihelp-parse-param-pst": "구문 분석 이전에 입력에 대한 사전 저장 변환을 수행합니다. 텍스트로 사용할 때에만 유효합니다.", "apihelp-parse-param-disablelimitreport": "파서 출력에서 제한 보고서(\"NewPP limit report\")를 제외합니다.", "apihelp-parse-param-disablepp": "$1disablelimitreport를 대신 사용합니다.", "apihelp-parse-param-disableeditsection": "파서 출력에서 문단 편집 링크를 제외합니다.", @@ -315,6 +344,7 @@ "apihelp-query+allredirects-param-limit": "반환할 총 항목 수입니다.", "apihelp-query+allrevisions-summary": "모든 판 표시.", "apihelp-query+mystashedfiles-param-limit": "가져올 파일의 갯수.", + "apihelp-query+alltransclusions-summary": "존재하지 않는 문서를 포함하여 끼워넣은 모든 문서({{x}}를 사용하여 끼워넣은 문서)를 나열합니다.", "apihelp-query+alltransclusions-param-prop": "포함할 정보:", "apihelp-query+alltransclusions-param-namespace": "열거할 이름공간.", "apihelp-query+alltransclusions-param-limit": "반환할 총 항목 수입니다.", @@ -376,6 +406,7 @@ "apihelp-query+exturlusage-paramvalue-prop-url": "문서에 사용된 URL을 추가합니다.", "apihelp-query+exturlusage-param-namespace": "열거할 문서 이름공간.", "apihelp-query+exturlusage-param-limit": "반환할 문서 수.", + "apihelp-query+exturlusage-example-simple": "https://www.mediawiki.org를 가리키는 문서를 표시합니다.", "apihelp-query+filearchive-summary": "삭제된 모든 파일을 순서대로 열거합니다.", "apihelp-query+filearchive-paramvalue-prop-sha1": "그림에 대한 SHA-1 해시를 추가합니다.", "apihelp-query+filearchive-paramvalue-prop-user": "그림 판을 올린 사용자를 추가합니다.", @@ -395,7 +426,9 @@ "apihelp-query+fileusage-param-limit": "반환할 항목 수.", "apihelp-query+fileusage-param-show": "이 기준을 충족하는 항목만 표시합니다:\n;redirect:넘겨주기만 표시합니다.\n;!redirect:넘겨주기가 아닌 항목만 표시합니다.", "apihelp-query+imageinfo-summary": "파일 정보와 업로드 역사를 반환합니다.", + "apihelp-query+imageinfo-param-prop": "가져올 파일 정보입니다:", "apihelp-query+imageinfo-paramvalue-prop-timestamp": "업로드된 판에 대한 타임스탬프를 추가합니다.", + "apihelp-query+imageinfo-paramvalue-prop-parsedcomment": "판의 설명을 구문 분석합니다.", "apihelp-query+imageinfo-paramvalue-prop-sha1": "파일에 대한 SHA-1 해시를 추가합니다.", "apihelp-query+imageinfo-paramvalue-prop-mediatype": "파일의 미디어 유형을 추가합니다.", "apihelp-query+imageinfo-param-urlheight": "$1urlwidth와 유사합니다.", @@ -412,6 +445,7 @@ "apihelp-query+info-param-prop": "얻고자 하는 추가 속성:", "apihelp-query+info-paramvalue-prop-protection": "각 문서의 보호 수준을 나열합니다.", "apihelp-query+info-paramvalue-prop-readable": "사용자가 이 문서를 읽을 수 있는지의 여부.", + "apihelp-query+info-paramvalue-prop-varianttitles": "모든 종류의 사이트 내용 언어의 표시 제목을 지정합니다.", "apihelp-query+iwbacklinks-summary": "제시된 인터위키 링크에 연결된 모든 문서를 찾습니다.", "apihelp-query+iwbacklinks-param-prefix": "인터위키의 접두사.", "apihelp-query+iwbacklinks-param-title": "검색할 인터위키 링크. $1blprefix와 함께 사용해야 합니다.", @@ -598,7 +632,16 @@ "apihelp-stashedit-param-sectiontitle": "새 문단을 위한 제목.", "apihelp-stashedit-param-text": "문서 내용.", "apihelp-stashedit-param-contentmodel": "새 콘텐츠의 콘텐츠 모델.", + "apihelp-tag-summary": "개별 판이나 기록 항목에서 변경 태그를 추가하거나 제거합니다.", + "apihelp-tag-param-rcid": "태그를 변경하거나 추가할 하나 이상의 최근 바뀜 ID입니다.", + "apihelp-tag-param-revid": "태그를 추가하거나 제거할 하나 이상의 판 ID입니다.", + "apihelp-tag-param-logid": "태그를 추가하거나 제거할 하나 이상의 기록 항목 ID입니다.", + "apihelp-tag-param-add": "추가할 태그입니다. 수동으로 지정한 태그만 추가할 수 있습니다.", + "apihelp-tag-param-remove": "제거할 태그입니다. 수동으로 지정하거나 완전히 정의되지 않은 태그만 제거할 수 있습니다.", "apihelp-tag-param-reason": "변경 이유.", + "apihelp-tag-param-tags": "이 동작의 결과로 생성되는 기록 항목에 적용할 태그입니다.", + "apihelp-tag-example-rev": "이유를 지정하지 않고 vandalism 태그를 판 ID 123에 추가합니다", + "apihelp-tag-example-log": "이유를 Wrongly applied로 지정하고 기록 항목 ID 123에서 spam 태그를 제거합니다", "apihelp-tokens-summary": "데이터 수정 작업을 위해 토큰을 가져옵니다.", "apihelp-tokens-extended-description": "이 모듈은 [[Special:ApiHelp/query+tokens|action=query&meta=tokens]]의 선호에 따라 사용이 권장되지 않습니다.", "apihelp-tokens-param-type": "요청할 토큰의 종류.", @@ -616,7 +659,7 @@ "apihelp-undelete-extended-description": "삭제된 판의 목록(타임스탬프 포함)은 [[Special:ApiHelp/query+deletedrevisions|prop=deletedrevisions]]을 통해 검색할 수 있으며 삭제된 파일 ID의 목록은 [[Special:ApiHelp/query+filearchive|list=filearchive]]을 통해 검색할 수 있습니다.", "apihelp-undelete-param-title": "복구할 문서의 제목입니다.", "apihelp-undelete-param-reason": "복구할 이유입니다.", - "apihelp-undelete-param-tags": "삭제 기록의 항목에 적용할 태그를 변경합니다.", + "apihelp-undelete-param-tags": "삭제 기록의 항목에 적용할 변경 태그입니다.", "apihelp-undelete-param-timestamps": "복구할 판의 타임스탬프입니다. $1timestamps와 $1fileids가 둘 다 비어있으면 모든 판이 복구됩니다.", "apihelp-undelete-param-fileids": "복구할 파일 판의 ID입니다. $1timestamps와 $1fileids가 둘 다 비어있으면 모든 판이 복구됩니다.", "apihelp-undelete-param-watchlist": "현재 사용자의 주시목록에서 문서를 무조건적으로 추가하거나 제거하거나, 환경 설정을 사용하거나 주시를 변경하지 않습니다.", @@ -625,6 +668,7 @@ "apihelp-unlinkaccount-summary": "현재 사용자에 연결된 타사 계정을 제거합니다.", "apihelp-unlinkaccount-example-simple": "FooAuthenticationRequest와 연결된 제공자에 대한 현재 사용자의 토론 링크 제거를 시도합니다.", "apihelp-upload-summary": "파일을 업로드하거나 대기 중인 업로드의 상태를 가져옵니다.", + "apihelp-upload-extended-description": "몇 가지 방식을 사용할 수 있습니다:\n* $1file 변수를 사용하여 파일의 내용을 직접 업로드합니다.\n* $1filesize, $1chunk, $1offset 변수를 사용하여 파일을 부분적으로 업로드합니다.\n* $1url 변수를 사용하여 미디어위키 서버가 URL로부터 파일을 가져오게 합니다.\n* $1filekey 변수를 사용하여 경고로 실패한 과거의 업로드를 완료합니다.\n$1file을(를) 보낼 때 HTTP POST는 파일 업로드로 끝나야 합니다. (예: multipart/form-data를 사용하여)", "apihelp-upload-param-filename": "대상 파일 이름.", "apihelp-upload-param-comment": "업로드 주석입니다. 또, $1text가 지정되지 않은 경우 새로운 파일들의 초기 페이지 텍스트로 사용됩니다.", "apihelp-upload-param-tags": "업로드 기록 항목과 파일 문서 판에 적용할 태그를 변경합니다.", @@ -696,7 +740,7 @@ "api-help-lead": "이 페이지는 자동으로 생성된 미디어위키 API 도움말 문서입니다.\n\n설명 문서 및 예시: https://www.mediawiki.org/wiki/API", "api-help-main-header": "메인 모듈", "api-help-undocumented-module": "$1 모듈에 대한 설명문이 없습니다.", - "api-help-flag-deprecated": "이 모듈은 사용되지 않습니다.", + "api-help-flag-deprecated": "이 모듈은 구식입니다.", "api-help-flag-internal": "이 모듈은 내부용이거나 불안정합니다. 동작은 예고 없이 변경될 수 있습니다.", "api-help-flag-readrights": "이 모듈은 read 권한을 요구합니다.", "api-help-flag-writerights": "이 모듈은 write 권한을 요구합니다.", @@ -708,13 +752,13 @@ "api-help-license-noname": "라이선스: [[$1|링크 참조]]", "api-help-license-unknown": "라이선스: 알 수 없음", "api-help-parameters": "{{PLURAL:$1|변수}}:", - "api-help-param-deprecated": "사용되지 않습니다.", + "api-help-param-deprecated": "구식입니다.", "api-help-param-required": "이 변수는 필수 입력 사항입니다.", "api-help-datatypes-header": "데이터 유형", "api-help-datatypes": "API 요청 내 몇몇 매개변수형에 대해 더 자세히 설명해보겠습니다:\n;boolean\n:Boolean 매개변수들은 HTML 체크박스처럼 동작합니다: 만약 매개변수가 지정되었다면, 값에 상관없이 참의 값으로 여겨집니다. 거짓값은 매개변수 전체를 생략하세요.\n;timestamp\n:타임스탬프들은 여러 형식으로 표현될 수 있으나 ISO 8601 날짜와 시간이 추천됩니다. 모든 시간은 UTC이어야 하며, 포함된 시간대는 모두 무시됩니다.\n:* ISO 8601 날짜와 시간, 2001-01-15T14:56:00Z (구두점과 Z는 선택입니다.)\n:* ISO 8601 날짜와 시간과 (무시되는) 소수 초, 2001-01-15T14:56:00.00001Z (대시, 콜론과 Z는 선택입니다.)\n:* 미디어위키 형식, 20010115145600\n:* 일반적인 수 형식 2001-01-15 14:56:00 (GMT, +##, 또는 -##와 같은 선택적 시간대는 무시됩니다)\n:*RFC 2822 형식 (시간대는 생략될 수 있음), Mon, 15 Jan 2001 14:56:00\n:* RFC 850 형식 (시간대는 생략될 수 있음), Monday, 15-Jan-2001 14:56:00\n:* C ctime 형식, Mon Jan 15 14:56:00 2001\n:* 1부터 13자리까지의 숫자로 표현된 1970-01-01T00:00:00Z부터 흐른 시간(초) (0을 제외)\n:* 문자열 now", "api-help-param-type-limit": "유형: 정수 또는 max", "api-help-param-type-integer": "유형: {{PLURAL:$1|1=정수|2=정수 목록}}", - "api-help-param-type-boolean": "유형: 부울 ([[Special:ApiHelp/main#main/datatypes|자세한 정보]])", + "api-help-param-type-boolean": "유형: 불리언 ([[Special:ApiHelp/main#main/datatypes|자세한 정보]])", "api-help-param-type-timestamp": "유형: {{PLURAL:$1|1=타임스탬프|2=타임스탬프 목록}} ([[Special:ApiHelp/main#main/datatypes|허용되는 포맷]])", "api-help-param-type-user": "유형: {{PLURAL:$1|1=사용자 이름|2=사용자 이름 목록}}", "api-help-param-list": "{{PLURAL:$1|1=다음 값 중 하나|2=값 ({{!}}로 구분)}}: $2 또는 [[Special:ApiHelp/main#main/datatypes|alternative]]: $2", @@ -748,6 +792,7 @@ "api-help-authmanagerhelper-messageformat": "반환 메시지에 사용할 형식.", "api-help-authmanagerhelper-mergerequestfields": "모든 인증 요청에 대한 필드 정보를 하나의 배열로 합칩니다.", "api-help-authmanagerhelper-preservestate": "가능하면 과거에 실패한 로그인 시도의 상태를 보존합니다.", + "api-help-authmanagerhelper-returnurl": "서드파티 인증 플로의 URL을 반환하며, 절대 주소여야 합니다. 이것 또는 $1continue는 필수입니다.\n\nREDIRECT 응답을 받으면 일반적으로 서드파티 인증 플로를 위해 지정한 redirecttarget URL에 대해 브라우저나 웹 뷰를 열게 됩니다. 이 작업이 끝나면 서드파티는 브라우저나 웹 뷰를 이 URL로 보냅니다. URL로부터 쿼리나 POST 변수를 추출한 다음 이것들을 $1continue 요청으로서 이 API 모듈로 전달하는 것이 좋습니다.", "api-help-authmanagerhelper-continue": "이 요청은 초기 UI 또는 REDIRECT 응답 이후에 계속됩니다. 이것 또는 $1returnurl 중 하나가 필요합니다.", "api-help-authmanagerhelper-additional-params": "이 모듈은 사용 가능한 인증 요청에 따라 추가 변수를 허용합니다. 사용 가능한 요청 및 사용되는 필드를 결정하려면 amirequestsfor=$1(또는 해당되는 경우 이 모듈의 과거 응답)과 함께 [[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]을(를) 사용하십시오.", "apierror-articleexists": "작성하려는 문서가 이미 만들어져 있습니다.", diff --git a/includes/api/i18n/lb.json b/includes/api/i18n/lb.json index a0e196630d..f68bd699cc 100644 --- a/includes/api/i18n/lb.json +++ b/includes/api/i18n/lb.json @@ -107,6 +107,7 @@ "apihelp-query+deletedrevs-summary": "Geläscht Versiounen oplëschten.", "apihelp-query+deletedrevs-param-unique": "Nëmmen eng Versioun fir all Säit weisen.", "apihelp-query+embeddedin-param-filterredir": "Wéi Viruleedungen gefiltert gi sollen.", + "apihelp-query+exturlusage-example-simple": "Säiten. déi op https://www.mediawiki.org linken, weisen.", "apihelp-query+filearchive-paramvalue-prop-dimensions": "Alias fir Gréisst.", "apihelp-query+filearchive-example-simple": "Eng Lëscht vun alle geläschte Fichiere weisen", "apihelp-query+fileusage-paramvalue-prop-title": "Titel vun all Säit.", diff --git a/includes/api/i18n/lt.json b/includes/api/i18n/lt.json index 6f2a72c84b..f470a72228 100644 --- a/includes/api/i18n/lt.json +++ b/includes/api/i18n/lt.json @@ -199,7 +199,7 @@ "apihelp-query+exturlusage-paramvalue-prop-ids": "Prideda puslapio ID.", "apihelp-query+exturlusage-paramvalue-prop-url": "Prideda URL, panaudota puslapyje.", "apihelp-query+exturlusage-param-limit": "Kiek puslapių gražinti.", - "apihelp-query+exturlusage-example-simple": "Rodyti puslapius, nurodančius į http://www.mediawiki.org.", + "apihelp-query+exturlusage-example-simple": "Rodyti puslapius, nurodančius į https://www.mediawiki.org.", "apihelp-query+filearchive-param-prop": "Kokią paveikslėlio informaciją gauti:", "apihelp-query+filearchive-paramvalue-prop-timestamp": "Prideda laiko žymę įkeltai versijai.", "apihelp-query+filearchive-paramvalue-prop-user": "Prideda vartotoją, kuris įkėlė paveikslėlio versiją.", diff --git a/includes/api/i18n/nb.json b/includes/api/i18n/nb.json index c2fea1c0ec..a1c92389d4 100644 --- a/includes/api/i18n/nb.json +++ b/includes/api/i18n/nb.json @@ -657,7 +657,6 @@ "apierror-missingtitle-byname": "Siden $1 fins ikke.", "apierror-moduledisabled": "Modulen $1 har blitt slått av.", "apierror-multival-only-one-of": "{{PLURAL:$3|Kun|Kun én av} $2 tillates for parameteren $3.", - "apierror-multival-only-one": "Bare én verdi er tillatt for parameteret $1.", "apierror-multpages": "$1 kan kun brukes med én enkel side.", "apierror-mustbeloggedin-changeauth": "Du må være logget inn for å endre autentiseringsdata.", "apierror-mustbeloggedin-generic": "Du må være logget inn.", diff --git a/includes/api/i18n/pt-br.json b/includes/api/i18n/pt-br.json index 7102f73d47..1445647582 100644 --- a/includes/api/i18n/pt-br.json +++ b/includes/api/i18n/pt-br.json @@ -26,7 +26,7 @@ "apihelp-main-param-assert": "Verifique se o usuário está logado se configurado para user ou tem o direito do usuário do bot se bot.", "apihelp-main-param-assertuser": "Verificar que o usuário atual é o utilizador nomeado.", "apihelp-main-param-requestid": "Qualquer valor dado aqui será incluído na resposta. Pode ser usado para distinguir requisições.", - "apihelp-main-param-servedby": "Inclua o nome de host que atendeu a solicitação nos resultados.", + "apihelp-main-param-servedby": "Incluir nos resultados o nome do servidor que serviu o pedido.", "apihelp-main-param-curtimestamp": "Inclui o timestamp atual no resultado.", "apihelp-main-param-responselanginfo": "Inclua os idiomas usados para uselang e errorlang no resultado.", "apihelp-main-param-origin": "Ao acessar a API usando uma solicitação AJAX por domínio cruzado (CORS), defina isto como o domínio de origem. Isto deve estar incluso em toda solicitação ''pre-flight'', sendo portanto parte do URI da solicitação (ao invés do corpo do POST).\n\nPara solicitações autenticadas, isto deve corresponder a uma das origens no cabeçalho Origin, para que seja algo como https://pt.wikipedia.org ou https://meta.wikimedia.org. Se este parâmetro não corresponder ao cabeçalho Origin, uma resposta 403 será retornada. Se este parâmetro corresponder ao cabeçalho Origin e a origem for permitida (''whitelisted''), os cabeçalhos Access-Control-Allow-Origin e Access-Control-Allow-Credentials serão definidos.\n\nPara solicitações não autenticadas, especifique o valor *. Isto fará com que o cabeçalho Access-Control-Allow-Origin seja definido, porém o Access-Control-Allow-Credentials será false e todos os dados específicos para usuários tornar-se-ão restritos.", @@ -77,6 +77,7 @@ "apihelp-compare-param-torev": "Segunda revisão para comparar.", "apihelp-compare-param-torelative": "Use uma revisão relativa à revisão determinada de fromtitle, fromid ou fromrev. Todas as outras opções 'to' serão ignoradas.", "apihelp-compare-param-totext": "Use este texto em vez do conteúdo da revisão especificada por totitle, toid ou torev.", + "apihelp-compare-param-tosection": "Utilizar apenas a secção especificada do conteúdo 'to' especificado.", "apihelp-compare-param-topst": "Faz uma transformação pré-salvar em totext.", "apihelp-compare-param-tocontentmodel": "Modelo de conteúdo de totext. Se não for fornecido, será adivinhado com base nos outros parâmetros.", "apihelp-compare-param-tocontentformat": "Formato de serialização de conteúdo de totext.", @@ -239,6 +240,8 @@ "apihelp-import-extended-description": "Observe que o POST HTTP deve ser feito como um upload de arquivos (ou seja, usar multipart/form-data) ao enviar um arquivo para o parâmetro xml.", "apihelp-import-param-summary": "Resumo de importação do log de entrada.", "apihelp-import-param-xml": "Enviar arquivo XML.", + "apihelp-import-param-interwikiprefix": "Para importações carregadas: o prefixo interwikis a ser aplicado aos nomes de utilizador desconhecidos (e aos conhecidos se $1assignknownusers estiver definido).", + "apihelp-import-param-assignknownusers": "Atribuir as edições aos usuários locais se o utilizador nomeado existir localmente.", "apihelp-import-param-interwikisource": "Para importações de interwiki: wiki para importar de.", "apihelp-import-param-interwikipage": "Para importações de interwiki: página para importar.", "apihelp-import-param-fullhistory": "Para importações de interwiki: importa o histórico completo, não apenas a versão atual.", @@ -736,7 +739,7 @@ "apihelp-query+exturlusage-param-namespace": "O espaço nominal das páginas para enumerar.", "apihelp-query+exturlusage-param-limit": "Quantas páginas retornar.", "apihelp-query+exturlusage-param-expandurl": "Expandir URLs relativos ao protocolo com o protocolo canônico.", - "apihelp-query+exturlusage-example-simple": "Mostra páginas vigiadas à http://www.mediawiki.org.", + "apihelp-query+exturlusage-example-simple": "Mostra páginas vigiadas à https://www.mediawiki.org.", "apihelp-query+filearchive-summary": "Enumerar todos os arquivos excluídos sequencialmente.", "apihelp-query+filearchive-param-from": "O título da imagem do qual começar a enumeração.", "apihelp-query+filearchive-param-to": "O título da imagem no qual parar a enumeração.", @@ -837,6 +840,7 @@ "apihelp-query+info-paramvalue-prop-readable": "Se o usuário pode ler esta página.", "apihelp-query+info-paramvalue-prop-preload": "Fornece o texto retornado por EditFormPreloadText.", "apihelp-query+info-paramvalue-prop-displaytitle": "Fornece o modo como o título da página é exibido.", + "apihelp-query+info-paramvalue-prop-varianttitles": "Fornece o título de apresentação em todas as variantes da língua de conteúdo da wiki.", "apihelp-query+info-param-testactions": "Testa se o usuário atual pode executar determinadas ações na página.", "apihelp-query+info-param-token": "Use [[Special:ApiHelp/query+tokens|action=query&meta=tokens]] em vez.", "apihelp-query+info-example-simple": "Obter informações sobre a página Main Page.", @@ -996,6 +1000,7 @@ "apihelp-query+recentchanges-paramvalue-prop-sizes": "Adiciona o comprimento novo e antigo da página em bytes.", "apihelp-query+recentchanges-paramvalue-prop-redirect": "Etiqueta a edição se a página é um redirecionamento.", "apihelp-query+recentchanges-paramvalue-prop-patrolled": "Etiquete edições patrulháveis como sendo patrulhadas ou não-patrulhadas.", + "apihelp-query+recentchanges-paramvalue-prop-autopatrolled": "Etiqueta as edições que podem ser patrulhadas, marcando-as como autopatrulhadas ou não.", "apihelp-query+recentchanges-paramvalue-prop-loginfo": "Adiciona informações de registro (ID de registro, tipo de registro, etc.) às entradas do log.", "apihelp-query+recentchanges-paramvalue-prop-tags": "Listar as etiquetas para a entrada.", "apihelp-query+recentchanges-paramvalue-prop-sha1": "Adiciona o checksum do conteúdo para entradas associadas a uma revisão.", @@ -1075,6 +1080,7 @@ "apihelp-query+search-paramvalue-prop-sectiontitle": "Adiciona o título da seção correspondente.", "apihelp-query+search-paramvalue-prop-categorysnippet": "Adiciona um parsed snippet da categoria correspondente.", "apihelp-query+search-paramvalue-prop-isfilematch": "Adiciona um booleano que indica se a pesquisa corresponde ao conteúdo do arquivo.", + "apihelp-query+search-paramvalue-prop-extensiondata": "Acrescenta dados adicionais gerados por extensões.", "apihelp-query+search-paramvalue-prop-score": "Ignorado.", "apihelp-query+search-paramvalue-prop-hasrelated": "Ignorado.", "apihelp-query+search-param-limit": "Quantas páginas retornar.", @@ -1173,6 +1179,7 @@ "apihelp-query+usercontribs-paramvalue-prop-sizediff": "Adiciona o tamanho delta da edição contra o seu pai.", "apihelp-query+usercontribs-paramvalue-prop-flags": "Adiciona etiqueta da edição.", "apihelp-query+usercontribs-paramvalue-prop-patrolled": "Etiquetas de edições patrulhadas.", + "apihelp-query+usercontribs-paramvalue-prop-autopatrolled": "Etiqueta as edições autopatrulhadas.", "apihelp-query+usercontribs-paramvalue-prop-tags": "Lista as tags para editar.", "apihelp-query+usercontribs-param-show": "Mostre apenas itens que atendam a esses critérios, por exemplo, apenas edições não-menores: $2show=!minor.\n\nSe $2show=patrolled ou $2show=!patrolled estiver definido, revisões mais antigas do que [[mw:Special:MyLanguage/Manual:$wgRCMaxAge|$wgRCMaxAge]] ($1 {{PLURAL:$1|segundo|segundos}}) não serão exibidas.", "apihelp-query+usercontribs-param-tag": "Lista apenas as revisões com esta tag.", @@ -1237,9 +1244,11 @@ "apihelp-query+watchlist-paramvalue-prop-parsedcomment": "Adiciona o comentário analisado da edição.", "apihelp-query+watchlist-paramvalue-prop-timestamp": "Adiciona o timestamp da edição.", "apihelp-query+watchlist-paramvalue-prop-patrol": "Edições de tags que são patrulhadas.", + "apihelp-query+watchlist-paramvalue-prop-autopatrol": "Etiqueta que indica as edições que são autopatrulhadas.", "apihelp-query+watchlist-paramvalue-prop-sizes": "Adiciona os velhos e novos comprimentos da página.", "apihelp-query+watchlist-paramvalue-prop-notificationtimestamp": "Adiciona o timestamp de quando o usuário foi notificado pela última vez sobre a edição.", "apihelp-query+watchlist-paramvalue-prop-loginfo": "Adiciona informações de log, quando apropriado.", + "apihelp-query+watchlist-paramvalue-prop-tags": "Lista as etiquetas da entrada.", "apihelp-query+watchlist-param-show": "Mostre apenas itens que atendam a esses critérios. Por exemplo, para ver apenas edições menores feitas por usuários conectados, set $1show=minor|!anon.", "apihelp-query+watchlist-param-type": "Quais tipos de mudanças mostrar:", "apihelp-query+watchlist-paramvalue-type-edit": "Edições comuns nas páginas.", @@ -1490,6 +1499,8 @@ "api-help-param-direction": "Em qual direção enumerar:\n;newer: Lista primeiro mais antigo. Nota: $1start deve ser anterior a $1end.\n;older: Lista mais recente primeiro (padrão). Nota: $1start deve ser posterior a $1end.", "api-help-param-continue": "Quando houver mais resultados disponíveis, use isso para continuar.", "api-help-param-no-description": "(sem descrição)", + "api-help-param-maxbytes": "Não pode exceder $1 {{PLURAL:$1|byte|bytes}}.", + "api-help-param-maxchars": "Não pode exceder $1 {{PLURAL:$1|carácter|caracteres}}.", "api-help-examples": "{{PLURAL:$1|Exemplo|Exemplos}}:", "api-help-permissions": "{{PLURAL:$1|Permissão|Permissões}}:", "api-help-permissions-granted-to": "{{PLURAL:$1|Concedido a|Concedidos a}}: $2", @@ -1594,6 +1605,8 @@ "apierror-invalidurlparam": "Valor inválido para $1urlparam ($2=$3).", "apierror-invaliduser": "Nome de usuário \"$1\" é inválido.", "apierror-invaliduserid": "O ID de usuário $1 não é permitido.", + "apierror-maxbytes": "O parâmetro $1 não pode exceder $2 {{PLURAL:$2|byte|bytes}}", + "apierror-maxchars": "O parâmetro $1 não pode exceder $2 {{PLURAL:$2|carácter|caracteres}}", "apierror-maxlag-generic": "Aguardando um servidor de banco de dados: $1 {{PLURAL:$1|segundo|segundos}} atraso.", "apierror-maxlag": "Esperando $2: $1 {{PLURAL: $1|segundo|segundos}} atrasado.", "apierror-mimesearchdisabled": "A pesquisa MIME está desativada no Miser Mode.", @@ -1609,7 +1622,6 @@ "apierror-missingtitle-byname": "A página $1 não existe.", "apierror-moduledisabled": "O módulo $1 foi desativado.", "apierror-multival-only-one-of": "{{PLURAL:$3|Somente|Somente um de}} $2 é permitido para parâmetro $1.", - "apierror-multival-only-one": "Apenas um valor é permitido para o parâmetro $1.", "apierror-multpages": "$1 só pode ser usada com uma única página.", "apierror-mustbeloggedin-changeauth": "Você precisa estar autenticado para alterar dados de autenticação.", "apierror-mustbeloggedin-generic": "Você deve estar logado.", diff --git a/includes/api/i18n/pt.json b/includes/api/i18n/pt.json index a519b3f4c8..2a81d29d93 100644 --- a/includes/api/i18n/pt.json +++ b/includes/api/i18n/pt.json @@ -489,7 +489,7 @@ "apihelp-query+alllinks-example-B": "Listar os títulos para os quais existem ligações, incluindo títulos em falta, com os identificadores das páginas que contêm as respetivas ligações, começando pela letra B.", "apihelp-query+alllinks-example-unique": "Listar os títulos únicos para os quais existem hiperligações.", "apihelp-query+alllinks-example-unique-generator": "Obtém todos os títulos para os quais existem hiperligações, marcando aqueles em falta.", - "apihelp-query+alllinks-example-generator": "Obtém as páginas que contêm as ligações.", + "apihelp-query+alllinks-example-generator": "Obtém as páginas que contêm as hiperligações.", "apihelp-query+allmessages-summary": "Devolver as mensagens deste sítio.", "apihelp-query+allmessages-param-messages": "Mensagens a serem produzidas no resultado. * (o valor por omissão) significa todas as mensagens.", "apihelp-query+allmessages-param-prop": "As propriedades a serem obtidas:", @@ -734,7 +734,7 @@ "apihelp-query+exturlusage-param-namespace": "Os espaços nominais a serem enumerados.", "apihelp-query+exturlusage-param-limit": "O número de páginas a serem devolvidas.", "apihelp-query+exturlusage-param-expandurl": "Expandir os URL relativos a protocolo com o protocolo canónico.", - "apihelp-query+exturlusage-example-simple": "Mostrar as páginas com hiperligações para http://www.mediawiki.org.", + "apihelp-query+exturlusage-example-simple": "Mostrar as páginas com hiperligações para https://www.mediawiki.org.", "apihelp-query+filearchive-summary": "Enumerar todos os ficheiros eliminados sequencialmente.", "apihelp-query+filearchive-param-from": "O título da imagem a partir do qual será começada a enumeração.", "apihelp-query+filearchive-param-to": "O título da imagem no qual será terminada a enumeração.", @@ -819,7 +819,7 @@ "apihelp-query+imageusage-param-dir": "A direção de listagem.", "apihelp-query+imageusage-param-filterredir": "Como filtrar redirecionamentos. Se definido como nonredirects quando $1redirect está ativado, isto só é aplicado ao segundo nível.", "apihelp-query+imageusage-param-limit": "O número total de páginas a serem devolvidas. Se $1redirect estiver ativado, o nível aplica-se a cada nível em separado (o que significa que até 2 * $1limit resultados podem ser devolvidos).", - "apihelp-query+imageusage-param-redirect": "Se a página que contém a ligação é um redirecionamento, procurar também todas as páginas que contêm ligações para esse redirecionamento. O limite máximo é reduzido para metade.", + "apihelp-query+imageusage-param-redirect": "Se a página que contém a hiperligação é um redirecionamento, procurar também todas as páginas que contêm hiperligações para esse redirecionamento. O limite máximo é reduzido para metade.", "apihelp-query+imageusage-example-simple": "Mostrar as páginas que usam [[:File:Albert Einstein Head.jpg]].", "apihelp-query+imageusage-example-generator": "Obter informações sobre as páginas que usam o ficheiro [[:File:Albert Einstein Head.jpg]].", "apihelp-query+info-summary": "Obter a informação básica da página.", @@ -835,6 +835,7 @@ "apihelp-query+info-paramvalue-prop-readable": "Indica se o utilizador pode ler esta página.", "apihelp-query+info-paramvalue-prop-preload": "Fornece o texto devolvido por EditFormPreloadText.", "apihelp-query+info-paramvalue-prop-displaytitle": "Fornece a forma como o título da página é apresentado.", + "apihelp-query+info-paramvalue-prop-varianttitles": "Fornece o título de apresentação em todas as variantes da língua de conteúdo da wiki.", "apihelp-query+info-param-testactions": "Testar se o utilizador pode realizar certas operações na página.", "apihelp-query+info-param-token": "Em substituição, usar [[Special:ApiHelp/query+tokens|action=query&meta=tokens]].", "apihelp-query+info-example-simple": "Obter informações sobre a página Main Page.", @@ -994,6 +995,7 @@ "apihelp-query+recentchanges-paramvalue-prop-sizes": "Adiciona os tamanhos antigo e novo da página em ''bytes''.", "apihelp-query+recentchanges-paramvalue-prop-redirect": "Etiqueta a página se esta for um redirecionamento.", "apihelp-query+recentchanges-paramvalue-prop-patrolled": "Etiqueta as edições que podem ser patrulhadas, marcando-as como patrulhadas ou não patrulhadas.", + "apihelp-query+recentchanges-paramvalue-prop-autopatrolled": "Etiqueta as edições que podem ser patrulhadas, marcando-as como autopatrulhadas ou não.", "apihelp-query+recentchanges-paramvalue-prop-loginfo": "Adiciona informação de registo (identificador do registo, tipo de entrada, etc.) às entradas do registo.", "apihelp-query+recentchanges-paramvalue-prop-tags": "Lista as etiquetas da entrada.", "apihelp-query+recentchanges-paramvalue-prop-sha1": "Adiciona a soma de controlo do conteúdo para as entradas associadas com uma revisão.", @@ -1172,6 +1174,7 @@ "apihelp-query+usercontribs-paramvalue-prop-sizediff": "Adiciona a diferença de tamanho entre a edição e a sua progenitora.", "apihelp-query+usercontribs-paramvalue-prop-flags": "Adiciona as etiquetas da edição.", "apihelp-query+usercontribs-paramvalue-prop-patrolled": "Etiqueta as edições patrulhadas.", + "apihelp-query+usercontribs-paramvalue-prop-autopatrolled": "Etiqueta as edições autopatrulhadas.", "apihelp-query+usercontribs-paramvalue-prop-tags": "Lista as etiquetas da edição.", "apihelp-query+usercontribs-param-show": "Mostrar só as contribuições que correspondem a estes critérios; por exemplo, só as edições não menores: $2show=!minor.\n\nSe um dos valores $2show=patrolled ou $2show=!patrolled estiver definido, as revisões feitas há mais de [[mw:Special:MyLanguage/Manual:$wgRCMaxAge|$wgRCMaxAge]] ($1 {{PLURAL:$1|segundo|segundos}}) não serão mostradas.", "apihelp-query+usercontribs-param-tag": "Listar só as revisões marcadas com esta etiqueta.", @@ -1236,7 +1239,8 @@ "apihelp-query+watchlist-paramvalue-prop-parsedcomment": "Adiciona o comentário da edição, após análise sintática.", "apihelp-query+watchlist-paramvalue-prop-timestamp": "Adiciona a data e hora da edição.", "apihelp-query+watchlist-paramvalue-prop-patrol": "Etiqueta que indica as edições que são patrulhadas.", - "apihelp-query+watchlist-paramvalue-prop-sizes": "Adiciona os tamanhos novo e antigo da página.", + "apihelp-query+watchlist-paramvalue-prop-autopatrol": "Etiqueta que indica as edições que são autopatrulhadas.", + "apihelp-query+watchlist-paramvalue-prop-sizes": "Adiciona o tamanho novo e antigo da página.", "apihelp-query+watchlist-paramvalue-prop-notificationtimestamp": "Adiciona a data e hora da última vez em que o utilizador foi notificado da edição.", "apihelp-query+watchlist-paramvalue-prop-loginfo": "Adiciona informação do registo quando apropriado.", "apihelp-query+watchlist-paramvalue-prop-tags": "Lista as etiquetas da entrada.", @@ -1613,7 +1617,6 @@ "apierror-missingtitle-byname": "A página $1 não existe.", "apierror-moduledisabled": "O módulo $1 foi desativado.", "apierror-multival-only-one-of": "Só é permitido {{PLURAL:$3|o valor|um dos valores}} $2 para o parâmetro $1.", - "apierror-multival-only-one": "Só é permitido um valor para o parâmetro $1.", "apierror-multpages": "$1 só pode ser usado com uma única página.", "apierror-mustbeloggedin-changeauth": "Tem de estar autenticado para alterar dados de autenticação.", "apierror-mustbeloggedin-generic": "Tem de estar autenticado.", diff --git a/includes/api/i18n/qqq.json b/includes/api/i18n/qqq.json index 96ff10fda2..594bf8e685 100644 --- a/includes/api/i18n/qqq.json +++ b/includes/api/i18n/qqq.json @@ -840,6 +840,7 @@ "apihelp-query+info-paramvalue-prop-readable": "{{doc-apihelp-paramvalue|query+info|prop|readable}}", "apihelp-query+info-paramvalue-prop-preload": "{{doc-apihelp-paramvalue|query+info|prop|preload}}", "apihelp-query+info-paramvalue-prop-displaytitle": "{{doc-apihelp-paramvalue|query+info|prop|displaytitle}}", + "apihelp-query+info-paramvalue-prop-varianttitles": "{{doc-apihelp-paramvalue|query+info|prop|varianttitles}}", "apihelp-query+info-param-testactions": "{{doc-apihelp-param|query+info|testactions}}", "apihelp-query+info-param-token": "{{doc-apihelp-param|query+info|token}}", "apihelp-query+info-example-simple": "{{doc-apihelp-example|query+info}}", @@ -1628,7 +1629,6 @@ "apierror-missingtitle-byname": "{{doc-apierror}}", "apierror-moduledisabled": "{{doc-apierror}}\n\nParameters:\n* $1 - Name of the module.", "apierror-multival-only-one-of": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.\n* $2 - Possible values for the parameter.\n* $3 - Number of values.", - "apierror-multival-only-one": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.", "apierror-multpages": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name", "apierror-mustbeloggedin-changeauth": "{{doc-apierror}}", "apierror-mustbeloggedin-generic": "{{doc-apierror}}", diff --git a/includes/api/i18n/ru.json b/includes/api/i18n/ru.json index ae997942c7..7c8c1694d0 100644 --- a/includes/api/i18n/ru.json +++ b/includes/api/i18n/ru.json @@ -28,7 +28,9 @@ "Alexey zakharenkov", "Facenapalm", "Jack who built the house", - "Mouse21" + "Mouse21", + "Happy13241", + "Ole Yves" ] }, "apihelp-main-extended-description": "
\n* [[mw:Special:MyLanguage/API:Main_page|Документация]]\n* [[mw:Special:MyLanguage/API:FAQ|ЧаВО]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Почтовая рассылка]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Новости API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Ошибки и запросы]\n
\nСтатус: MediaWiki API — зрелый и стабильный интерфейс, активно поддерживаемый и улучшаемый. Мы стараемся избегать ломающих изменений, однако изредка они могут быть необходимы. Подпишитесь на [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ почтовую рассылку mediawiki-api-announce], чтобы быть в курсе обновлений.\n\nОшибочные запросы: Если API получает запрос с ошибкой, вернётся заголовок HTTP с ключом «MediaWiki-API-Error», после чего значение заголовка и код ошибки будут отправлены обратно и установлены в то же значение. Более подробную информацию см. [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Ошибки и предупреждения]].\n\n

Тестирование: для удобства тестирования API-запросов, см. [[Special:ApiSandbox]].

", @@ -313,7 +315,7 @@ "apihelp-opensearch-summary": "Поиск по вики с использованием протокола OpenSearch.", "apihelp-opensearch-param-search": "Строка поиска.", "apihelp-opensearch-param-limit": "Максимальное число возвращаемых результатов.", - "apihelp-opensearch-param-namespace": "Пространства имён для поиска.", + "apihelp-opensearch-param-namespace": "Пространства имён для поиска. Игнорируется, если $1search начинается с корректного префикса пространства имён.", "apihelp-opensearch-param-suggest": "Ничего не делать, если [[mw:Special:MyLanguage/Manual:$wgEnableOpenSearchSuggest|$wgEnableOpenSearchSuggest]] ложно.", "apihelp-opensearch-param-redirects": "Как обрабатывать перенаправления:\n;return: Вернуть само перенаправление.\n;resolve: Вернуть целевую страницу. Может вернуть меньше $1limit результатов.\nПо историческим причинам значением по умолчанию является «return» для $1format=json и «resolve» для остальных форматов.", "apihelp-opensearch-param-format": "Формат вывода.", @@ -753,7 +755,7 @@ "apihelp-query+exturlusage-param-namespace": "Пространства имён для перечисления.", "apihelp-query+exturlusage-param-limit": "Сколько страниц вернуть.", "apihelp-query+exturlusage-param-expandurl": "Раскрыть зависимые от протокола ссылки с какноничным протоколом.", - "apihelp-query+exturlusage-example-simple": "Показать страницы, ссылающиеся на http://www.mediawiki.org.", + "apihelp-query+exturlusage-example-simple": "Показать страницы, ссылающиеся на https://www.mediawiki.org.", "apihelp-query+filearchive-summary": "Перечисление всех удалённых файлов.", "apihelp-query+filearchive-param-from": "Название изображения, с которого начать перечисление.", "apihelp-query+filearchive-param-to": "Название изображения, на котором закончить перечисление.", @@ -961,7 +963,7 @@ "apihelp-query+prefixsearch-summary": "Осуществление поиска по префиксу названий страниц.", "apihelp-query+prefixsearch-extended-description": "Не смотря на похожесть названий, этот модуль не является эквивалентом [[Special:PrefixIndex]]; если вы ищете его, см. [[Special:ApiHelp/query+allpages|action=query&list=allpages]] с параметром apprefix. Задача этого модуля близка к [[Special:ApiHelp/opensearch|action=opensearch]]: получение пользовательского ввода и представление наиболее подходящих заголовков. В зависимости от поискового движка, используемого на сервере, сюда может включаться исправление опечаток, избегание перенаправлений и другие эвристики.", "apihelp-query+prefixsearch-param-search": "Поисковый запрос.", - "apihelp-query+prefixsearch-param-namespace": "Пространства имён для поиска.", + "apihelp-query+prefixsearch-param-namespace": "Пространства имён для поиска. Игнорируется, если $1search начинается с корректного префикса пространства имён.", "apihelp-query+prefixsearch-param-limit": "Максимальное число возвращаемых результатов.", "apihelp-query+prefixsearch-param-offset": "Количество пропускаемых результатов.", "apihelp-query+prefixsearch-example-simple": "Поиск названий страниц, начинающихся с meaning.", @@ -1013,6 +1015,7 @@ "apihelp-query+recentchanges-paramvalue-prop-sizes": "Добавляет старую и новую длину страницы в байтах.", "apihelp-query+recentchanges-paramvalue-prop-redirect": "Отмечает правку, если страница является перенаправлением.", "apihelp-query+recentchanges-paramvalue-prop-patrolled": "Отмечает патрулируемые правки как отпатрулированные или неотпатрулированные.", + "apihelp-query+recentchanges-paramvalue-prop-autopatrolled": "Отмечает патрулируемые правки как отпатрулированные или неотпатрулированные.", "apihelp-query+recentchanges-paramvalue-prop-loginfo": "Добавляет информацию о записи журнала (идентификатор записи, её тип, и так далее).", "apihelp-query+recentchanges-paramvalue-prop-tags": "Перечисляет метки записи.", "apihelp-query+recentchanges-paramvalue-prop-sha1": "Добавляет значение контрольных сумм для записей, связанных с версией.", @@ -1191,6 +1194,7 @@ "apihelp-query+usercontribs-paramvalue-prop-sizediff": "Добавляет разницу между размерами страницы до и после правки.", "apihelp-query+usercontribs-paramvalue-prop-flags": "Добавляет флаги правки.", "apihelp-query+usercontribs-paramvalue-prop-patrolled": "Отмечает отпатрулированные правки.", + "apihelp-query+usercontribs-paramvalue-prop-autopatrolled": "Отмечает автоматически отпатрулированные правки.", "apihelp-query+usercontribs-paramvalue-prop-tags": "Перечисляет метки правки.", "apihelp-query+usercontribs-param-show": "Показать только элементы, удовлетворяющие данным критериям, например, только не малые правки: $2show=!minor.\n\nЕсли установлено $2show=patrolled или $2show=!patrolled, правки старее [[mw:Special:MyLanguage/Manual:$wgRCMaxAge|$wgRCMaxAge]] ($1 {{PLURAL:$1|секунды|секунд}}) не будут показаны.", "apihelp-query+usercontribs-param-tag": "Только правки с заданной меткой.", @@ -1255,6 +1259,7 @@ "apihelp-query+watchlist-paramvalue-prop-parsedcomment": "Добавляет распарсенное описание правки.", "apihelp-query+watchlist-paramvalue-prop-timestamp": "Добавляет временную метку правки.", "apihelp-query+watchlist-paramvalue-prop-patrol": "Определяет, была ли правка отпатрулирована.", + "apihelp-query+watchlist-paramvalue-prop-autopatrol": "Отмечает автоматически отпатрулированные правки.", "apihelp-query+watchlist-paramvalue-prop-sizes": "Добавляет старую и новую длину страницы.", "apihelp-query+watchlist-paramvalue-prop-notificationtimestamp": "Добавляет метку времени, когда участник был уведомлён о правке.", "apihelp-query+watchlist-paramvalue-prop-loginfo": "Добавляет информацию о журнале, где уместно.", @@ -1632,7 +1637,6 @@ "apierror-missingtitle-byname": "Страница $1 не существует.", "apierror-moduledisabled": "Модуль $1 был отключён.", "apierror-multival-only-one-of": "Параметру $1 может быть присвоено только {{PLURAL:$3|значение|одно из значений}} $2.", - "apierror-multival-only-one": "Параметру $1 может быть присвоено только одно значение.", "apierror-multpages": "Параметр $1 может быть применён только к одной странице.", "apierror-mustbeloggedin-changeauth": "Вы должны быть авторизованы для смены аутентификационных данных.", "apierror-mustbeloggedin-generic": "Вы должны быть авторизованы.", diff --git a/includes/api/i18n/sv.json b/includes/api/i18n/sv.json index d22ec34a53..5c0a12ee61 100644 --- a/includes/api/i18n/sv.json +++ b/includes/api/i18n/sv.json @@ -426,6 +426,7 @@ "apihelp-query+extlinks-param-limit": "Hur många länkar som ska returneras.", "apihelp-query+extlinks-example-simple": "Hämta en lista över externa länkar på Main Page.", "apihelp-query+exturlusage-param-limit": "Hur många sidor att returnera.", + "apihelp-query+exturlusage-example-simple": "Visa sidor som länkar till https://www.mediawiki.org.", "apihelp-query+filearchive-param-limit": "Hur många bilder att returnera totalt.", "apihelp-query+filearchive-param-dir": "Riktningen att lista mot.", "apihelp-query+filearchive-paramvalue-prop-timestamp": "Lägger till tidsstämpel för den uppladdade versionen.", @@ -451,6 +452,7 @@ "apihelp-query+imageusage-example-simple": "Visa sidor med hjälp av [[:File:Albert Einstein Head.jpg]].", "apihelp-query+imageusage-example-generator": "Hämta information om sidor med hjälp av [[:File:Albert Einstein Head.jpg]].", "apihelp-query+info-summary": "Få grundläggande sidinformation.", + "apihelp-query+info-paramvalue-prop-varianttitles": "Ger visningstiteln i alla variationer på webbplatsens innehållsspråk.", "apihelp-query+iwbacklinks-param-limit": "Hur många sidor att returnera totalt.", "apihelp-query+iwbacklinks-param-dir": "Riktningen att lista mot.", "apihelp-query+iwlinks-param-dir": "Riktningen att lista mot.", diff --git a/includes/api/i18n/uk.json b/includes/api/i18n/uk.json index f55f65e397..bc0b365be3 100644 --- a/includes/api/i18n/uk.json +++ b/includes/api/i18n/uk.json @@ -733,7 +733,7 @@ "apihelp-query+exturlusage-param-namespace": "Простори назв для переліку.", "apihelp-query+exturlusage-param-limit": "Скільки сторінок виводити.", "apihelp-query+exturlusage-param-expandurl": "Розгорнути протокол-залежні URL за канонічним протоколом.", - "apihelp-query+exturlusage-example-simple": "Показати сторінки, які посилаються на http://www.mediawiki.org.", + "apihelp-query+exturlusage-example-simple": "Показати сторінки, які посилаються на https://www.mediawiki.org.", "apihelp-query+filearchive-summary": "Перерахувати всі вилучені файли послідовно.", "apihelp-query+filearchive-param-from": "Назва зображення, з якої почати перелічувати.", "apihelp-query+filearchive-param-to": "Назва зображення, якою закінчити перелічувати.", @@ -1604,7 +1604,6 @@ "apierror-missingtitle-byname": "Сторінка $1 не існує.", "apierror-moduledisabled": "Модуль $1 було вимкнено.", "apierror-multival-only-one-of": "{{PLURAL:$3|Лише значення|Лише одне значення з}} $2 дозволене для параметра $1.", - "apierror-multival-only-one": "Лише одне значення дозволене для параметра $1.", "apierror-multpages": "$1 може використовуватись тільки з однією сторінкою.", "apierror-mustbeloggedin-changeauth": "Вам треба увійти в систему, щоб змінити автентифікаційні дані.", "apierror-mustbeloggedin-generic": "Ви повинні перебувати в системі.", diff --git a/includes/api/i18n/zh-hans.json b/includes/api/i18n/zh-hans.json index 8af308590e..fba891b62f 100644 --- a/includes/api/i18n/zh-hans.json +++ b/includes/api/i18n/zh-hans.json @@ -748,7 +748,7 @@ "apihelp-query+exturlusage-param-namespace": "要列举的页面名字空间。", "apihelp-query+exturlusage-param-limit": "返回多少页面。", "apihelp-query+exturlusage-param-expandurl": "用标准协议展开协议相关URL。", - "apihelp-query+exturlusage-example-simple": "显示链接至http://www.mediawiki.org的页面。", + "apihelp-query+exturlusage-example-simple": "显示链接至https://www.mediawiki.org的页面。", "apihelp-query+filearchive-summary": "循序列举所有被删除的文件。", "apihelp-query+filearchive-param-from": "枚举的起始图片标题。", "apihelp-query+filearchive-param-to": "枚举的结束图片标题。", @@ -849,6 +849,7 @@ "apihelp-query+info-paramvalue-prop-readable": "用户是否可以阅读此页面。", "apihelp-query+info-paramvalue-prop-preload": "提供由EditFormPreloadText返回的文本。", "apihelp-query+info-paramvalue-prop-displaytitle": "在页面标题实际显示的地方提供方式。", + "apihelp-query+info-paramvalue-prop-varianttitles": "提供网站内容语言所有变体的显示标题。", "apihelp-query+info-param-testactions": "测试当前用户是否可以在页面上执行某种操作。", "apihelp-query+info-param-token": "请改用[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]。", "apihelp-query+info-example-simple": "获取有关页面Main Page的信息。", @@ -1008,6 +1009,7 @@ "apihelp-query+recentchanges-paramvalue-prop-sizes": "添加新旧页面长度(字节)。", "apihelp-query+recentchanges-paramvalue-prop-redirect": "如果页面是重定向的话,标记编辑。", "apihelp-query+recentchanges-paramvalue-prop-patrolled": "将可巡查编辑标记为已巡查或未巡查。", + "apihelp-query+recentchanges-paramvalue-prop-autopatrolled": "将可巡查编辑标记为自动巡查或未巡查。", "apihelp-query+recentchanges-paramvalue-prop-loginfo": "添加日志信息(日志ID、日志类型等)至日志记录。", "apihelp-query+recentchanges-paramvalue-prop-tags": "列举条目的标签。", "apihelp-query+recentchanges-paramvalue-prop-sha1": "为与某一修订版本有关的记录添加内容校验和。", @@ -1186,6 +1188,7 @@ "apihelp-query+usercontribs-paramvalue-prop-sizediff": "添加与父编辑相比该编辑的大小变化。", "apihelp-query+usercontribs-paramvalue-prop-flags": "添加编辑标记。", "apihelp-query+usercontribs-paramvalue-prop-patrolled": "标记已巡查编辑。", + "apihelp-query+usercontribs-paramvalue-prop-autopatrolled": "编辑自动巡查编辑。", "apihelp-query+usercontribs-paramvalue-prop-tags": "列举用于编辑的标签。", "apihelp-query+usercontribs-param-show": "只显示符合这些标准的项目,例如只显示不是小编辑的编辑:$2show=!minor。\n\n如果$2show=patrolled或$2show=!patrolled被设定,早于[[mw:Special:MyLanguage/Manual:$wgRCMaxAge|$wgRCMaxAge]]($1秒)的修订不会被显示。", "apihelp-query+usercontribs-param-tag": "只列出被此标签标记的修订。", @@ -1236,7 +1239,7 @@ "apihelp-query+watchlist-param-allrev": "将同一页面的多个修订包含于指定的时间表内。", "apihelp-query+watchlist-param-start": "枚举的起始时间戳。", "apihelp-query+watchlist-param-end": "枚举的结束时间戳。", - "apihelp-query+watchlist-param-namespace": "过滤更改为仅限指定的名字空间。", + "apihelp-query+watchlist-param-namespace": "过滤更改为仅限指定名字空间。", "apihelp-query+watchlist-param-user": "只列出此用户的更改。", "apihelp-query+watchlist-param-excludeuser": "不要列出此用户的更改。", "apihelp-query+watchlist-param-limit": "根据结果返回的结果总数。", @@ -1250,6 +1253,7 @@ "apihelp-query+watchlist-paramvalue-prop-parsedcomment": "添加解析过的编辑摘要。", "apihelp-query+watchlist-paramvalue-prop-timestamp": "添加编辑时间戳。", "apihelp-query+watchlist-paramvalue-prop-patrol": "将编辑标记为已巡查。", + "apihelp-query+watchlist-paramvalue-prop-autopatrol": "将编辑标记为自动巡查。", "apihelp-query+watchlist-paramvalue-prop-sizes": "添加页面的旧有长度和新长度。", "apihelp-query+watchlist-paramvalue-prop-notificationtimestamp": "添加最近被通知有关编辑的用户的时间戳。", "apihelp-query+watchlist-paramvalue-prop-loginfo": "在适当位置添加日志信息。", @@ -1627,7 +1631,6 @@ "apierror-missingtitle-byname": "页面$1不存在。", "apierror-moduledisabled": "$1模块已被禁用。", "apierror-multival-only-one-of": "参数$1只允许$2{{PLURAL:$3||之一}}。", - "apierror-multival-only-one": "参数$1只允许一个值。", "apierror-multpages": "$1只可以在单一页面使用。", "apierror-mustbeloggedin-changeauth": "您必须登录以更改身份验证数据。", "apierror-mustbeloggedin-generic": "您必须登录。", diff --git a/includes/api/i18n/zh-hant.json b/includes/api/i18n/zh-hant.json index 75baaaabf0..ebf998b3ec 100644 --- a/includes/api/i18n/zh-hant.json +++ b/includes/api/i18n/zh-hant.json @@ -14,7 +14,8 @@ "Corainn", "A2093064", "Wwycheuk", - "Wbxshiori" + "Wbxshiori", + "Sanmosa" ] }, "apihelp-main-extended-description": "
\n* [[mw:Special:MyLanguage/API:Main_page|說明文件]]\n* [[mw:Special:MyLanguage/API:FAQ|常見問題]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api 郵遞清單]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API公告]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R 報告錯誤及請求功能]\n
\n狀態資訊:本頁所展示的所有功能都應正常運作,但API仍在開發,會隨時變化。請訂閱[https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ mediawiki-api-announce 郵遞清單]以便獲得更新通知。\n\n錯誤的請求:當API收到錯誤的請求,會發出以「MediaWiki-API-Error」為鍵的HTTP標頭欄位,隨後標頭欄位的值,以及傳回的錯誤碼會設為相同值。詳細資訊請參閱[[mw:Special:MyLanguage/API:Errors_and_warnings|API: 錯誤與警告]]。\n\n測試:要簡化API請求的測試過程,請見[[Special:ApiSandbox]]。", @@ -28,7 +29,7 @@ "apihelp-main-param-servedby": "在結果中包括提出請求的主機名。", "apihelp-main-param-curtimestamp": "在結果中包括目前的時間戳。", "apihelp-main-param-responselanginfo": "在結果中包括uselang和errorlang所用的語言。", - "apihelp-block-summary": "封鎖使用者。", + "apihelp-block-summary": "封鎖用戶。", "apihelp-block-param-user": "要封鎖的使用者名稱、IP 位址或 IP 範圍。不能與 $1userid 一起使用", "apihelp-block-param-reason": "封鎖原因。", "apihelp-block-param-anononly": "僅封鎖匿名使用者 (禁止這個 IP 位址的匿名使用者編輯)。", @@ -59,7 +60,7 @@ "apihelp-compare-param-torev": "要比對的第二個修訂。", "apihelp-compare-example-1": "建立修訂 1 與 1 的差異檔", "apihelp-createaccount-summary": "建立新使用者帳號。", - "apihelp-createaccount-param-name": "使用者名稱。", + "apihelp-createaccount-param-name": "用戶名。", "apihelp-createaccount-param-password": "密碼 (若有設定 $1mailpassword 則可略過)。", "apihelp-createaccount-param-domain": "外部身分核對使用的網域 (可有可無)。", "apihelp-createaccount-param-token": "在第一次請求時已取得的帳號建立金鑰。", @@ -139,7 +140,7 @@ "apihelp-import-param-namespace": "匯入至此命名空間。無法與 $1rootpage 一起使用。", "apihelp-import-param-rootpage": "匯入作為此頁面的子頁面。無法與 $1namespace 一起使用。", "apihelp-login-summary": "登入並取得身分核對 cookies", - "apihelp-login-param-name": "使用者名稱。", + "apihelp-login-param-name": "用戶名。", "apihelp-login-param-password": "密碼。", "apihelp-login-param-domain": "網域名稱(可有可無)。", "apihelp-login-example-login": "登入", @@ -268,8 +269,8 @@ "apihelp-unblock-example-id": "解除封銷 ID #105。", "apihelp-undelete-param-reason": "還原的原因。", "apihelp-userrights-summary": "更改一位使用者的群組成員。", - "apihelp-userrights-param-user": "使用者名稱。", - "apihelp-userrights-param-userid": "使用者 ID。", + "apihelp-userrights-param-user": "用戶名。", + "apihelp-userrights-param-userid": "用戶ID。", "apihelp-userrights-param-add": "加入使用者至這些群組;若已是成員,則更新失效時間。", "apihelp-userrights-param-remove": "從這些群組移除使用者。", "apihelp-userrights-param-reason": "變更的原因。", @@ -315,6 +316,7 @@ "api-help-permissions": "{{PLURAL:$1|權限}}:", "api-help-permissions-granted-to": "{{PLURAL:$1|已授權給}}: $2", "api-help-authmanager-general-usage": "使用此模組的一般程式是:\n# 通過amirequestsfor=$4取得來自[[Special:ApiHelp/query+authmanagerinfo|action=query&meta=authmanagerinfo]]的可用欄位,和來自[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]的$5令牌。\n# 向用戶顯示欄位,並獲得其提交的內容。\n# 提交(POST)至此模組,提供$1returnurl及任何相關欄位。\n# 在回应中檢查status。\n#* 如果您收到了PASS(成功)或FAIL(失敗),則認為操作結束。成功與否如上句所示。\n#* 如果您收到了UI,向用戶顯示新欄位,並再次獲取其提交的內容。然後再次使用$1continue,向本模組提交相關欄位,並重復第四步。\n#* 如果您收到了REDIRECT,將使用者指向redirecttarget中的目標,等待其返回$1returnurl。然後再次使用$1continue,向本模組提交返回URL中提供的一切欄位,並重復第四步。\n#* 如果您收到了RESTART,這意味著身份驗證正常運作,但我們沒有連結的使用者賬戶。您可以將此看做UI或FAIL。", + "apierror-missingparam": "$1參數必須被設定。", "apierror-mustbeloggedin-changeauth": "必須登入,才能變更身分核對資取。", "apierror-mustbeloggedin-removeauth": "必須登入,才能移除身分核對資取。", "apierror-permissiondenied": "您沒有權限$1。", diff --git a/includes/api/i18n/zh-hk.json b/includes/api/i18n/zh-hk.json new file mode 100644 index 0000000000..5ea1800d63 --- /dev/null +++ b/includes/api/i18n/zh-hk.json @@ -0,0 +1,15 @@ +{ + "@metadata": { + "authors": [ + "Liuxinyu970226" + ] + }, + "apihelp-block-param-hidename": "隱藏封鎖日誌的用戶名稱。 (需要 \"hideuser\" 權限)。", + "apihelp-block-param-allowusertalk": "允許用戶編輯自己的對話頁面 (依據 [[mw:Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]] 的設定)。", + "apihelp-block-param-reblock": "若用戶已被封鎖,覆寫既有的封鎖設定值。", + "apihelp-block-param-watchuser": "監視用戶或 IP 的用戶頁面與對話頁面。", + "apihelp-createaccount-summary": "建立一個新用戶戶口。", + "apihelp-login-param-name": "用戶名稱。", + "apihelp-userrights-param-user": "用戶名稱。", + "apihelp-userrights-param-userid": "用戶 ID。" +} diff --git a/includes/auth/EmailNotificationSecondaryAuthenticationProvider.php b/includes/auth/EmailNotificationSecondaryAuthenticationProvider.php index a4855318b5..0878c34f42 100644 --- a/includes/auth/EmailNotificationSecondaryAuthenticationProvider.php +++ b/includes/auth/EmailNotificationSecondaryAuthenticationProvider.php @@ -51,7 +51,7 @@ class EmailNotificationSecondaryAuthenticationProvider && !$this->manager->getAuthenticationSessionData( 'no-email' ) ) { // TODO show 'confirmemail_oncreate'/'confirmemail_sendfailed' message - wfGetDB( DB_MASTER )->onTransactionIdle( + wfGetDB( DB_MASTER )->onTransactionCommitOrIdle( function () use ( $user ) { $user = $user->getInstanceForUpdate(); $status = $user->sendConfirmationMail(); diff --git a/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php b/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php index 4a2d0094eb..0ef13b34ec 100644 --- a/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php +++ b/includes/auth/TemporaryPasswordPrimaryAuthenticationProvider.php @@ -314,7 +314,7 @@ class TemporaryPasswordPrimaryAuthenticationProvider if ( $sendMail ) { // Send email after DB commit - $dbw->onTransactionIdle( + $dbw->onTransactionCommitOrIdle( function () use ( $req ) { /** @var TemporaryPasswordAuthenticationRequest $req */ $this->sendPasswordResetEmail( $req ); @@ -388,7 +388,7 @@ class TemporaryPasswordPrimaryAuthenticationProvider if ( $mailpassword ) { // Send email after DB commit - wfGetDB( DB_MASTER )->onTransactionIdle( + wfGetDB( DB_MASTER )->onTransactionCommitOrIdle( function () use ( $user, $creator, $req ) { $this->sendNewAccountEmail( $user, $creator, $req->password ); }, diff --git a/includes/changes/RecentChange.php b/includes/changes/RecentChange.php index b0511209db..cc9532e064 100644 --- a/includes/changes/RecentChange.php +++ b/includes/changes/RecentChange.php @@ -78,6 +78,16 @@ class RecentChange { const PRC_PATROLLED = 1; const PRC_AUTOPATROLLED = 2; + /** + * @var bool For save() - save to the database only, without any events. + */ + const SEND_NONE = true; + + /** + * @var bool For save() - do emit the change to RCFeeds (usually public). + */ + const SEND_FEED = false; + public $mAttribs = []; public $mExtra = []; @@ -347,11 +357,23 @@ class RecentChange { /** * Writes the data in this object to the database - * @param bool $noudp + * + * For compatibility reasons, the SEND_ constants internally reference a value + * that may seem negated from their purpose (none=true, feed=false). This is + * because the parameter used to be called "$noudp", defaulting to false. + * + * @param bool $send self::SEND_FEED or self::SEND_NONE */ - public function save( $noudp = false ) { + public function save( $send = self::SEND_FEED ) { global $wgPutIPinRC, $wgUseEnotif, $wgShowUpdatedMarker; + if ( is_string( $send ) ) { + // Callers used to pass undocumented strings like 'noudp' + // or 'pleasedontudp' instead of self::SEND_NONE (true). + // @deprecated since 1.31 Use SEND_NONE instead. + $send = self::SEND_NONE; + } + $dbw = wfGetDB( DB_MASTER ); if ( !is_array( $this->mExtra ) ) { $this->mExtra = []; @@ -425,8 +447,8 @@ class RecentChange { $this->mAttribs['rc_this_oldid'], $this->mAttribs['rc_logid'], null, $this ); } - # Notify external application via UDP - if ( !$noudp ) { + if ( $send === self::SEND_FEED ) { + // Emit the change to external applications via RCFeeds. $this->notifyRCFeeds(); } @@ -442,7 +464,7 @@ class RecentChange { ) { // @FIXME: This would be better as an extension hook // Send emails or email jobs once this row is safely committed - $dbw->onTransactionIdle( + $dbw->onTransactionCommitOrIdle( function () use ( $editor, $title ) { $enotif = new EmailNotification(); $enotif->notifyOnPageChange( @@ -622,7 +644,7 @@ class RecentChange { $dbw->update( 'recentchanges', [ - 'rc_patrolled' => 1 + 'rc_patrolled' => self::PRC_PATROLLED ], [ 'rc_id' => $this->getAttribute( 'rc_id' ) @@ -704,9 +726,6 @@ class RecentChange { function () use ( $rc, $tags ) { $rc->addTags( $tags ); $rc->save(); - if ( $rc->mAttribs['rc_patrolled'] ) { - PatrolLog::record( $rc, true, $rc->getPerformer() ); - } }, DeferredUpdates::POSTSEND, wfGetDB( DB_MASTER ) @@ -780,9 +799,6 @@ class RecentChange { function () use ( $rc, $tags ) { $rc->addTags( $tags ); $rc->save(); - if ( $rc->mAttribs['rc_patrolled'] ) { - PatrolLog::record( $rc, true, $rc->getPerformer() ); - } }, DeferredUpdates::POSTSEND, wfGetDB( DB_MASTER ) @@ -890,7 +906,7 @@ class RecentChange { 'rc_last_oldid' => 0, 'rc_bot' => $user->isAllowed( 'bot' ) ? (int)$wgRequest->getBool( 'bot', true ) : 0, 'rc_ip' => self::checkIPAddress( $ip ), - 'rc_patrolled' => $markPatrolled ? 1 : 0, + 'rc_patrolled' => $markPatrolled ? self::PRC_AUTOPATROLLED : self::PRC_UNPATROLLED, 'rc_new' => 0, # obsolete 'rc_old_len' => null, 'rc_new_len' => null, @@ -976,7 +992,7 @@ class RecentChange { 'rc_last_oldid' => $oldRevId, 'rc_bot' => $bot ? 1 : 0, 'rc_ip' => self::checkIPAddress( $ip ), - 'rc_patrolled' => 1, // Always patrolled, just like log entries + 'rc_patrolled' => self::PRC_AUTOPATROLLED, // Always patrolled, just like log entries 'rc_new' => 0, # obsolete 'rc_old_len' => null, 'rc_new_len' => null, diff --git a/includes/changetags/ChangeTags.php b/includes/changetags/ChangeTags.php index 5b6088d59f..b64f85a9b4 100644 --- a/includes/changetags/ChangeTags.php +++ b/includes/changetags/ChangeTags.php @@ -32,6 +32,9 @@ class ChangeTags { */ const MAX_DELETE_USES = 5000; + /** + * A list of tags defined and used by MediaWiki itself. + */ private static $definedSoftwareTags = [ 'mw-contentmodelchange', 'mw-new-redirect', @@ -474,9 +477,12 @@ class ChangeTags { * Is it OK to allow the user to apply all the specified tags at the same time * as they edit/make the change? * + * Extensions should not use this function, unless directly handling a user + * request to add a tag to a revision or log entry that the user is making. + * * @param array $tags Tags that you are interested in applying - * @param User|null $user User whose permission you wish to check, or null if - * you don't care (e.g. maintenance scripts) + * @param User|null $user User whose permission you wish to check, or null to + * check for a generic non-blocked user with the relevant rights * @return Status * @since 1.25 */ @@ -541,10 +547,13 @@ class ChangeTags { * Is it OK to allow the user to adds and remove the given tags tags to/from a * change? * + * Extensions should not use this function, unless directly handling a user + * request to add or remove tags from an existing revision or log entry. + * * @param array $tagsToAdd Tags that you are interested in adding * @param array $tagsToRemove Tags that you are interested in removing - * @param User|null $user User whose permission you wish to check, or null if - * you don't care (e.g. maintenance scripts) + * @param User|null $user User whose permission you wish to check, or null to + * check for a generic non-blocked user with the relevant rights * @return Status * @since 1.25 */ @@ -589,11 +598,15 @@ class ChangeTags { * Adds and/or removes tags to/from a given change, checking whether it is * allowed first, and adding a log entry afterwards. * - * Includes a call to ChangeTag::canUpdateTags(), so your code doesn't need + * Includes a call to ChangeTags::canUpdateTags(), so your code doesn't need * to do that. However, it doesn't check whether the *_id parameters are a * valid combination. That is up to you to enforce. See ApiTag::execute() for * an example. * + * Extensions should generally avoid this function. Call + * ChangeTags::updateTags() instead, unless directly handling a user request + * to add or remove tags from an existing revision or log entry. + * * @param array|null $tagsToAdd If none, pass array() or null * @param array|null $tagsToRemove If none, pass array() or null * @param int|null $rc_id The rc_id of the change to add the tags to @@ -721,7 +734,8 @@ class ChangeTags { * @throws MWException When unable to determine appropriate JOIN condition for tagging */ public static function modifyDisplayQuery( &$tables, &$fields, &$conds, - &$join_conds, &$options, $filter_tag = '' ) { + &$join_conds, &$options, $filter_tag = '' + ) { global $wgUseTagFilter; // Normalize to arrays @@ -1057,6 +1071,9 @@ class ChangeTags { /** * Is it OK to allow the user to create this tag? * + * Extensions should NOT use this function. In most cases, a tag can be + * defined using the ListDefinedTags hook without any checking. + * * @param string $tag Tag that you are interested in creating * @param User|null $user User whose permission you wish to check, or null if * you don't care (e.g. maintenance scripts) @@ -1092,6 +1109,9 @@ class ChangeTags { /** * Creates a tag by adding a row to the `valid_tag` table. * + * Extensions should NOT use this function; they can use the ListDefinedTags + * hook instead. + * * Includes a call to ChangeTag::canDeleteTag(), so your code doesn't need to * do that. * diff --git a/includes/collation/IcuCollation.php b/includes/collation/IcuCollation.php index 36efdb379b..9ac81ae01e 100644 --- a/includes/collation/IcuCollation.php +++ b/includes/collation/IcuCollation.php @@ -384,9 +384,17 @@ class IcuCollation extends Collation { foreach ( $letters as $letter ) { $key = $this->getPrimarySortKey( $letter ); if ( isset( $letterMap[$key] ) ) { - // Primary collision - // Keep whichever one sorts first in the main collator - if ( $this->mainCollator->compare( $letter, $letterMap[$key] ) < 0 ) { + // Primary collision (two characters with the same sort position). + // Keep whichever one sorts first in the main collator. + $comp = $this->mainCollator->compare( $letter, $letterMap[$key] ); + wfDebug( "Primary collision '$letter' '{$letterMap[$key]}' (comparison: $comp)\n" ); + // If that also has a collision, use codepoint as a tiebreaker. + if ( $comp === 0 ) { + // TODO Use <=> operator when PHP 7 is allowed. + $comp = UtfNormal\Utils::utf8ToCodepoint( $letter ) - + UtfNormal\Utils::utf8ToCodepoint( $letterMap[$key] ); + } + if ( $comp < 0 ) { $letterMap[$key] = $letter; } } else { diff --git a/includes/dao/DBAccessBase.php b/includes/dao/DBAccessBase.php index 3947f4b19c..beac91e0e1 100644 --- a/includes/dao/DBAccessBase.php +++ b/includes/dao/DBAccessBase.php @@ -1,5 +1,6 @@ wiki ); + $loadBalancer = $this->getLoadBalancer(); return $loadBalancer->getConnection( $id, $groups, $this->wiki ); } @@ -83,13 +84,14 @@ abstract class DBAccessBase implements IDBAccessObject { /** * Get the database type used for read operations. * - * @see wfGetLB + * @see MediaWikiServices::getDBLoadBalancer * * @since 1.21 * * @return LoadBalancer The database load balancer object */ public function getLoadBalancer() { - return wfGetLB( $this->wiki ); + $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory(); + return $lbFactory->getMainLB( $this->wiki ); } } diff --git a/includes/db/MWLBFactory.php b/includes/db/MWLBFactory.php index f0a17f7bf7..79f787dba4 100644 --- a/includes/db/MWLBFactory.php +++ b/includes/db/MWLBFactory.php @@ -31,6 +31,10 @@ use Wikimedia\Rdbms\DatabaseDomain; * @ingroup Database */ abstract class MWLBFactory { + + /** @var array Cache of already-logged deprecation messages */ + private static $loggedDeprecations = []; + /** * @param array $lbConf Config for LBFactory::__construct() * @param Config $mainConfig Main config object from MediaWikiServices @@ -57,6 +61,7 @@ abstract class MWLBFactory { 'connLogger' => LoggerFactory::getInstance( 'DBConnection' ), 'perfLogger' => LoggerFactory::getInstance( 'DBPerformance' ), 'errorLogger' => [ MWExceptionHandler::class, 'logException' ], + 'deprecationLogger' => [ static::class, 'logDeprecation' ], 'cliMode' => $wgCommandLineMode, 'hostname' => wfHostname(), 'readOnlyReason' => $readOnlyMode->getReason(), @@ -203,10 +208,8 @@ abstract class MWLBFactory { return $class; } - public static function setSchemaAliases( LBFactory $lbFactory ) { - $mainLB = $lbFactory->getMainLB(); - $masterType = $mainLB->getServerType( $mainLB->getWriterIndex() ); - if ( $masterType === 'mysql' ) { + public static function setSchemaAliases( LBFactory $lbFactory, Config $config ) { + if ( $config->get( 'DBtype' ) === 'mysql' ) { /** * When SQLite indexes were introduced in r45764, it was noted that * SQLite requires index names to be unique within the whole database, @@ -228,4 +231,22 @@ abstract class MWLBFactory { ] ); } } + + /** + * Log a database deprecation warning + * @param string $msg Deprecation message + */ + public static function logDeprecation( $msg ) { + global $wgDevelopmentWarnings; + + if ( isset( self::$loggedDeprecations[$msg] ) ) { + return; + } + self::$loggedDeprecations[$msg] = true; + + if ( $wgDevelopmentWarnings ) { + trigger_error( $msg, E_USER_DEPRECATED ); + } + wfDebugLog( 'deprecated', $msg, 'private' ); + } } diff --git a/includes/deferred/DeferredUpdates.php b/includes/deferred/DeferredUpdates.php index 9b25d53820..8543c4b1f4 100644 --- a/includes/deferred/DeferredUpdates.php +++ b/includes/deferred/DeferredUpdates.php @@ -36,11 +36,14 @@ use Wikimedia\Rdbms\LoadBalancer; * Updates that work through this system will be more likely to complete by the time the client * makes their next request after this one than with the JobQueue system. * - * In CLI mode, updates run immediately if no DB writes are pending. Otherwise, they run when: - * - a) Any waitForReplication() call if no writes are pending on any DB - * - b) A commit happens on Maintenance::getDB( DB_MASTER ) if no writes are pending on any DB - * - c) EnqueueableDataUpdate tasks may enqueue on commit of Maintenance::getDB( DB_MASTER ) - * - d) At the completion of Maintenance::execute() + * In CLI mode, deferred updates will run: + * - a) During DeferredUpdates::addUpdate if no LBFactory DB handles have writes pending + * - b) On commit of an LBFactory DB handle if no other such handles have writes pending + * - c) During an LBFactory::waitForReplication call if no LBFactory DBs have writes pending + * - d) When the queue is large and an LBFactory DB handle commits (EnqueueableDataUpdate only) + * - e) At the completion of Maintenance::execute() + * + * @see Maintenance::setLBFactoryTriggers * * When updates are deferred, they go into one two FIFO "top-queues" (one for pre-send and one * for post-send). Updates enqueued *during* doUpdate() of a "top" update go into the "sub-queue" @@ -206,23 +209,29 @@ class DeferredUpdates { foreach ( $updatesByType as $updatesForType ) { foreach ( $updatesForType as $update ) { self::$executeContext = [ 'stage' => $stage, 'subqueue' => [] ]; - /** @var DeferrableUpdate $update */ - $guiError = self::runUpdate( $update, $lbFactory, $mode, $stage ); - $reportableError = $reportableError ?: $guiError; - // Do the subqueue updates for $update until there are none - while ( self::$executeContext['subqueue'] ) { - $subUpdate = reset( self::$executeContext['subqueue'] ); - $firstKey = key( self::$executeContext['subqueue'] ); - unset( self::$executeContext['subqueue'][$firstKey] ); - - if ( $subUpdate instanceof DataUpdate ) { - $subUpdate->setTransactionTicket( $ticket ); - } - - $guiError = self::runUpdate( $subUpdate, $lbFactory, $mode, $stage ); + try { + /** @var DeferrableUpdate $update */ + $guiError = self::runUpdate( $update, $lbFactory, $mode, $stage ); $reportableError = $reportableError ?: $guiError; + // Do the subqueue updates for $update until there are none + while ( self::$executeContext['subqueue'] ) { + $subUpdate = reset( self::$executeContext['subqueue'] ); + $firstKey = key( self::$executeContext['subqueue'] ); + unset( self::$executeContext['subqueue'][$firstKey] ); + + if ( $subUpdate instanceof DataUpdate ) { + $subUpdate->setTransactionTicket( $ticket ); + } + + $guiError = self::runUpdate( $subUpdate, $lbFactory, $mode, $stage ); + $reportableError = $reportableError ?: $guiError; + } + } finally { + // Make sure we always clean up the context. + // Losing updates while rewinding the stack is acceptable, + // losing updates that are added later is not. + self::$executeContext = null; } - self::$executeContext = null; } } @@ -265,6 +274,12 @@ class DeferredUpdates { $guiError = $e; } MWExceptionHandler::rollbackMasterChangesAndLog( $e ); + + // VW-style hack to work around T190178, so we can make sure + // PageMetaDataUpdater doesn't throw exceptions. + if ( defined( 'MW_PHPUNIT_TEST' ) ) { + throw $e; + } } return $guiError; @@ -273,8 +288,9 @@ class DeferredUpdates { /** * Run all deferred updates immediately if there are no DB writes active * - * If $mode is 'run' but there are busy databates, EnqueueableDataUpdate - * tasks will be enqueued anyway for the sake of progress. + * If there are many deferred updates pending, $mode is 'run', and there + * are still busy LBFactory database handles, then any EnqueueableDataUpdate + * tasks might be enqueued as jobs to be executed later. * * @param string $mode Use "enqueue" to use the job queue when possible * @return bool Whether updates were allowed to run @@ -361,7 +377,7 @@ class DeferredUpdates { */ private static function areDatabaseTransactionsActive() { $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory(); - if ( $lbFactory->hasTransactionRound() ) { + if ( $lbFactory->hasTransactionRound() || !$lbFactory->isReadyForRoundOperations() ) { return true; } diff --git a/includes/deferred/LinksDeletionUpdate.php b/includes/deferred/LinksDeletionUpdate.php index 52e996a047..3c86d11ac6 100644 --- a/includes/deferred/LinksDeletionUpdate.php +++ b/includes/deferred/LinksDeletionUpdate.php @@ -96,13 +96,12 @@ class LinksDeletionUpdate extends DataUpdate implements EnqueueableDataUpdate { } } - // Refresh the category table entry if it seems to have no pages. Check - // master for the most up-to-date cat_pages count. + // Refresh counts on categories that should be empty now if ( $title->getNamespace() === NS_CATEGORY ) { $row = $dbw->selectRow( 'category', [ 'cat_id', 'cat_title', 'cat_pages', 'cat_subcats', 'cat_files' ], - [ 'cat_title' => $title->getDBkey(), 'cat_pages <= 0' ], + [ 'cat_title' => $title->getDBkey(), 'cat_pages <= 100' ], __METHOD__ ); if ( $row ) { diff --git a/includes/deferred/LinksUpdate.php b/includes/deferred/LinksUpdate.php index 8913642891..4ddd15117b 100644 --- a/includes/deferred/LinksUpdate.php +++ b/includes/deferred/LinksUpdate.php @@ -177,15 +177,16 @@ class LinksUpdate extends DataUpdate implements EnqueueableDataUpdate { // Commit and release the lock (if set) ScopedCallback::consume( $scopedLock ); - // Run post-commit hooks without DBO_TRX - $this->getDB()->onTransactionIdle( + // Run post-commit hook handlers without DBO_TRX + DeferredUpdates::addUpdate( new AutoCommitUpdate( + $this->getDB(), + __METHOD__, function () { // Avoid PHP 7.1 warning from passing $this by reference $linksUpdate = $this; Hooks::run( 'LinksUpdateComplete', [ &$linksUpdate, $this->ticket ] ); - }, - __METHOD__ - ); + } + ) ); } /** diff --git a/includes/deferred/SqlDataUpdate.php b/includes/deferred/SqlDataUpdate.php deleted file mode 100644 index 2411beff89..0000000000 --- a/includes/deferred/SqlDataUpdate.php +++ /dev/null @@ -1,40 +0,0 @@ -mDb = wfGetLB()->getLazyConnectionRef( DB_MASTER ); - } -} diff --git a/includes/deferred/TransactionRoundDefiningUpdate.php b/includes/deferred/TransactionRoundDefiningUpdate.php index 65baec5d51..a32d4a0703 100644 --- a/includes/deferred/TransactionRoundDefiningUpdate.php +++ b/includes/deferred/TransactionRoundDefiningUpdate.php @@ -1,8 +1,7 @@ getUser(); @@ -542,7 +542,7 @@ class DifferenceEngine extends ContextSource { [ 'rc_timestamp' => $db->timestamp( $this->mNewRev->getTimestamp() ), 'rc_this_oldid' => $this->mNewid, - 'rc_patrolled' => 0 + 'rc_patrolled' => RecentChange::PRC_UNPATROLLED ], __METHOD__ ); @@ -564,9 +564,7 @@ class DifferenceEngine extends ContextSource { // Build the link if ( $rcid ) { $this->getOutput()->preventClickjacking(); - if ( $wgEnableAPI && $wgEnableWriteAPI - && $user->isAllowed( 'writeapi' ) - ) { + if ( $user->isAllowed( 'writeapi' ) ) { $this->getOutput()->addModules( 'mediawiki.page.patrol.ajax' ); } diff --git a/includes/diff/WordAccumulator.php b/includes/diff/WordAccumulator.php index ad802756ef..88631ed415 100644 --- a/includes/diff/WordAccumulator.php +++ b/includes/diff/WordAccumulator.php @@ -89,6 +89,8 @@ class WordAccumulator { $this->flushLine( $tag ); $word = substr( $word, 1 ); } + // FIXME: Don't use assert() + // phpcs:ignore MediaWiki.Usage.ForbiddenFunctions.assert assert( !strstr( $word, "\n" ) ); $this->group .= $word; } diff --git a/includes/filebackend/FileBackendGroup.php b/includes/filebackend/FileBackendGroup.php index 9239c6cc34..454b6332fd 100644 --- a/includes/filebackend/FileBackendGroup.php +++ b/includes/filebackend/FileBackendGroup.php @@ -112,7 +112,7 @@ class FileBackendGroup { /** * Register an array of file backend configurations * - * @param array $configs + * @param array[] $configs * @param string|null $readOnlyReason * @throws InvalidArgumentException */ diff --git a/includes/filebackend/filejournal/DBFileJournal.php b/includes/filebackend/filejournal/DBFileJournal.php index 4269f91ef9..3dc9f18ec6 100644 --- a/includes/filebackend/filejournal/DBFileJournal.php +++ b/includes/filebackend/filejournal/DBFileJournal.php @@ -124,9 +124,9 @@ class DBFileJournal extends FileJournal { /** * @see FileJournal::doGetChangeEntries() - * @param int $start + * @param int|null $start * @param int $limit - * @return array + * @return array[] */ protected function doGetChangeEntries( $start, $limit ) { $dbw = $this->getMasterDB(); diff --git a/includes/filerepo/FileBackendDBRepoWrapper.php b/includes/filerepo/FileBackendDBRepoWrapper.php index 21b7ac2fae..dbb542172d 100644 --- a/includes/filerepo/FileBackendDBRepoWrapper.php +++ b/includes/filerepo/FileBackendDBRepoWrapper.php @@ -92,9 +92,9 @@ class FileBackendDBRepoWrapper extends FileBackend { * E.g. mwstore://local-backend/local-public/a/ab/.jpg * => mwstore://local-backend/local-original/x/y/z/.jpg * - * @param array $paths + * @param string[] $paths * @param bool $latest - * @return array Translated paths in same order + * @return string[] Translated paths in same order */ public function getBackendPaths( array $paths, $latest = true ) { $db = $this->getDB( $latest ? DB_MASTER : DB_REPLICA ); @@ -341,8 +341,8 @@ class FileBackendDBRepoWrapper extends FileBackend { * * This leaves destination paths alone since we don't want those to mutate * - * @param array $ops - * @return array + * @param array[] $ops + * @return array[] */ protected function mungeOpPaths( array $ops ) { // Ops that use 'src' and do not mutate core file data there diff --git a/includes/filerepo/FileRepo.php b/includes/filerepo/FileRepo.php index e430bc8f43..b15f81fafe 100644 --- a/includes/filerepo/FileRepo.php +++ b/includes/filerepo/FileRepo.php @@ -78,10 +78,6 @@ class FileRepo { */ protected $scriptDirUrl; - /** @var string Script extension of the MediaWiki installation, equivalent - * to the old $wgScriptExtension, e.g. .php5 defaults to .php */ - protected $scriptExtension; - /** @var string Equivalent to $wgArticlePath, e.g. https://en.wikipedia.org/wiki/$1 */ protected $articleUrl; @@ -166,7 +162,7 @@ class FileRepo { $optionalSettings = [ 'descBaseUrl', 'scriptDirUrl', 'articleUrl', 'fetchDescription', 'thumbScriptUrl', 'pathDisclosureProtection', 'descriptionCacheExpiry', - 'scriptExtension', 'favicon', 'thumbProxyUrl', 'thumbProxySecret' + 'favicon', 'thumbProxyUrl', 'thumbProxySecret', ]; foreach ( $optionalSettings as $var ) { if ( isset( $info[$var] ) ) { @@ -582,8 +578,8 @@ class FileRepo { * Get an array of arrays or iterators of file objects for files that * have the given SHA-1 content hashes. * - * @param array $hashes An array of hashes - * @return array An Array of arrays or iterators of file objects and the hash as key + * @param string[] $hashes An array of hashes + * @return array[] An Array of arrays or iterators of file objects and the hash as key */ public function findBySha1s( array $hashes ) { $result = []; @@ -603,7 +599,7 @@ class FileRepo { * STUB * @param string $prefix The prefix to search for * @param int $limit The maximum amount of files to return - * @return array + * @return LocalFile[] */ public function findFilesByPrefix( $prefix, $limit ) { return []; @@ -744,9 +740,7 @@ class FileRepo { */ public function makeUrl( $query = '', $entry = 'index' ) { if ( isset( $this->scriptDirUrl ) ) { - $ext = isset( $this->scriptExtension ) ? $this->scriptExtension : '.php'; - - return wfAppendQuery( "{$this->scriptDirUrl}/{$entry}{$ext}", $query ); + return wfAppendQuery( "{$this->scriptDirUrl}/{$entry}.php", $query ); } return false; @@ -795,7 +789,7 @@ class FileRepo { * should use File::getDescriptionText(). * * @param string $name Name of image to fetch - * @param string $lang Language to fetch it in, if any. + * @param string|null $lang Language to fetch it in, if any. * @return string|false */ public function getDescriptionRenderUrl( $name, $lang = null ) { @@ -934,7 +928,7 @@ class FileRepo { * Each file can be a (zone, rel) pair, virtual url, storage path. * It will try to delete each file, but ignores any errors that may occur. * - * @param array $files List of files to delete + * @param string[] $files List of files to delete * @param int $flags Bitwise combination of the following flags: * self::SKIP_LOCKING Skip any file locking when doing the deletions * @return Status @@ -1384,7 +1378,7 @@ class FileRepo { /** * Checks existence of an array of files. * - * @param array $files Virtual URLs (or storage paths) of files to check + * @param string[] $files Virtual URLs (or storage paths) of files to check * @return array Map of files and existence flags, or false */ public function fileExistsBatch( array $files ) { @@ -1490,7 +1484,7 @@ class FileRepo { * Delete files in the deleted directory if they are not referenced in the filearchive table * * STUB - * @param array $storageKeys + * @param string[] $storageKeys */ public function cleanupDeletedBatch( array $storageKeys ) { $this->assertWritableRepo(); @@ -1634,7 +1628,11 @@ class FileRepo { $status = $this->newGood(); $status->merge( $this->backend->streamFile( $params ) ); - ob_end_flush(); + // T186565: Close the buffer, unless it has already been closed + // in HTTPFileStreamer::resetOutputBuffers(). + if ( ob_get_status() ) { + ob_end_flush(); + } return $status; } @@ -1706,7 +1704,7 @@ class FileRepo { /** * Get a callback function to use for cleaning error message parameters * - * @return array + * @return string[] */ function getErrorCleanupFunction() { switch ( $this->pathDisclosureProtection ) { @@ -1895,7 +1893,7 @@ class FileRepo { /** * Get an UploadStash associated with this repo. * - * @param User $user + * @param User|null $user * @return UploadStash */ public function getUploadStash( User $user = null ) { @@ -1928,7 +1926,7 @@ class FileRepo { $optionalSettings = [ 'url', 'thumbUrl', 'initialCapital', 'descBaseUrl', 'scriptDirUrl', 'articleUrl', - 'fetchDescription', 'descriptionCacheExpiry', 'scriptExtension', 'favicon' + 'fetchDescription', 'descriptionCacheExpiry', 'favicon' ]; foreach ( $optionalSettings as $k ) { if ( isset( $this->$k ) ) { diff --git a/includes/filerepo/ForeignAPIRepo.php b/includes/filerepo/ForeignAPIRepo.php index 1a86648889..cba21c8870 100644 --- a/includes/filerepo/ForeignAPIRepo.php +++ b/includes/filerepo/ForeignAPIRepo.php @@ -22,6 +22,7 @@ */ use MediaWiki\Logger\LoggerFactory; +use MediaWiki\MediaWikiServices; /** * A foreign repository with a remote MediaWiki with an API thingy @@ -120,7 +121,7 @@ class ForeignAPIRepo extends FileRepo { } /** - * @param array $files + * @param string[] $files * @return array */ function fileExistsBatch( array $files ) { @@ -176,7 +177,7 @@ class ForeignAPIRepo extends FileRepo { /** * @param string $virtualUrl - * @return bool + * @return false */ function getFileProps( $virtualUrl ) { return false; @@ -231,7 +232,7 @@ class ForeignAPIRepo extends FileRepo { /** * @param string $hash - * @return array + * @return ForeignAPIFile[] */ function findBySha1( $hash ) { $results = $this->fetchImageQuery( [ @@ -257,10 +258,10 @@ class ForeignAPIRepo extends FileRepo { * @param string $name * @param int $width * @param int $height - * @param array &$result + * @param array|null &$result Output-only parameter, guaranteed to become an array * @param string $otherParams * - * @return bool + * @return string|false */ function getThumbUrl( $name, $width = -1, $height = -1, &$result = null, $otherParams = '' ) { $data = $this->fetchImageQuery( [ @@ -287,7 +288,7 @@ class ForeignAPIRepo extends FileRepo { * @param int $width * @param int $height * @param string $otherParams - * @param string $lang Language code for language of error + * @param string|null $lang Language code for language of error * @return bool|MediaTransformError * @since 1.22 */ @@ -332,7 +333,7 @@ class ForeignAPIRepo extends FileRepo { * @return bool|string */ function getThumbUrlFromCache( $name, $width, $height, $params = "" ) { - $cache = ObjectCache::getMainWANInstance(); + $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); // We can't check the local cache using FileRepo functions because // we override fileExistsBatch(). We have to use the FileBackend directly. $backend = $this->getBackend(); // convenience @@ -569,7 +570,7 @@ class ForeignAPIRepo extends FileRepo { $url = $this->makeUrl( $query, 'api' ); } - $cache = ObjectCache::getMainWANInstance(); + $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); return $cache->getWithSetCallback( $this->getLocalCacheKey( static::class, $target, md5( $url ) ), $cacheTTL, diff --git a/includes/filerepo/ForeignDBViaLBRepo.php b/includes/filerepo/ForeignDBViaLBRepo.php index 249cd27cfe..302b194a2c 100644 --- a/includes/filerepo/ForeignDBViaLBRepo.php +++ b/includes/filerepo/ForeignDBViaLBRepo.php @@ -21,6 +21,10 @@ * @ingroup FileRepo */ +use MediaWiki\MediaWikiServices; +use Wikimedia\Rdbms\IDatabase; +use Wikimedia\Rdbms\LoadBalancer; + /** * A foreign repository with a MediaWiki database accessible via the configured LBFactory * @@ -59,14 +63,14 @@ class ForeignDBViaLBRepo extends LocalRepo { * @return IDatabase */ function getMasterDB() { - return wfGetLB( $this->wiki )->getConnectionRef( DB_MASTER, [], $this->wiki ); + return $this->getDBLoadBalancer()->getConnectionRef( DB_MASTER, [], $this->wiki ); } /** * @return IDatabase */ function getReplicaDB() { - return wfGetLB( $this->wiki )->getConnectionRef( DB_REPLICA, [], $this->wiki ); + return $this->getDBLoadBalancer()->getConnectionRef( DB_REPLICA, [], $this->wiki ); } /** @@ -74,10 +78,18 @@ class ForeignDBViaLBRepo extends LocalRepo { */ protected function getDBFactory() { return function ( $index ) { - return wfGetLB( $this->wiki )->getConnectionRef( $index, [], $this->wiki ); + return $this->getDBLoadBalancer()->getConnectionRef( $index, [], $this->wiki ); }; } + /** + * @return LoadBalancer + */ + protected function getDBLoadBalancer() { + $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory(); + return $lbFactory->getMainLB( $this->wiki ); + } + function hasSharedCache() { return $this->hasSharedCache; } diff --git a/includes/filerepo/LocalRepo.php b/includes/filerepo/LocalRepo.php index 1bf534649f..03a9d44168 100644 --- a/includes/filerepo/LocalRepo.php +++ b/includes/filerepo/LocalRepo.php @@ -22,6 +22,7 @@ * @ingroup FileRepo */ +use MediaWiki\MediaWikiServices; use Wikimedia\Rdbms\ResultWrapper; use Wikimedia\Rdbms\Database; use Wikimedia\Rdbms\IDatabase; @@ -91,7 +92,7 @@ class LocalRepo extends FileRepo { * interleave database locks with file operations, which is potentially a * remote operation. * - * @param array $storageKeys + * @param string[] $storageKeys * * @return Status */ @@ -200,7 +201,7 @@ class LocalRepo extends FileRepo { } $method = __METHOD__; - $redirDbKey = ObjectCache::getMainWANInstance()->getWithSetCallback( + $redirDbKey = MediaWikiServices::getInstance()->getMainWANObjectCache()->getWithSetCallback( $memcKey, $expiry, function ( $oldValue, &$ttl, array &$setOpts ) use ( $method, $title ) { @@ -371,7 +372,7 @@ class LocalRepo extends FileRepo { * SHA-1 content hash. * * @param string $hash A sha1 hash to look for - * @return File[] + * @return LocalFile[] */ function findBySha1( $hash ) { $dbr = $this->getReplicaDB(); @@ -400,8 +401,8 @@ class LocalRepo extends FileRepo { * * Overrides generic implementation in FileRepo for performance reason * - * @param array $hashes An array of hashes - * @return array An Array of arrays or iterators of file objects and the hash as key + * @param string[] $hashes An array of hashes + * @return array[] An Array of arrays or iterators of file objects and the hash as key */ function findBySha1s( array $hashes ) { if ( !count( $hashes ) ) { @@ -434,7 +435,7 @@ class LocalRepo extends FileRepo { * * @param string $prefix The prefix to search for * @param int $limit The maximum amount of files to return - * @return array + * @return LocalFile[] */ public function findFilesByPrefix( $prefix, $limit ) { $selectOptions = [ 'ORDER BY' => 'img_name', 'LIMIT' => intval( $limit ) ]; @@ -520,7 +521,7 @@ class LocalRepo extends FileRepo { if ( $key ) { $this->getMasterDB()->onTransactionPreCommitOrIdle( function () use ( $key ) { - ObjectCache::getMainWANInstance()->delete( $key ); + MediaWikiServices::getInstance()->getMainWANObjectCache()->delete( $key ); }, __METHOD__ ); diff --git a/includes/filerepo/RepoGroup.php b/includes/filerepo/RepoGroup.php index 24d1ab2efe..b7977900a3 100644 --- a/includes/filerepo/RepoGroup.php +++ b/includes/filerepo/RepoGroup.php @@ -423,7 +423,7 @@ class RepoGroup { * Split a virtual URL into repo, zone and rel parts * @param string $url * @throws MWException - * @return array Containing repo, zone and rel + * @return string[] Containing repo, zone and rel */ function splitVirtualUrl( $url ) { if ( substr( $url, 0, 9 ) != 'mwrepo://' ) { diff --git a/includes/filerepo/file/ArchivedFile.php b/includes/filerepo/file/ArchivedFile.php index 982fea49cf..65e43459fe 100644 --- a/includes/filerepo/file/ArchivedFile.php +++ b/includes/filerepo/file/ArchivedFile.php @@ -21,6 +21,8 @@ * @ingroup FileAbstraction */ +use MediaWiki\MediaWikiServices; + /** * Class representing a row of the 'filearchive' table * @@ -214,7 +216,7 @@ class ArchivedFile { /** * Fields in the filearchive table * @deprecated since 1.31, use self::getQueryInfo() instead. - * @return array + * @return string[] */ static function selectFields() { global $wgActorTableSchemaMigrationStage; @@ -251,20 +253,20 @@ class ArchivedFile { 'fa_deleted', 'fa_deleted_timestamp', /* Used by LocalFileRestoreBatch */ 'fa_sha1', - ] + CommentStore::getStore()->getFields( 'fa_description' ); + ] + MediaWikiServices::getInstance()->getCommentStore()->getFields( 'fa_description' ); } /** * Return the tables, fields, and join conditions to be selected to create * a new archivedfile object. * @since 1.31 - * @return array With three keys: + * @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 static function getQueryInfo() { - $commentQuery = CommentStore::getStore()->getJoin( 'fa_description' ); + $commentQuery = MediaWikiServices::getInstance()->getCommentStore()->getJoin( 'fa_description' ); $actorQuery = ActorMigration::newMigration()->getJoin( 'fa_user' ); return [ 'tables' => [ 'filearchive' ] + $commentQuery['tables'] + $actorQuery['tables'], @@ -310,7 +312,7 @@ class ArchivedFile { $this->metadata = $row->fa_metadata; $this->mime = "$row->fa_major_mime/$row->fa_minor_mime"; $this->media_type = $row->fa_media_type; - $this->description = CommentStore::getStore() + $this->description = MediaWikiServices::getInstance()->getCommentStore() // Legacy because $row may have come from self::selectFields() ->getCommentLegacy( wfGetDB( DB_REPLICA ), 'fa_description', $row )->text; $this->user = User::newFromAnyId( $row->fa_user, $row->fa_user_text, $row->fa_actor ); diff --git a/includes/filerepo/file/File.php b/includes/filerepo/file/File.php index d4605d3ca8..7c87af3974 100644 --- a/includes/filerepo/file/File.php +++ b/includes/filerepo/file/File.php @@ -268,7 +268,7 @@ abstract class File implements IDBAccessObject { * a two-part name, set the minor type to 'unknown'. * * @param string $mime "text/html" etc - * @return array ("text", "html") etc + * @return string[] ("text", "html") etc */ public static function splitMime( $mime ) { if ( strpos( $mime, '/' ) !== false ) { @@ -569,7 +569,7 @@ abstract class File implements IDBAccessObject { * format does not support that sort of thing, returns * an empty array. * - * @return array + * @return string[] * @since 1.23 */ public function getAvailableLanguages() { @@ -1423,7 +1423,7 @@ abstract class File implements IDBAccessObject { * Get all thumbnail names previously generated for this file * STUB * Overridden by LocalFile - * @return array + * @return string[] */ function getThumbnails() { return []; @@ -1474,9 +1474,9 @@ abstract class File implements IDBAccessObject { * Return a fragment of the history of file. * * STUB - * @param int $limit Limit of rows to return - * @param string $start Only revisions older than $start will be returned - * @param string $end Only revisions newer than $end will be returned + * @param int|null $limit Limit of rows to return + * @param string|int|null $start Only revisions older than $start will be returned + * @param string|int|null $end Only revisions newer than $end will be returned * @param bool $inc Include the endpoints of the time range * * @return File[] @@ -2065,7 +2065,7 @@ abstract class File implements IDBAccessObject { $renderUrl = $this->repo->getDescriptionRenderUrl( $this->getName(), $lang->getCode() ); if ( $renderUrl ) { - $cache = ObjectCache::getMainWANInstance(); + $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); $key = $this->repo->getLocalCacheKey( 'RemoteFileDescription', 'url', @@ -2099,9 +2099,9 @@ abstract class File implements IDBAccessObject { * File::FOR_PUBLIC to be displayed to all users * File::FOR_THIS_USER to be displayed to the given user * File::RAW get the description regardless of permissions - * @param User $user User object to check for, only if FOR_THIS_USER is + * @param User|null $user User object to check for, only if FOR_THIS_USER is * passed to the $audience parameter - * @return string + * @return null|string */ function getDescription( $audience = self::FOR_PUBLIC, User $user = null ) { return null; @@ -2161,7 +2161,7 @@ abstract class File implements IDBAccessObject { * field of this file, if it's marked as deleted. * STUB * @param int $field - * @param User $user User object to check, or null to use $wgUser + * @param User|null $user User object to check, or null to use $wgUser * @return bool */ function userCan( $field, User $user = null ) { @@ -2177,7 +2177,7 @@ abstract class File implements IDBAccessObject { } /** - * @return array HTTP header name/value map to use for HEAD/GET request responses + * @return string[] HTTP header name/value map to use for HEAD/GET request responses * @since 1.30 */ function getContentHeaders() { diff --git a/includes/filerepo/file/ForeignAPIFile.php b/includes/filerepo/file/ForeignAPIFile.php index 2a40942527..1002b82c52 100644 --- a/includes/filerepo/file/ForeignAPIFile.php +++ b/includes/filerepo/file/ForeignAPIFile.php @@ -21,6 +21,8 @@ * @ingroup FileAbstraction */ +use MediaWiki\MediaWikiServices; + /** * Foreign file accessible through api.php requests. * Very hacky and inefficient, do not use :D @@ -33,7 +35,7 @@ class ForeignAPIFile extends File { /** @var array */ private $mInfo = []; - protected $repoClass = ForeignApiRepo::class; + protected $repoClass = ForeignAPIRepo::class; /** * @param Title|string|bool $title @@ -192,8 +194,8 @@ class ForeignAPIFile extends File { } /** - * @param array $metadata - * @return array + * @param mixed $metadata + * @return mixed */ public static function parseMetadata( $metadata ) { if ( !is_array( $metadata ) ) { @@ -254,7 +256,7 @@ class ForeignAPIFile extends File { /** * @param int $audience - * @param User $user + * @param User|null $user * @return null|string */ public function getDescription( $audience = self::FOR_PUBLIC, User $user = null ) { @@ -333,7 +335,7 @@ class ForeignAPIFile extends File { } /** - * @return array + * @return string[] */ function getThumbnails() { $dir = $this->getThumbPath( $this->getName() ); @@ -360,7 +362,7 @@ class ForeignAPIFile extends File { $url = $this->repo->getDescriptionRenderUrl( $this->getName(), $wgContLang->getCode() ); $key = $this->repo->getLocalCacheKey( 'RemoteFileDescription', 'url', md5( $url ) ); - ObjectCache::getMainWANInstance()->delete( $key ); + MediaWikiServices::getInstance()->getMainWANObjectCache()->delete( $key ); } /** @@ -368,7 +370,7 @@ class ForeignAPIFile extends File { */ function purgeThumbnails( $options = [] ) { $key = $this->repo->getLocalCacheKey( 'ForeignAPIRepo', 'ThumbUrl', $this->getName() ); - ObjectCache::getMainWANInstance()->delete( $key ); + MediaWikiServices::getInstance()->getMainWANObjectCache()->delete( $key ); $files = $this->getThumbnails(); // Give media handler a chance to filter the purge list diff --git a/includes/filerepo/file/ForeignDBFile.php b/includes/filerepo/file/ForeignDBFile.php index cf211618fc..05df45b32f 100644 --- a/includes/filerepo/file/ForeignDBFile.php +++ b/includes/filerepo/file/ForeignDBFile.php @@ -21,6 +21,7 @@ * @ingroup FileAbstraction */ +use MediaWiki\MediaWikiServices; use Wikimedia\Rdbms\DBUnexpectedError; /** @@ -74,7 +75,7 @@ class ForeignDBFile extends LocalFile { * @param string $source * @param bool $watch * @param bool|string $timestamp - * @param User $user User object or null to use $wgUser + * @param User|null $user User object or null to use $wgUser * @return bool * @throws MWException */ @@ -150,7 +151,7 @@ class ForeignDBFile extends LocalFile { return false; // no description page } - $cache = ObjectCache::getMainWANInstance(); + $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); return $cache->getWithSetCallback( $this->repo->getLocalCacheKey( diff --git a/includes/filerepo/file/LocalFile.php b/includes/filerepo/file/LocalFile.php index 7fc45ebf4a..c078e90dfd 100644 --- a/includes/filerepo/file/LocalFile.php +++ b/includes/filerepo/file/LocalFile.php @@ -141,7 +141,7 @@ class LocalFile extends File { * @param FileRepo $repo * @param null $unused * - * @return LocalFile + * @return self */ static function newFromTitle( $title, $repo, $unused = null ) { return new self( $title, $repo ); @@ -154,7 +154,7 @@ class LocalFile extends File { * @param stdClass $row * @param FileRepo $repo * - * @return LocalFile + * @return self */ static function newFromRow( $row, $repo ) { $title = Title::makeTitle( NS_FILE, $row->img_name ); @@ -195,7 +195,7 @@ class LocalFile extends File { /** * Fields in the image table * @deprecated since 1.31, use self::getQueryInfo() instead. - * @return array + * @return string[] */ static function selectFields() { global $wgActorTableSchemaMigrationStage; @@ -226,7 +226,7 @@ class LocalFile extends File { 'img_actor' => $wgActorTableSchemaMigrationStage > MIGRATION_OLD ? 'img_actor' : null, 'img_timestamp', 'img_sha1', - ] + CommentStore::getStore()->getFields( 'img_description' ); + ] + MediaWikiServices::getInstance()->getCommentStore()->getFields( 'img_description' ); } /** @@ -235,13 +235,13 @@ class LocalFile extends File { * @since 1.31 * @param string[] $options * - omit-lazy: Omit fields that are lazily cached. - * @return array With three keys: + * @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 static function getQueryInfo( array $options = [] ) { - $commentQuery = CommentStore::getStore()->getJoin( 'img_description' ); + $commentQuery = MediaWikiServices::getInstance()->getCommentStore()->getJoin( 'img_description' ); $actorQuery = ActorMigration::newMigration()->getJoin( 'img_user' ); $ret = [ 'tables' => [ 'image' ] + $commentQuery['tables'] + $actorQuery['tables'], @@ -323,7 +323,7 @@ class LocalFile extends File { return; } - $cache = ObjectCache::getMainWANInstance(); + $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); $cachedValues = $cache->getWithSetCallback( $key, $cache::TTL_WEEK, @@ -388,7 +388,7 @@ class LocalFile extends File { $this->repo->getMasterDB()->onTransactionPreCommitOrIdle( function () use ( $key ) { - ObjectCache::getMainWANInstance()->delete( $key ); + MediaWikiServices::getInstance()->getMainWANObjectCache()->delete( $key ); }, __METHOD__ ); @@ -405,7 +405,7 @@ class LocalFile extends File { /** * Returns the list of object properties that are included as-is in the cache. * @param string $prefix Must be the empty string - * @return array + * @return string[] * @since 1.31 No longer accepts a non-empty $prefix */ protected function getCacheFields( $prefix = 'img_' ) { @@ -427,7 +427,7 @@ class LocalFile extends File { * Returns the list of object properties that are included as-is in the * cache, only when they're not too big, and are lazily loaded by self::loadExtraFromDB(). * @param string $prefix Must be the empty string - * @return array + * @return string[] * @since 1.31 No longer accepts a non-empty $prefix */ protected function getLazyCacheFields( $prefix = 'img_' ) { @@ -500,7 +500,7 @@ class LocalFile extends File { /** * @param IDatabase $dbr * @param string $fname - * @return array|bool + * @return string[]|bool */ private function loadExtraFieldsWithTimestamp( $dbr, $fname ) { $fieldMap = false; @@ -579,7 +579,7 @@ class LocalFile extends File { function decodeRow( $row, $prefix = 'img_' ) { $decoded = $this->unprefixRow( $row, $prefix ); - $decoded['description'] = CommentStore::getStore() + $decoded['description'] = MediaWikiServices::getInstance()->getCommentStore() ->getComment( 'description', (object)$decoded )->text; $decoded['user'] = User::newFromAnyId( @@ -1160,9 +1160,9 @@ class LocalFile extends File { /** purgeEverything inherited */ /** - * @param int $limit Optional: Limit to number of results - * @param int $start Optional: Timestamp, start from - * @param int $end Optional: Timestamp, end at + * @param int|null $limit Optional: Limit to number of results + * @param string|int|null $start Optional: Timestamp, start from + * @param string|int|null $end Optional: Timestamp, end at * @param bool $inc * @return OldLocalFile[] */ @@ -1321,7 +1321,7 @@ class LocalFile extends File { ) { $props = $this->repo->getFileProps( $srcPath ); } else { - $mwProps = new MWFileProps( MediaWiki\MediaWikiServices::getInstance()->getMimeAnalyzer() ); + $mwProps = new MWFileProps( MediaWikiServices::getInstance()->getMimeAnalyzer() ); $props = $mwProps->getPropsFromPath( $srcPath, true ); } } @@ -1462,7 +1462,7 @@ class LocalFile extends File { # Test to see if the row exists using INSERT IGNORE # This avoids race conditions by locking the row until the commit, and also # doesn't deadlock. SELECT FOR UPDATE causes a deadlock for every race condition. - $commentStore = CommentStore::getStore(); + $commentStore = MediaWikiServices::getInstance()->getCommentStore(); list( $commentFields, $commentCallback ) = $commentStore->insertWithTempTable( $dbw, 'img_description', $comment ); $actorMigration = ActorMigration::newMigration(); @@ -2105,8 +2105,8 @@ class LocalFile extends File { * This is not used by ImagePage for local files, since (among other things) * it skips the parser cache. * - * @param Language $lang What language to get description in (Optional) - * @return bool|mixed + * @param Language|null $lang What language to get description in (Optional) + * @return string|false */ function getDescriptionText( $lang = null ) { $revision = Revision::newFromTitle( $this->title, false, Revision::READ_NORMAL ); @@ -2124,7 +2124,7 @@ class LocalFile extends File { /** * @param int $audience - * @param User $user + * @param User|null $user * @return string */ function getDescription( $audience = self::FOR_PUBLIC, User $user = null ) { @@ -2369,7 +2369,7 @@ class LocalFileDeleteBatch { /** * Add the old versions of the image to the batch - * @return array List of archive names from old versions + * @return string[] List of archive names from old versions */ public function addOlds() { $archiveNames = []; @@ -2470,7 +2470,7 @@ class LocalFileDeleteBatch { $now = time(); $dbw = $this->file->repo->getMasterDB(); - $commentStore = CommentStore::getStore(); + $commentStore = MediaWikiServices::getInstance()->getCommentStore(); $actorMigration = ActorMigration::newMigration(); $encTimestamp = $dbw->addQuotes( $dbw->timestamp( $now ) ); @@ -2762,10 +2762,10 @@ class LocalFileRestoreBatch { /** @var LocalFile */ private $file; - /** @var array List of file IDs to restore */ + /** @var string[] List of file IDs to restore */ private $cleanupBatch; - /** @var array List of file IDs to restore */ + /** @var string[] List of file IDs to restore */ private $ids; /** @var bool Add all revisions of the file */ @@ -2780,7 +2780,7 @@ class LocalFileRestoreBatch { */ function __construct( File $file, $unsuppress = false ) { $this->file = $file; - $this->cleanupBatch = $this->ids = []; + $this->cleanupBatch = []; $this->ids = []; $this->unsuppress = $unsuppress; } @@ -2830,7 +2830,7 @@ class LocalFileRestoreBatch { $dbw = $this->file->repo->getMasterDB(); - $commentStore = CommentStore::getStore(); + $commentStore = MediaWikiServices::getInstance()->getCommentStore(); $actorMigration = ActorMigration::newMigration(); $status = $this->file->repo->newGood(); @@ -3097,8 +3097,8 @@ class LocalFileRestoreBatch { /** * Removes non-existent files from a cleanup batch. - * @param array $batch - * @return array + * @param string[] $batch + * @return string[] */ protected function removeNonexistentFromCleanup( $batch ) { $files = $newBatch = []; @@ -3142,7 +3142,7 @@ class LocalFileRestoreBatch { * rollback by removing all items that were succesfully copied. * * @param Status $storeStatus - * @param array $storeBatch + * @param array[] $storeBatch */ protected function cleanupFailedBatch( $storeStatus, $storeBatch ) { $cleanupBatch = []; @@ -3208,7 +3208,7 @@ class LocalFileMoveBatch { /** * Add the old versions of the image to the batch - * @return array List of archive names from old versions + * @return string[] List of archive names from old versions */ public function addOlds() { $archiveBase = 'archive'; @@ -3344,9 +3344,9 @@ class LocalFileMoveBatch { __METHOD__, [ 'FOR UPDATE' ] ); - $oldRowCount = $dbw->selectField( + $oldRowCount = $dbw->selectRowCount( 'oldimage', - 'COUNT(*)', + '*', [ 'oi_name' => $this->oldName ], __METHOD__, [ 'FOR UPDATE' ] @@ -3409,7 +3409,7 @@ class LocalFileMoveBatch { /** * Generate triplets for FileRepo::storeBatch(). - * @return array + * @return array[] */ protected function getMoveTriplets() { $moves = array_merge( [ $this->cur ], $this->olds ); @@ -3461,7 +3461,7 @@ class LocalFileMoveBatch { /** * Cleanup a partially moved array of triplets by deleting the target * files. Called if something went wrong half way. - * @param array $triplets + * @param array[] $triplets */ protected function cleanupTarget( $triplets ) { // Create dest pairs from the triplets @@ -3477,7 +3477,7 @@ class LocalFileMoveBatch { /** * Cleanup a fully moved array of triplets by deleting the source files. * Called at the end of the move process if everything else went ok. - * @param array $triplets + * @param array[] $triplets */ protected function cleanupSource( $triplets ) { // Create source file names from the triplets diff --git a/includes/filerepo/file/OldLocalFile.php b/includes/filerepo/file/OldLocalFile.php index 65f0fb1b26..aa434d0c8f 100644 --- a/includes/filerepo/file/OldLocalFile.php +++ b/includes/filerepo/file/OldLocalFile.php @@ -21,13 +21,15 @@ * @ingroup FileAbstraction */ +use MediaWiki\MediaWikiServices; + /** * Class to represent a file in the oldimage table * * @ingroup FileAbstraction */ class OldLocalFile extends LocalFile { - /** @var string Timestamp */ + /** @var string|int Timestamp */ protected $requestedTime; /** @var string Archive name */ @@ -39,8 +41,8 @@ class OldLocalFile extends LocalFile { /** * @param Title $title * @param FileRepo $repo - * @param null|int $time Timestamp or null - * @return OldLocalFile + * @param string|int $time + * @return self * @throws MWException */ static function newFromTitle( $title, $repo, $time = null ) { @@ -56,7 +58,7 @@ class OldLocalFile extends LocalFile { * @param Title $title * @param FileRepo $repo * @param string $archiveName - * @return OldLocalFile + * @return self */ static function newFromArchiveName( $title, $repo, $archiveName ) { return new self( $title, $repo, null, $archiveName ); @@ -65,7 +67,7 @@ class OldLocalFile extends LocalFile { /** * @param stdClass $row * @param FileRepo $repo - * @return OldLocalFile + * @return self */ static function newFromRow( $row, $repo ) { $title = Title::makeTitle( NS_FILE, $row->oi_name ); @@ -107,7 +109,7 @@ class OldLocalFile extends LocalFile { /** * Fields in the oldimage table * @deprecated since 1.31, use self::getQueryInfo() instead. - * @return array + * @return string[] */ static function selectFields() { global $wgActorTableSchemaMigrationStage; @@ -140,7 +142,7 @@ class OldLocalFile extends LocalFile { 'oi_timestamp', 'oi_deleted', 'oi_sha1', - ] + CommentStore::getStore()->getFields( 'oi_description' ); + ] + MediaWikiServices::getInstance()->getCommentStore()->getFields( 'oi_description' ); } /** @@ -149,13 +151,13 @@ class OldLocalFile extends LocalFile { * @since 1.31 * @param string[] $options * - omit-lazy: Omit fields that are lazily cached. - * @return array With three keys: + * @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 static function getQueryInfo( array $options = [] ) { - $commentQuery = CommentStore::getStore()->getJoin( 'oi_description' ); + $commentQuery = MediaWikiServices::getInstance()->getCommentStore()->getJoin( 'oi_description' ); $actorQuery = ActorMigration::newMigration()->getJoin( 'oi_user' ); $ret = [ 'tables' => [ 'oldimage' ] + $commentQuery['tables'] + $actorQuery['tables'], @@ -191,8 +193,8 @@ class OldLocalFile extends LocalFile { /** * @param Title $title * @param FileRepo $repo - * @param string $time Timestamp or null to load by archive name - * @param string $archiveName Archive name or null to load by timestamp + * @param string|int|null $time Timestamp or null to load by archive name + * @param string|null $archiveName Archive name or null to load by timestamp * @throws MWException */ function __construct( $title, $repo, $time, $archiveName ) { @@ -446,7 +448,8 @@ class OldLocalFile extends LocalFile { return false; } - $commentFields = CommentStore::getStore()->insert( $dbw, 'oi_description', $comment ); + $commentFields = MediaWikiServices::getInstance()->getCommentStore() + ->insert( $dbw, 'oi_description', $comment ); $actorFields = ActorMigration::newMigration()->getInsertValues( $dbw, 'oi_user', $user ); $dbw->insert( 'oldimage', [ diff --git a/includes/gallery/ImageGalleryBase.php b/includes/gallery/ImageGalleryBase.php index 318329726a..09e40a2821 100644 --- a/includes/gallery/ImageGalleryBase.php +++ b/includes/gallery/ImageGalleryBase.php @@ -58,6 +58,15 @@ abstract class ImageGalleryBase extends ContextSource { */ protected $mCaption = false; + /** + * Length to truncate filename to in caption when using "showfilename". + * A value of 'true' will truncate the filename to one line using CSS + * and will be the behaviour after deprecation. + * + * @var bool|int + */ + protected $mCaptionLength = true; + /** * @var bool Hide blacklisted images? */ diff --git a/includes/gallery/TraditionalImageGallery.php b/includes/gallery/TraditionalImageGallery.php index 7a520bcbd1..cd6aab0f88 100644 --- a/includes/gallery/TraditionalImageGallery.php +++ b/includes/gallery/TraditionalImageGallery.php @@ -191,29 +191,21 @@ class TraditionalImageGallery extends ImageGalleryBase { } $textlink = $this->mShowFilename ? - // Preloaded into LinkCache above - Linker::linkKnown( - $nt, - htmlspecialchars( - $this->mCaptionLength !== true ? - $lang->truncate( $nt->getText(), $this->mCaptionLength ) : - $nt->getText() - ), - [ - 'class' => 'galleryfilename' . - ( $this->mCaptionLength === true ? ' galleryfilename-truncate' : '' ) - ] - ) . "\n" : + $this->getCaptionHtml( $nt, $lang ) : ''; $galleryText = $textlink . $text . $meta; $galleryText = $this->wrapGalleryText( $galleryText, $thumb ); + $gbWidth = $this->getGBWidth( $thumb ) . 'px'; + if ( $this->getGBWidthOverwrite( $thumb ) ) { + $gbWidth = $this->getGBWidthOverwrite( $thumb ); + } # Weird double wrapping (the extra div inside the li) needed due to FF2 bug # Can be safely removed if FF2 falls completely out of existence $output .= "\n\t\t" . '
  • ' - . '
    ' + . $gbWidth . '">' + . '
    ' . $thumbhtml . $galleryText . "\n\t\t
  • "; @@ -223,6 +215,27 @@ class TraditionalImageGallery extends ImageGalleryBase { return $output; } + /** + * @param Title $nt + * @param Language $lang + * @return string HTML + */ + protected function getCaptionHtml( Title $nt, Language $lang ) { + // Preloaded into LinkCache in toHTML + return Linker::linkKnown( + $nt, + htmlspecialchars( + is_int( $this->getCaptionLength() ) ? + $lang->truncate( $nt->getText(), $this->getCaptionLength() ) : + $nt->getText() + ), + [ + 'class' => 'galleryfilename' . + ( $this->getCaptionLength() === true ? ' galleryfilename-truncate' : '' ) + ] + ) . "\n"; + } + /** * Add the wrapper html around the thumb's caption * @@ -272,6 +285,17 @@ class TraditionalImageGallery extends ImageGalleryBase { return 8; } + /** + * Length to truncate filename to in caption when using "showfilename" (if int). + * A value of 'true' will truncate the filename to one line using CSS, while + * 'false' will disable truncating. + * + * @return int|bool + */ + protected function getCaptionLength() { + return $this->mCaptionLength; + } + /** * Get total padding. * @@ -319,7 +343,7 @@ class TraditionalImageGallery extends ImageGalleryBase { } /** - * Width of gallerybox
  • . + * Computed width of gallerybox
  • . * * Generally is the width of the image, plus padding on image * plus padding on gallerybox. @@ -332,6 +356,21 @@ class TraditionalImageGallery extends ImageGalleryBase { return $this->mWidths + $this->getThumbPadding() + $this->getGBPadding(); } + /** + * Allows overwriting the computed width of the gallerybox
  • with a string, + * like '100%'. + * + * Generally is the width of the image, plus padding on image + * plus padding on gallerybox. + * + * @note Important: parameter will be false if no thumb used. + * @param MediaTransformOutput|bool $thumb MediaTransformObject object or false. + * @return bool|string Ignored if false. + */ + protected function getGBWidthOverwrite( $thumb ) { + return false; + } + /** * Get a list of modules to include in the page. * diff --git a/includes/htmlform/HTMLForm.php b/includes/htmlform/HTMLForm.php index af1743e078..ff6cfffb56 100644 --- a/includes/htmlform/HTMLForm.php +++ b/includes/htmlform/HTMLForm.php @@ -159,6 +159,7 @@ class HTMLForm extends ContextSource { 'date' => HTMLDateTimeField::class, 'time' => HTMLDateTimeField::class, 'datetime' => HTMLDateTimeField::class, + 'expiry' => HTMLExpiryField::class, // HTMLTextField will output the correct type="" attribute automagically. // There are about four zillion other HTML5 input types, like range, but // we don't use those at the moment, so no point in adding all of them. @@ -1336,10 +1337,13 @@ class HTMLForm extends ContextSource { /** * Identify that the submit button in the form has a progressive action * @since 1.25 + * @deprecated since 1.32, No need to call. Submit button already + * has a progressive action form. * * @return HTMLForm $this for chaining calls (since 1.28) */ public function setSubmitProgressive() { + wfDeprecated( __METHOD__, '1.32' ); $this->mSubmitFlags = [ 'progressive', 'primary' ]; return $this; diff --git a/includes/htmlform/fields/HTMLExpiryField.php b/includes/htmlform/fields/HTMLExpiryField.php new file mode 100644 index 0000000000..dfe6a8a623 --- /dev/null +++ b/includes/htmlform/fields/HTMLExpiryField.php @@ -0,0 +1,88 @@ +relativeField = $this->getFieldByType( $type ); + } + + /** + * {@inheritdoc} + * + * Use whatever the relative field is as the standard HTML input. + */ + public function getInputHTML( $value ) { + return $this->relativeField->getInputHTML( $value ); + } + + protected function shouldInfuseOOUI() { + return true; + } + + /** + * {@inheritdoc} + */ + protected function getOOUIModules() { + return array_merge( + [ + 'mediawiki.widgets.expiry', + ], + $this->relativeField->getOOUIModules() + ); + } + + /** + * {@inheritdoc} + */ + public function getInputOOUI( $value ) { + return new ExpiryInputWidget( + $this->relativeField->getInputOOUI( $value ), + [ + 'id' => $this->mID, + 'required' => isset( $this->mParams['required'] ) ? $this->mParams['required'] : false, + ] + ); + } + + /** + * {@inheritdoc} + */ + public function loadDataFromRequest( $request ) { + return $this->relativeField->loadDataFromRequest( $request ); + } + + /** + * Get the HTMLForm field by the type string. + * + * @param string $type + * @return \HTMLFormField + */ + protected function getFieldByType( $type ) { + $class = HTMLForm::$typeMappings[$type]; + $params = $this->mParams; + $params['type'] = $type; + $params['class'] = $class; + + // Remove Parameters that are being used on the parent. + unset( $params['label-message'] ); + return new $class( $params ); + } + +} diff --git a/includes/htmlform/fields/HTMLTitleTextField.php b/includes/htmlform/fields/HTMLTitleTextField.php index 3eb3f5dfa1..602ddee486 100644 --- a/includes/htmlform/fields/HTMLTitleTextField.php +++ b/includes/htmlform/fields/HTMLTitleTextField.php @@ -25,6 +25,8 @@ class HTMLTitleTextField extends HTMLTextField { 'relative' => false, 'creatable' => false, 'exists' => false, + // This overrides the default from HTMLFormField + 'required' => true, ]; parent::__construct( $params ); @@ -34,8 +36,16 @@ class HTMLTitleTextField extends HTMLTextField { if ( $this->mParent->getMethod() === 'get' && $value === '' ) { // If the form is a GET form and has no value, assume it hasn't been // submitted yet, and skip validation + // TODO This doesn't look right, we should be able to tell the difference + // between "not submitted" (null) and "submitted but empty" (empty string). return parent::validate( $value, $alldata ); } + + if ( !$this->mParams['required'] && $value === '' ) { + // If this field is not required and the value is empty, that's okay, skip validation + return parent::validate( $value, $alldata ); + } + try { if ( !$this->mParams['relative'] ) { $title = Title::newFromTextThrow( $value ); diff --git a/includes/import/ImportStreamSource.php b/includes/import/ImportStreamSource.php index cf382e4804..ebac200a4a 100644 --- a/includes/import/ImportStreamSource.php +++ b/includes/import/ImportStreamSource.php @@ -74,20 +74,21 @@ class ImportStreamSource implements ImportSource { } if ( !empty( $upload['error'] ) ) { switch ( $upload['error'] ) { - case 1: - # The uploaded file exceeds the upload_max_filesize directive in php.ini. + case UPLOAD_ERR_INI_SIZE: + // The uploaded file exceeds the upload_max_filesize directive in php.ini. return Status::newFatal( 'importuploaderrorsize' ); - case 2: - # The uploaded file exceeds the MAX_FILE_SIZE directive that - # was specified in the HTML form. + case UPLOAD_ERR_FORM_SIZE: + // The uploaded file exceeds the MAX_FILE_SIZE directive that + // was specified in the HTML form. + // FIXME This is probably never used since that directive was removed in 8e91c520? return Status::newFatal( 'importuploaderrorsize' ); - case 3: - # The uploaded file was only partially uploaded + case UPLOAD_ERR_PARTIAL: + // The uploaded file was only partially uploaded return Status::newFatal( 'importuploaderrorpartial' ); - case 6: - # Missing a temporary folder. + case UPLOAD_ERR_NO_TMP_DIR: + // Missing a temporary folder. return Status::newFatal( 'importuploaderrortemp' ); - # case else: # Currently impossible + // Other error codes get the generic 'importnofile' error message below } } diff --git a/includes/import/ImportableOldRevisionImporter.php b/includes/import/ImportableOldRevisionImporter.php index 33fad3e626..066a3eacef 100644 --- a/includes/import/ImportableOldRevisionImporter.php +++ b/includes/import/ImportableOldRevisionImporter.php @@ -68,18 +68,20 @@ class ImportableOldRevisionImporter implements OldRevisionImporter { // Note: sha1 has been in XML dumps since 2012. If you have an // older dump, the duplicate detection here won't work. - $prior = $dbw->selectField( 'revision', '1', - [ 'rev_page' => $pageId, + if ( $importableRevision->getSha1Base36() !== false ) { + $prior = $dbw->selectField( 'revision', '1', + [ 'rev_page' => $pageId, 'rev_timestamp' => $dbw->timestamp( $importableRevision->getTimestamp() ), 'rev_sha1' => $importableRevision->getSha1Base36() ], - __METHOD__ - ); - if ( $prior ) { - // @todo FIXME: This could fail slightly for multiple matches :P - $this->logger->debug( __METHOD__ . ": skipping existing revision for [[" . - $importableRevision->getTitle()->getPrefixedText() . "]], timestamp " . - $importableRevision->getTimestamp() . "\n" ); - return false; + __METHOD__ + ); + if ( $prior ) { + // @todo FIXME: This could fail slightly for multiple matches :P + $this->logger->debug( __METHOD__ . ": skipping existing revision for [[" . + $importableRevision->getTitle()->getPrefixedText() . "]], timestamp " . + $importableRevision->getTimestamp() . "\n" ); + return false; + } } } diff --git a/includes/import/ImportableUploadRevisionImporter.php b/includes/import/ImportableUploadRevisionImporter.php index 495b3d60b3..b64114cf87 100644 --- a/includes/import/ImportableUploadRevisionImporter.php +++ b/includes/import/ImportableUploadRevisionImporter.php @@ -50,7 +50,7 @@ class ImportableUploadRevisionImporter implements UploadRevisionImporter { $file->load( File::READ_LATEST ); $this->logger->debug( __METHOD__ . 'Importing new file as ' . $file->getName() . "\n" ); if ( $file->exists() && $file->getTimestamp() > $importableRevision->getTimestamp() ) { - $archiveName = $file->getTimestamp() . '!' . $file->getName(); + $archiveName = $importableRevision->getTimestamp() . '!' . $file->getName(); $file = OldLocalFile::newFromArchiveName( $importableRevision->getTitle(), RepoGroup::singleton()->getLocalRepo(), $archiveName ); $this->logger->debug( __METHOD__ . "File already exists; importing as $archiveName\n" ); diff --git a/includes/installer/DatabaseUpdater.php b/includes/installer/DatabaseUpdater.php index 7a1aba636c..1f6110bd44 100644 --- a/includes/installer/DatabaseUpdater.php +++ b/includes/installer/DatabaseUpdater.php @@ -136,7 +136,7 @@ abstract class DatabaseUpdater { $wgExtPGAlteredFields, $wgExtNewIndexes, $wgExtModifiedFields; # For extensions only, should be populated via hooks - # $wgDBtype should be checked to specifiy the proper file + # $wgDBtype should be checked to specify the proper file $wgExtNewTables = []; // table, dir $wgExtNewFields = []; // table, column, dir $wgExtPGNewFields = []; // table, column, column attributes; for PostgreSQL @@ -150,7 +150,7 @@ abstract class DatabaseUpdater { * LoadExtensionSchemaUpdates hook. */ private function loadExtensions() { - if ( !defined( 'MEDIAWIKI_INSTALL' ) ) { + if ( !defined( 'MEDIAWIKI_INSTALL' ) || defined( 'MW_EXTENSIONS_LOADED' ) ) { return; // already loaded } $vars = Installer::getExistingLocalSettings(); @@ -162,7 +162,7 @@ abstract class DatabaseUpdater { // This will automatically add "AutoloadClasses" to $wgAutoloadClasses $data = $registry->readFromQueue( $queue ); - $hooks = [ 'wgHooks' => [ 'LoadExtensionSchemaUpdates' => [] ] ]; + $hooks = []; if ( isset( $data['globals']['wgHooks']['LoadExtensionSchemaUpdates'] ) ) { $hooks = $data['globals']['wgHooks']['LoadExtensionSchemaUpdates']; } @@ -1257,10 +1257,15 @@ abstract class DatabaseUpdater { * @since 1.31 */ protected function migrateArchiveText() { - $this->output( "Migrating archive ar_text to modern storage.\n" ); - $task = $this->maintenance->runChild( MigrateArchiveText::class, 'migrateArchiveText.php' ); - $task->execute(); - $this->output( "done.\n" ); + if ( $this->db->fieldExists( 'archive', 'ar_text', __METHOD__ ) ) { + $this->output( "Migrating archive ar_text to modern storage.\n" ); + $task = $this->maintenance->runChild( MigrateArchiveText::class, 'migrateArchiveText.php' ); + $task->setForce(); + if ( $task->execute() ) { + $this->applyPatch( 'patch-drop-ar_text.sql', false, + 'Dropping ar_text and ar_flags columns' ); + } + } } /** diff --git a/includes/installer/Installer.php b/includes/installer/Installer.php index 7cfc617333..1efe5d6373 100644 --- a/includes/installer/Installer.php +++ b/includes/installer/Installer.php @@ -1301,7 +1301,15 @@ abstract class Installer { if ( !is_dir( "$extDir/$file" ) ) { continue; } - if ( file_exists( "$extDir/$file/$jsonFile" ) || file_exists( "$extDir/$file/$file.php" ) ) { + $fullJsonFile = "$extDir/$file/$jsonFile"; + $isJson = file_exists( $fullJsonFile ); + $isPhp = false; + if ( !$isJson ) { + // Only fallback to PHP file if JSON doesn't exist + $fullPhpFile = "$extDir/$file/$file.php"; + $isPhp = file_exists( $fullPhpFile ); + } + if ( $isJson || $isPhp ) { // Extension exists. Now see if there are screenshots $exts[$file] = []; if ( is_dir( "$extDir/$file/screenshots" ) ) { @@ -1312,6 +1320,13 @@ abstract class Installer { } } + if ( $isJson ) { + $info = $this->readExtension( $fullJsonFile ); + if ( $info === false ) { + continue; + } + $exts[$file] += $info; + } } closedir( $dh ); uksort( $exts, 'strnatcasecmp' ); @@ -1319,6 +1334,82 @@ abstract class Installer { return $exts; } + /** + * @param string $fullJsonFile + * @param array $extDeps + * @param array $skinDeps + * + * @return array|bool False if this extension can't be loaded + */ + private function readExtension( $fullJsonFile, $extDeps = [], $skinDeps = [] ) { + $load = [ + $fullJsonFile => 1 + ]; + if ( $extDeps ) { + $extDir = $this->getVar( 'IP' ) . '/extensions'; + foreach ( $extDeps as $dep ) { + $fname = "$extDir/$dep/extension.json"; + if ( !file_exists( $fname ) ) { + return false; + } + $load[$fname] = 1; + } + } + if ( $skinDeps ) { + $skinDir = $this->getVar( 'IP' ) . '/skins'; + foreach ( $skinDeps as $dep ) { + $fname = "$skinDir/$dep/skin.json"; + if ( !file_exists( $fname ) ) { + return false; + } + $load[$fname] = 1; + } + } + $registry = new ExtensionRegistry(); + try { + $info = $registry->readFromQueue( $load ); + } catch ( ExtensionDependencyError $e ) { + if ( $e->incompatibleCore || $e->incompatibleSkins + || $e->incompatibleExtensions + ) { + // If something is incompatible with a dependency, we have no real + // option besides skipping it + return false; + } elseif ( $e->missingExtensions || $e->missingSkins ) { + // There's an extension missing in the dependency tree, + // so add those to the dependency list and try again + return $this->readExtension( + $fullJsonFile, + array_merge( $extDeps, $e->missingExtensions ), + array_merge( $skinDeps, $e->missingSkins ) + ); + } + // Some other kind of dependency error? + return false; + } + $ret = []; + // The order of credits will be the order of $load, + // so the first extension is the one we want to load, + // everything else is a dependency + $i = 0; + foreach ( $info['credits'] as $name => $credit ) { + $i++; + if ( $i == 1 ) { + // Extension we want to load + continue; + } + $type = basename( $credit['path'] ) === 'skin.json' ? 'skins' : 'extensions'; + $ret['requires'][$type][] = $credit['name']; + } + $credits = array_values( $info['credits'] )[0]; + if ( isset( $credits['url'] ) ) { + $ret['url'] = $credits['url']; + } + $ret['type'] = $credits['type']; + + return $ret; + } + /** * Returns a default value to be used for $wgDefaultSkin: normally the one set in DefaultSettings, * but will fall back to another if the default skin is missing and some other one is present @@ -1346,6 +1437,10 @@ abstract class Installer { $exts = $this->getVar( '_Extensions' ); $IP = $this->getVar( 'IP' ); + // Marker for DatabaseUpdater::loadExtensions so we don't + // double load extensions + define( 'MW_EXTENSIONS_LOADED', true ); + /** * We need to include DefaultSettings before including extensions to avoid * warnings about unset variables. However, the only thing we really diff --git a/includes/installer/LocalSettingsGenerator.php b/includes/installer/LocalSettingsGenerator.php index b4ef49d7c6..6d70338cf9 100644 --- a/includes/installer/LocalSettingsGenerator.php +++ b/includes/installer/LocalSettingsGenerator.php @@ -299,6 +299,12 @@ class LocalSettingsGenerator { } $mcservers = $this->buildMemcachedServerList(); + if ( file_exists( dirname( __DIR__ ) . '/PlatformSettings.php' ) ) { + $platformSettings = "\n## Include platform/distribution defaults"; + $platformSettings .= "\nrequire_once \"\$IP/includes/PlatformSettings.php\";"; + } else { + $platformSettings = ''; + } return "db->sequenceExists( $ns ) ) { $this->output( "Creating sequence $ns\n" ); - $this->db->query( "CREATE SEQUENCE $ns" ); if ( $pkey !== false ) { + $this->db->query( "CREATE SEQUENCE $ns OWNED BY $table.$pkey" ); $this->setDefault( $table, $pkey, '"nextval"(\'"' . $ns . '"\'::"regclass")' ); + } else { + $this->db->query( "CREATE SEQUENCE $ns" ); } } } @@ -732,6 +767,13 @@ END; } } + protected function setSequenceOwner( $table, $pkey, $seq ) { + if ( $this->db->sequenceExists( $seq ) ) { + $this->output( "Setting sequence $seq owner to $table.$pkey\n" ); + $this->db->query( "ALTER SEQUENCE $seq OWNED BY $table.$pkey" ); + } + } + protected function renameTable( $old, $new, $patch = false ) { if ( $this->db->tableExists( $old ) ) { $this->output( "Renaming table $old to $new\n" ); diff --git a/includes/installer/SqliteUpdater.php b/includes/installer/SqliteUpdater.php index 309f30f693..76e1ca968f 100644 --- a/includes/installer/SqliteUpdater.php +++ b/includes/installer/SqliteUpdater.php @@ -210,6 +210,11 @@ class SqliteUpdater extends DatabaseUpdater { [ 'modifyField', 'revision', 'rev_text_id', 'patch-rev_text_id-default.sql' ], [ 'modifyTable', 'site_stats', 'patch-site_stats-modify.sql' ], [ 'populateArchiveRevId' ], + [ 'addIndex', 'recentchanges', 'rc_namespace_title_timestamp', + 'patch-recentchanges-nttindex.sql' ], + + // 1.32 + [ 'addTable', 'change_tag_def', 'patch-change_tag_def.sql' ], ]; } diff --git a/includes/installer/WebInstaller.php b/includes/installer/WebInstaller.php index 9d7e0514bb..8fb980791e 100644 --- a/includes/installer/WebInstaller.php +++ b/includes/installer/WebInstaller.php @@ -915,6 +915,7 @@ class WebInstaller extends Installer { * Parameters are: * var: The variable to be configured (required) * label: The message name for the label (required) + * labelAttribs:Additional attributes for the label element (optional) * attribs: Additional attributes for the input element (optional) * controlName: The name for the input element (optional) * value: The current value of the variable (optional) @@ -937,6 +938,9 @@ class WebInstaller extends Installer { if ( !isset( $params['help'] ) ) { $params['help'] = ""; } + if ( !isset( $params['labelAttribs'] ) ) { + $params['labelAttribs'] = []; + } if ( isset( $params['rawtext'] ) ) { $labelText = $params['rawtext']; } else { @@ -945,17 +949,19 @@ class WebInstaller extends Installer { return "
    \n" . $params['help'] . - "\n" . + Html::rawElement( + 'label', + $params['labelAttribs'], + Xml::check( + $params['controlName'], + $params['value'], + $params['attribs'] + [ + 'id' => $params['controlName'], + 'tabindex' => $this->nextTabIndex(), + ] + ) . + $labelText . "\n" + ) . "
    \n"; } diff --git a/includes/installer/WebInstallerOptions.php b/includes/installer/WebInstallerOptions.php index 07378ab32e..d798ea1e1a 100644 --- a/includes/installer/WebInstallerOptions.php +++ b/includes/installer/WebInstallerOptions.php @@ -25,6 +25,8 @@ class WebInstallerOptions extends WebInstallerPage { * @return string|null */ public function execute() { + global $wgLang; + if ( $this->getVar( '_SkipOptional' ) == 'skip' ) { $this->submitSkins(); return 'skip'; @@ -145,20 +147,93 @@ class WebInstallerOptions extends WebInstallerPage { $this->addHTML( $skinHtml ); $extensions = $this->parent->findExtensions(); + $dependencyMap = []; if ( $extensions ) { $extHtml = $this->getFieldsetStart( 'config-extensions' ); + $extByType = []; + $types = SpecialVersion::getExtensionTypes(); + // Sort by type first foreach ( $extensions as $ext => $info ) { - $extHtml .= $this->parent->getCheckBox( [ - 'var' => "ext-$ext", - 'rawtext' => $ext, - ] ); + if ( !isset( $info['type'] ) || !isset( $types[$info['type']] ) ) { + // We let extensions normally define custom types, but + // since we aren't loading extensions, we'll have to + // categorize them under other + $info['type'] = 'other'; + } + $extByType[$info['type']][$ext] = $info; + } + + foreach ( $types as $type => $message ) { + if ( !isset( $extByType[$type] ) ) { + continue; + } + $extHtml .= Html::element( 'h2', [], $message ); + foreach ( $extByType[$type] as $ext => $info ) { + $urlText = ''; + if ( isset( $info['url'] ) ) { + $urlText = ' ' . Html::element( 'a', [ 'href' => $info['url'] ], '(more information)' ); + } + $attribs = [ + 'data-name' => $ext, + 'class' => 'config-ext-input' + ]; + $labelAttribs = []; + $fullDepList = []; + if ( isset( $info['requires']['extensions'] ) ) { + $dependencyMap[$ext]['extensions'] = $info['requires']['extensions']; + $labelAttribs['class'] = 'mw-ext-with-dependencies'; + } + if ( isset( $info['requires']['skins'] ) ) { + $dependencyMap[$ext]['skins'] = $info['requires']['skins']; + $labelAttribs['class'] = 'mw-ext-with-dependencies'; + } + if ( isset( $dependencyMap[$ext] ) ) { + $links = []; + // For each dependency, link to the checkbox for each + // extension/skin that is required + if ( isset( $dependencyMap[$ext]['extensions'] ) ) { + foreach ( $dependencyMap[$ext]['extensions'] as $name ) { + $links[] = Html::element( + 'a', + [ 'href' => "#config_ext-$name" ], + $name + ); + } + } + if ( isset( $dependencyMap[$ext]['skins'] ) ) { + foreach ( $dependencyMap[$ext]['skins'] as $name ) { + $links[] = Html::element( + 'a', + [ 'href' => "#config_skin-$name" ], + $name + ); + } + } + + $text = wfMessage( 'config-extensions-requires' ) + ->rawParams( $ext, $wgLang->commaList( $links ) ) + ->escaped(); + } else { + $text = $ext; + } + $extHtml .= $this->parent->getCheckBox( [ + 'var' => "ext-$ext", + 'rawtext' => $text, + 'attribs' => $attribs, + 'labelAttribs' => $labelAttribs, + ] ); + } } $extHtml .= $this->parent->getHelpBox( 'config-extensions-help' ) . $this->getFieldsetEnd(); $this->addHTML( $extHtml ); + // Push the dependency map to the client side + $this->addHTML( Html::inlineScript( + 'var extDependencyMap = ' . Xml::encodeJsVar( $dependencyMap ) + ) ); } // Having / in paths in Windows looks funny :) @@ -259,7 +334,7 @@ class WebInstallerOptions extends WebInstallerPage { foreach ( $screenshots as $shot ) { $links[] = Html::element( 'a', - [ 'href' => $shot ], + [ 'href' => $shot, 'target' => '_blank' ], $wgLang->formatNum( $counter++ ) ); } @@ -269,7 +344,7 @@ class WebInstallerOptions extends WebInstallerPage { } else { $link = Html::element( 'a', - [ 'href' => $screenshots[0] ], + [ 'href' => $screenshots[0], 'target' => '_blank' ], wfMessage( 'config-screenshot' )->text() ); return wfMessage( 'config-skins-screenshot', $name )->rawParams( $link )->escaped(); diff --git a/includes/installer/WebInstallerOutput.php b/includes/installer/WebInstallerOutput.php index 6a55d69690..cb0092d2a6 100644 --- a/includes/installer/WebInstallerOutput.php +++ b/includes/installer/WebInstallerOutput.php @@ -130,9 +130,9 @@ class WebInstallerOutput { global $wgStyleDirectory; $moduleNames = [ - // See SkinTemplate::setupSkinUserCss + // Based on Skin::getDefaultModules 'mediawiki.legacy.shared', - // See Vector::setupSkinUserCss + // Based on Vector::setupSkinUserCss 'mediawiki.skinning.interface', ]; diff --git a/includes/installer/i18n/be-tarask.json b/includes/installer/i18n/be-tarask.json index 4c4504e261..19e1ea05b8 100644 --- a/includes/installer/i18n/be-tarask.json +++ b/includes/installer/i18n/be-tarask.json @@ -318,6 +318,7 @@ "config-nofile": "Файл «$1» ня знойдзены. Ці быў ён выдалены?", "config-extension-link": "Ці ведаеце вы, што вашая вікі падтрымлівае [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions пашырэньні]?\n\nВы можаце праглядзець [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category пашырэньні паводле катэгорыяў] або [https://www.mediawiki.org/wiki/Extension_Matrix матрыцу пашырэньняў], каб пабачыць поўны сьпіс.", "config-skins-screenshots": "$1 (здымкі экрану: $2)", + "config-extensions-requires": "$1 (патрабуе $2)", "config-screenshot": "здымак экрану", "mainpagetext": "MediaWiki была ўсталяваная.", "mainpagedocfooter": "Глядзіце [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents дапаможнік карыстальніка] для атрыманьня інфармацыі па карыстаньні вікі-праграмамі.\n\n== З чаго пачаць ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Сьпіс парамэтраў канфігурацыі]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Частыя пытаньні MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Рассылка паведамленьняў пра зьяўленьне новых вэрсіяў MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Пераклад MediaWiki на вашую мову]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Даведайцеся, як змагацца з спамам у вашай вікі]" diff --git a/includes/installer/i18n/cs.json b/includes/installer/i18n/cs.json index ad2b910b23..8ab7abee61 100644 --- a/includes/installer/i18n/cs.json +++ b/includes/installer/i18n/cs.json @@ -321,6 +321,7 @@ "config-nofile": "Soubor „$1“ nelze nalézt. Byl smazán?", "config-extension-link": "Věděli jste, že vaše wiki podporuje [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions rozšíření]?\n\nMůžete si prohlédnout [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category seznam rozšíření po kategoriích].", "config-skins-screenshots": "$1 (snímky obrazovky: $2)", + "config-extensions-requires": "$1 (vyžaduje $2)", "config-screenshot": "snímek obrazovky", "mainpagetext": "MediaWiki byla úspěšně nainstalována.", "mainpagedocfooter": "[https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Uživatelská příručka] vám napoví, jak používat MediaWiki.\n\n== Začínáme ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Nastavení konfigurace]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Často kladené otázky o MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce E-mailová konference oznámení MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Překlad MediaWiki do vašeho jazyka]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Naučte se bojovat se spamem na vaší wiki]" diff --git a/includes/installer/i18n/de.json b/includes/installer/i18n/de.json index 00005d33c9..b20e1ed73f 100644 --- a/includes/installer/i18n/de.json +++ b/includes/installer/i18n/de.json @@ -327,6 +327,7 @@ "config-nofile": "Die Datei „$1“ konnte nicht gefunden werden. Wurde sie gelöscht?", "config-extension-link": "Wusstest du, dass dein Wiki die Nutzung von [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions Erweiterungen] unterstützt?\n\nDu kannst die [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category Erweiterungen nach Kategorie] anzeigen oder die [https://www.mediawiki.org/wiki/Extension_Matrix Erweiterungs-Matrix] aufrufen, um eine vollständige Liste der Erweiterungen zu sehen.", "config-skins-screenshots": "$1 (Bildschirmfotos: $2)", + "config-extensions-requires": "$1 (erfordert $2)", "config-screenshot": "Bildschirmfoto", "mainpagetext": "MediaWiki wurde installiert.", "mainpagedocfooter": "Hilfe zur Benutzung und Konfiguration der Wiki-Software findest du im [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Benutzerhandbuch].\n\n== Starthilfen ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Liste der Konfigurationsvariablen]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki-FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailingliste neuer MediaWiki-Versionen]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Übersetze MediaWiki für deine Sprache]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Erfahre, wie du Spam auf deinem Wiki bekämpfen kannst]" diff --git a/includes/installer/i18n/el.json b/includes/installer/i18n/el.json index 6d1e3f1691..235097025d 100644 --- a/includes/installer/i18n/el.json +++ b/includes/installer/i18n/el.json @@ -65,7 +65,9 @@ "config-memory-raised": "Το memory_limit της PHP είναι $1 και αυξήθηκε σε $2.", "config-memory-bad": "Προειδοποίηση: το memory_limit της PHP είναι $1.\nΑυτή η τιμή είναι πιθανώς πολύ χαμηλή.\n\nΗ εγκατάσταση ενδέχεται να αποτύχει!", "config-apc": "Το [http://www.php.net/apc APC] είναι εγκατεστημένο", + "config-apcu": "Το [http://www.php.net/apcu APCu] είναι εγκατεστημένο", "config-wincache": "[https://www.iis.net/download/WinCacheForPhp Το WinCache] είναι εγκατεστημένο", + "config-no-cache-apcu": "Προειδοποίηση: Αποτυχία εύρεσης του [http://www.php.net/apcu APCu] ή του [http://www.iis.net/download/WinCacheForPhp WinCache].\nΗ αρχειοθέτηση αντικειμένων δεν έχει ενεργοποιηθεί.", "config-diff3-bad": "Το GNU diff3 δεν βρέθηκε.", "config-git": "Βρέθηκε το λογισμικό ελέγχου εκδόσεων Git: $1.", "config-git-bad": "Το λογισμικό ελέγχου εκδόσεων Git δεν βρέθηκε.", @@ -271,6 +273,7 @@ "config-install-extension-tables": "Γίνεται δημιουργία πινάκων για τις εγκατεστημένες επεκτάσεις", "config-install-mainpage-failed": "Δεν ήταν δυνατή η εισαγωγή της αρχικής σελίδας: $1", "config-install-done": "Συγχαρητήρια!\nΈχετε εγκαταστήσει το MediaWiki.\n\nΤο πρόγραμμα εγκατάστασης έχει δημιουργήσει το αρχείο LocalSettings.php.\nΠεριέχει όλες τις ρυθμίσεις παραμέτρων σας.\n\nΘα πρέπει να το κατεβάσετε και να το βάλετε στη βάση της εγκατάστασης του wiki σας (στον ίδιο κατάλογο με το index.php). Η λήψη θα πρέπει να έχει ξεκινήσει αυτόματα.\n\nΕάν δεν σας προτάθηκε λήψη, ή αν την ακυρώσατε, μπορείτε να επανεκκινήσετε τη λήψη κάνοντας κλικ στο σύνδεσμο ακριβώς από κάτω:\n\n$3\n\nΣημείωση: Εάν δεν το κάνετε αυτό τώρα, αυτό το αρχείο ρύθμισης παραμέτρων δεν θα είναι διαθέσιμο για σας αργότερα αν βγείτε από την εγκατάσταση χωρίς να το κατεβάσετε!\n\nΌταν θα έχει γίνει αυτό, μπορείτε να [$2 μπείτε στο wiki σας].", + "config-install-done-path": "Συγχαρητήρια!\nΈχετε εγκαταστήσει το MediaWiki.\n\nΤο πρόγραμμα εγκατάστασης έχει δημιουργήσει το αρχείο LocalSettings.php.\nΠεριέχει όλες τις ρύθμιση σας.\n\nΘα πρέπει να το κατεβάσετε και να το βάλετε στο $4. Η λήψη θα πρέπει να έχει ξεκινήσει αυτόματα.\n\nΕάν δεν σας προτάθηκε λήψη, ή αν την ακυρώσατε, μπορείτε να επανεκκινήσετε τη λήψη κάνοντας κλικ στο σύνδεσμο ακριβώς από κάτω:\n\n$3\n\nΣημείωση: Εάν δεν το κάνετε αυτό τώρα, αυτό το δημιουγημένο αρχείο ρύθμισης δεν θα είναι διαθέσιμο για σας αργότερα αν βγείτε από την εγκατάσταση χωρίς να το κατεβάσετε!\n\nΌταν θα έχει γίνει αυτό, μπορείτε να [$2 μπείτε στο wiki σας].", "config-install-success": " Το σύστημα της MediaWiki έχει εγκατασταθεί με επιτυχία. Μπορείτε τώρα να επισκεφθείτε το \n <$1$2> για να δείτε το wiki σας.\nΑν έχετε ερωτήσεις, ελέγξετε την λίστα με τις πιο συχνές ερωτήσεις:\n ή χρησιμοποιήστε ένα από τα φόρουμ υποστήριξης που είναι συνδεδεμένα σε αυτήν την σελίδα.", "config-download-localsettings": "Λήψη του LocalSettings.php", "config-help": "βοήθεια", @@ -278,6 +281,7 @@ "config-nofile": "Το αρχείο «$1» δεν μπορεί να βρεθεί. Μήπως έχει διαγραφεί;", "config-extension-link": "Γνωρίζατε ότι το wiki σας υποστηρίζει [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions επεκτάσεις];\n\nΜπορείτε να περιηγηθείτε στις [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category επεκτάσεις ανά κατηγορία] ή στον [https://www.mediawiki.org/wiki/Extension_Matrix πίνακα επεκτάσεων] για να δείτε την πλήρη λίστα των επεκτάσεων.", "config-skins-screenshots": "$1 (στιγμιότυπα: $2)", + "config-extensions-requires": "$1 (απαιτεί το $2)", "config-screenshot": "στιγμιότυπο", "mainpagetext": "To MediaWiki εγκαταστάθηκε.", "mainpagedocfooter": "Συμβουλευτείτε τον [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents οδηγό χρήστη] για πληροφορίες σχετικά με το λογισμικό wiki.\n\n== Ξεκινώντας ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Λίστα ρυθμίσεων διαμόρφωσης]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ\n Συχνές ερωτήσεις για το MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Λίστα αλληλογραφίας νέων κυκλοφοριών του MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Μεταφράστε το MediaWiki στη γλώσσα σας]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Μάθετε πώς να καταπολεμήσετε το ανεπιθύμητο περιεχόμενο στο wiki σας]" diff --git a/includes/installer/i18n/en.json b/includes/installer/i18n/en.json index f1b70806b4..d1a3b83423 100644 --- a/includes/installer/i18n/en.json +++ b/includes/installer/i18n/en.json @@ -311,6 +311,7 @@ "config-extension-link": "Did you know that your wiki supports [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions extensions]?\n\nYou can browse [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category extensions by category] or the [https://www.mediawiki.org/wiki/Extension_Matrix Extension Matrix] to see the full list of extensions.", "config-skins-screenshots": "$1 (screenshots: $2)", "config-skins-screenshot": "$1 ($2)", + "config-extensions-requires": "$1 (requires $2)", "config-screenshot": "screenshot", "mainpagetext": "MediaWiki has been installed.", "mainpagedocfooter": "Consult the [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents User's Guide] for information on using the wiki software.\n\n== Getting started ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Configuration settings list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki release mailing list]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Learn how to combat spam on your wiki]" diff --git a/includes/installer/i18n/eu.json b/includes/installer/i18n/eu.json index e94c71b220..18c917e092 100644 --- a/includes/installer/i18n/eu.json +++ b/includes/installer/i18n/eu.json @@ -64,7 +64,7 @@ "config-apc": "[http://www.php.net/apc APC] instalatuta dago", "config-apcu": "[http://www.php.net/apcu APCu] instalatuta dago", "config-wincache": "[https://www.iis.net/download/WinCacheForPhp WinCache] instalatuta dago", - "config-no-cache-apcu": "Warning: Ezin izan da [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] edo [http://www.iis.net/download/WinCacheForPhp WinCache] aurkitu.\nObjektu katxea ez dago aktibatuta.", + "config-no-cache-apcu": "Warning: Ezin izan da [http://www.php.net/apcu APCu] edo [http://www.iis.net/download/WinCacheForPhp WinCache] aurkitu.\nObjektu katxea ez dago aktibatuta.", "config-mod-security": "Warning: Zure web zerbitzariak [https://modsecurity.org/mod_security] / mod_security2 aktibatu du. Honen konfigurazio komun asko sortu ahal dituzte arazoak MediaWikin eta beste software batzuetan, hautazko edukia argitaratzeko aukera ematen dutenei erabiltzaileei.\nAhal izanez gero, desgaitu egin beharko litzateke. Bestela, kontsultatu [https://modsecurity.org/documentation/ mod_security documentation] edo jarri harremanetan zure ostalariarekin ausazko akatsak aurkitzen badituzu.", "config-diff3-bad": "GNU diff3 ez da aurkitu.", "config-git": "Git bertsio-kontrol software aurkitu da: $1", @@ -253,7 +253,7 @@ "config-cache-options": "Objektu cachearen ezarpenak:", "config-cache-help": "Objektuen katxea erabiltzen da MediaWikiko abiadura hobetzeko, sarritan erabiltzen diren datuak gordetzen.\nOso gomendagarria da, webgune handientzako eta ertainentzako, webgune txikiek ere ikusiko dituzte onurak.", "config-cache-none": "Desaktibatu Katxina (ez dira funtzionaltasunak ezabatu, baina wiki orrialde handietan abiaduran eragina izan ahal du)", - "config-cache-accel": "PHP objetuen katxea (APC, APCu, XCache edo WinCache)", + "config-cache-accel": "PHP objetuen katxea (APC, APCu, edo WinCache)", "config-cache-memcached": "Memcached erabili (konfigurazio eta instalazio gehiago behar du)", "config-memcached-servers": "Memcached serbidoreak:", "config-memcached-help": "Memcached-ekin erabiltzeko IP helbideen lista.\nLerro bakoitzen bat bakarrik jarri behar da eta zehaztu ze ataka erabiliko den. Adibidez:\n127.0.0.1:11211\n192.168.1.25:1234", @@ -316,6 +316,7 @@ "config-nofile": "Ezin da \"$1\" fitxategia aurkitu. Ezabatua izan da?", "config-extension-link": "Ba al zenekien wikiak [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions extensions] onartzen dituela?\n\nArakatu [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category extensions by category] edo [https://www.mediawiki.org/wiki/Extension_Matrix Extension Matrix] ikusi ahal izateko luzapenen zerrenda.", "config-skins-screenshots": "$1 (Pantaila-irudia: $2)", + "config-extensions-requires": "$1 ($2 behar du)", "config-screenshot": "Pantaila-irudia", "mainpagetext": "MediaWiki instalatu da.", "mainpagedocfooter": "Ikusi [https://meta.wikimedia.org/wiki/Help:Contents Erabiltzailearen Gida] wiki softwarea erabiltzen hasteko informazio gehiagorako.\n\n== Nola hasi ==\n\n*\n [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Konfigurazio balioen zerrenda]\n*\n [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ (MediaWikin Maiz egindako galderak)]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWikiren argitalpenen posta zerrenda]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Aurkitu MediaWiki zure hizkuntzan]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Zure wikian spam-a nola borrokatzen ikasi]" diff --git a/includes/installer/i18n/fi.json b/includes/installer/i18n/fi.json index 1b9a146792..9bb5a85389 100644 --- a/includes/installer/i18n/fi.json +++ b/includes/installer/i18n/fi.json @@ -161,6 +161,7 @@ "config-regenerate": "Luo LocalSettings.php uudelleen →", "config-show-table-status": "Kysely SHOW TABLE STATUS epäonnistui!", "config-unknown-collation": "Varoitus: Tietokanta käyttää tunnistamatonta lajittelua.", + "config-db-web-account": "Tietokantatili verkkokäyttöön", "config-db-web-help": "Valitse käyttäjänimi ja salasana joita palvelin käyttää muodostaessaan yhteyttä tietokantapalvelimeen wikin normaalin toiminnan aikana.", "config-db-web-account-same": "Käytä samaa tiliä kuin asennuksessa", "config-db-web-create": "Lisää tili, jos sitä ei ole jo olemassa", @@ -213,6 +214,7 @@ "config-profile-no-anon": "Tunnuksen luonti vaaditaan", "config-profile-fishbowl": "Vain hyväksytyt muokkaajat", "config-profile-private": "Yksityinen wiki", + "config-profile-help": "Wikit toimivat parhainten, kun annat niin monen ihmisen muokata niitä kuin mahdollista.\nMediaWikissä on helppoa esikatsella tuoreita muutoksia ja palauttaa kaiken vahingon, joita naiivit tai ilkeät käyttäjät tekevät.\n\nKuitenkin, monet ovat löytäneet MediaWikin hyödylliseksi monissa eri tehtävissä, ja joskus se ei ole helppoa vakuuttaa kaikkia wiki-tien eduista.\nJoten sinun pitää valita,\n\n{{int:config-profile-wiki}} -malli sallii kaikkien muokata, jopa ilman sisäänkirjautumista.\nWiki jossa {{int:config-profile-no-anon}} antaa lisävelvollisuutta, mutta saattaa estää vapaaehtoisia avustajia.\n\n{{int:config-profile-fishbowl}} skenaario sallii hyväksyttyjen käyttäjien muokata, mutta julkiset voivat nähdä sivut, mukaanlukien historia,\n{{int:config-profile-private}} sallii vain hyväksyttyjen käyttäjien nähdä ja muokata sivuja.\n\nMonimutkaisempia käyttöoikeuksien kokoonpanoja saatavilla asennuksen jälkeen, katso [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights relevant manual entry].", "config-license": "Tekijänoikeus ja lisenssi:", "config-license-none": "Ei lisenssin alatunnistetta", "config-license-cc-by-sa": "Creative Commons Nimeä-Tarttuva", @@ -310,6 +312,7 @@ "config-nofile": "Tiedostoa \"$1\" ei löytynyt. Onko se poistettu?", "config-extension-link": "Tiesitkö että wiki tukee [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions laajennuksia]?\n\nLaajennuksia voi hakea myös [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category luokittain].", "config-skins-screenshots": "$1 (kuvakaappaukset: $2)", + "config-extensions-requires": "$1 (vaatii $2)", "config-screenshot": "kuvakaappaus", "mainpagetext": "MediaWiki on onnistuneesti asennettu.", "mainpagedocfooter": "Lisätietoja wiki-ohjelmiston käytöstä on [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents käyttöoppaassa].\n\n=== Aloittaminen ===\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Asetusten teko-ohjeita]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWikin FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Sähköpostilista, jolla tiedotetaan MediaWikin uusista versioista]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Käännä MediaWikiä kielellesi]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Katso, kuinka torjua spämmiä wikissäsi]\n\n=== Asetukset ===\n\nTarkista, että alla olevat taivutusmuodot ovat oikein. Jos eivät, tee tarvittavat muutokset tiedostoon LocalSettings.php seuraavasti:\n $wgGrammarForms['fi']['genitive']['{{SITENAME}}'] = '...';\n $wgGrammarForms['fi']['partitive']['{{SITENAME}}'] = '...';\n $wgGrammarForms['fi']['elative']['{{SITENAME}}'] = '...';\n $wgGrammarForms['fi']['inessive']['{{SITENAME}}'] = '...';\n $wgGrammarForms['fi']['illative']['{{SITENAME}}'] = '...';\nTaivutusmuodot: {{GRAMMAR:genitive|{{SITENAME}}}} (yön) – {{GRAMMAR:partitive|{{SITENAME}}}} (yötä) – {{GRAMMAR:elative|{{SITENAME}}}} (yöstä) – {{GRAMMAR:inessive|{{SITENAME}}}} (yössä) – {{GRAMMAR:illative|{{SITENAME}}}} (yöhön)." diff --git a/includes/installer/i18n/fr.json b/includes/installer/i18n/fr.json index 2d9b94f4ca..3d801ccb1e 100644 --- a/includes/installer/i18n/fr.json +++ b/includes/installer/i18n/fr.json @@ -340,6 +340,7 @@ "config-nofile": "Le fichier « $1 » est introuvable. A-t-il été supprimé ?", "config-extension-link": "Saviez-vous que votre wiki prend en charge [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions des extensions] ?\n\nVous pouvez consulter les [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category extensions par catégorie] ou la [https://www.mediawiki.org/wiki/Extension_Matrix matrice des extensions] pour voir la liste complète des extensions.", "config-skins-screenshots": "$1 (captures d’écran : $2)", + "config-extensions-requires": "$1 (nécessite $2)", "config-screenshot": "Captures d’écrans", "mainpagetext": "MediaWiki a été installé.", "mainpagedocfooter": "Consultez le [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Guide de l’utilisateur] pour plus d’informations sur l’utilisation de ce logiciel de wiki.\n\n== Pour démarrer ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Liste des paramètres de configuration]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/fr Questions courantes sur MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Liste de discussion sur les distributions de MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Adaptez MediaWiki dans votre langue]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Apprendre comment lutter contre le pourriel dans votre wiki]" diff --git a/includes/installer/i18n/he.json b/includes/installer/i18n/he.json index d388a595e9..72a4d7d0d6 100644 --- a/includes/installer/i18n/he.json +++ b/includes/installer/i18n/he.json @@ -318,6 +318,7 @@ "config-nofile": "הקובץ \"$1\" לא נמצא. האם הוא נמחק?", "config-extension-link": "הידעת שמדיה־ויקי תומכת ב־[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions הרחבות]?\n\nבאפשרותך לעיין ב־[https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category הרחבות לפי קטגוריה].", "config-skins-screenshots": "$1 (צילומי מסך: $2)", + "config-extensions-requires": "$1 (נדרשת ההרחבה $2)", "config-screenshot": "צילום מסך", "mainpagetext": "תוכנת מדיה־ויקי הותקנה בהצלחה.", "mainpagedocfooter": "היעזרו ב[https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents מדריך למשתמש] למידע על שימוש בתוכנת הוויקי.\n\n== קישורים שימושיים ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings רשימת ההגדרות]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ שאלות ותשובות על מדיה־ויקי]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce רשימת התפוצה על השקת גרסאות]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources תרגום מדיה־ויקי לשפה שלך]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam איך להיאבק נגד ספאם באתר הוויקי שלך]" diff --git a/includes/installer/i18n/it.json b/includes/installer/i18n/it.json index d9b0dbc0bb..b9f04828a9 100644 --- a/includes/installer/i18n/it.json +++ b/includes/installer/i18n/it.json @@ -20,7 +20,8 @@ "Matteocng", "Einreiher", "Tosky", - "Selven" + "Selven", + "Sarah Bernabei" ] }, "config-desc": "Programma di installazione per MediaWiki", @@ -325,6 +326,7 @@ "config-help-tooltip": "fai clic per espandere", "config-nofile": "Il file \"$1\" non può essere trovato. È stato eliminato?", "config-extension-link": "Sapevi che il tuo wiki supporta le [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions estensioni]?\n\nPuoi navigare tra le [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category estensioni per categoria].", + "config-extensions-requires": "$1 (richiesto $2)", "mainpagetext": "MediaWiki è stato installato.", "mainpagedocfooter": "Consulta la [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents guida utente] per maggiori informazioni sull'uso di questo software wiki.\n\n== Per iniziare ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Impostazioni di configurazione]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Domande frequenti su MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailing list annunci MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Trova MediaWiki nella tua lingua]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Imparare a combattere lo spam sul tuo wiki]" } diff --git a/includes/installer/i18n/ja.json b/includes/installer/i18n/ja.json index c54dd81b12..a78481342a 100644 --- a/includes/installer/i18n/ja.json +++ b/includes/installer/i18n/ja.json @@ -334,6 +334,7 @@ "config-nofile": "ファイル「$1」が見つかりませんでした。削除された可能性があります。", "config-extension-link": "あなたのウィキは[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions 拡張機能]をサポートしていることをご存知ですか?\n\n[https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category カテゴリ別で拡張機能を見る]か[https://www.mediawiki.org/wiki/Extension_Matrix 拡張機能のマトリックス]で拡張機能すべてのリストをご覧になれます。", "config-skins-screenshots": "$1 (スクリーンショット: $2)", + "config-extensions-requires": "$1($2が必要)", "config-screenshot": "スクリーンショット", "mainpagetext": "MediaWiki はインストール済みです。", "mainpagedocfooter": "ウィキソフトウェアの使い方に関する情報は[https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents 利用者案内]を参照してください。\n\n== はじめましょう ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings/ja 設定の一覧]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/ja MediaWiki よくある質問と回答]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki リリース情報メーリングリスト]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation/ja MediaWiki のあなたの言語へのローカライズ]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam あなたのウィキでスパムと戦う方法を学ぶ]" diff --git a/includes/installer/i18n/ko.json b/includes/installer/i18n/ko.json index e9314ed4fc..3e646bf8d8 100644 --- a/includes/installer/i18n/ko.json +++ b/includes/installer/i18n/ko.json @@ -321,6 +321,7 @@ "config-nofile": "\"$1\" 파일을 찾을 수 없습니다. 이미 삭제되었나요?", "config-extension-link": "당신의 위키가 [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions 확장 기능]을 지원한다는 것을 알고 계십니까?\n\n[https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category 분류별 확장 기능]을 찾아보실 수 있습니다.", "config-skins-screenshots": "$1 (스크린샷: $2)", + "config-extensions-requires": "$1 ($2 필요)", "config-screenshot": "스크린샷", "mainpagetext": "미디어위키가 설치되었습니다.", "mainpagedocfooter": "[https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents 이곳]에서 위키 소프트웨어에 대한 정보를 얻을 수 있습니다.\n\n== 시작하기 ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings 설정하기 목록]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ 미디어위키 FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce 미디어위키 릴리스 메일링 리스트]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources 내 언어로 미디어위키 지역화]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam 당신의 위키에서 스팸에 대처하는 법을 배우세요]" diff --git a/includes/installer/i18n/lb.json b/includes/installer/i18n/lb.json index 66fb4cad0a..cc42c42197 100644 --- a/includes/installer/i18n/lb.json +++ b/includes/installer/i18n/lb.json @@ -205,6 +205,7 @@ "config-help": "Hëllef", "config-help-tooltip": "klickt fir opzeklappen", "config-nofile": "De Fichier \"$1\" gouf net fonnt. Gouf e geläscht?", + "config-extensions-requires": "$1 (brauch $2)", "config-screenshot": "Screenshot", "mainpagetext": "MediaWiki gouf installéiert.", "mainpagedocfooter": "Kuckt w.e.g. [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents d'Benotzerhandbuch] fir Informatiounen iwwer de Gebruach vun der Wiki Software.\n\n== Fir unzefänken ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Hëllef bei der Konfiguratioun]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki-FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Mailinglëscht vun neie MediaWiki-Versiounen]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Lokaliséiert MediaWiki fir Är Sprooch]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Léiert wéi Spam op Ärer Wiki reduzéiert gi kann]" diff --git a/includes/installer/i18n/li.json b/includes/installer/i18n/li.json index afd7569b9c..a1b55a8ecb 100644 --- a/includes/installer/i18n/li.json +++ b/includes/installer/i18n/li.json @@ -1,9 +1,49 @@ { "@metadata": { "authors": [ - "Seb35" + "Seb35", + "Ooswesthoesbes" ] }, - "mainpagetext": "'''MediaWiki software succesvol geïnsjtalleerd.'''", - "mainpagedocfooter": "Raodpleeg de [https://meta.wikimedia.org/wiki/Help:Contents Inhoudsopgave handjleiding] veur informatie euver 't gebroek van de wikisoftware.\n\n== Mieë hölp ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lies mit instellinge]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki VGV (FAQ)]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki mailinglies veur nuuj versies]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Localise MediaWiki for your language]" + "config-desc": "'t Installeringsprogram veur MediaWiki", + "config-title": "MediaWiki-installering $1", + "config-information": "Gegaeves", + "config-localsettings-key": "Opwardeersleutel:", + "config-localsettings-badkey": "De opwardeersleutel dae se höbs opgegaove is ónjuus.", + "config-upgrade-key-missing": "'n Bestäönde installaasje van MediaWiki is aangetroffe.\nPlaats de volgende regel óngeraan dien LocalSettings.php veur dees installaasje bie te wirke:\n\n$1", + "config-localsettings-incomplete": "De bestäönde inhawd van LocalSettings.php liekent incompleet.\nDe variabele $1 is neet ingestèld.\nVeranger LocalSettings.php zodet dees variabele is ingestèld en klik op \"{{int:Config-continue}}\".", + "config-localsettings-connection-error": "'n Fout is opgetraoje tiejes 't verbinje mitte databank mit de instèllinge oet LocalSettings.php. Los 't perbleem op mit de instèllinge en perbeer 't daonao oppernuuj.\n\n$1", + "config-session-error": "Fout bie begin vanne sessie: $1", + "config-your-language": "Dien spraok:", + "config-your-language-help": "Sillekteer de spraok die se wils broeke tiejes 't installaasjepercès.", + "config-wiki-language": "Wikispraok:", + "config-wiki-language-help": "Sillekteer de spraok wo de wiki veurnamelik in weurt gesjreve.", + "config-back": "← Trök", + "config-continue": "Gank door →", + "config-page-language": "Spraok", + "config-page-welcome": "Wilkóm bie MediaWiki!", + "config-page-dbconnect": "Maak verbinjing mit database", + "config-page-upgrade": "Wirk bestäönde installaasje bie", + "config-page-dbsettings": "Databankinstèllinge", + "config-page-name": "Naam", + "config-page-options": "Opties", + "config-page-install": "Installeer", + "config-page-complete": "Vaerdig!", + "config-page-restart": "Begin installering oppernuuj", + "config-page-readme": "Laes mich", + "config-page-releasenotes": "Gaef ópmèrkinge vrie", + "config-page-copying": "Kopieer", + "config-page-upgradedoc": "Wirk bie", + "config-page-existingwiki": "Bestäönde wiki", + "config-restart": "Jao, begin oppernuuj", + "config-env-php": "PHP $1 is geïnstalleerd.", + "config-env-hhvm": "HHVM $1 is geïnstalleerd.", + "config-diff3-bad": "GNU diff3 neet gevónje.", + "config-db-type": "Databanksaort:", + "config-db-host": "Databankgashieër:", + "config-db-host-oracle": "Databank-TNS:", + "config-db-wiki-settings": "Identificeer deze wiki", + "config-db-name": "Databanknaam:", + "mainpagetext": "MediaWiki software geïnsjtalleerd.", + "mainpagedocfooter": "Raodpleeg de [https://meta.wikimedia.org/wiki/Help:Contents Inhoudsopgave handjleiding] veur informatie euver 't gebroek van de wikisoftware.\n\n== Mieë hölp ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lies mit instellinge]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki VGV (FAQ)]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki mailinglies veur nuuj versies]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Maak MediaWiki besjikbaar in dien spraok]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Lieër wie se spam kans verkómme op diene wiki]" } diff --git a/includes/installer/i18n/mk.json b/includes/installer/i18n/mk.json index a9ae2fe12e..4598aa9289 100644 --- a/includes/installer/i18n/mk.json +++ b/includes/installer/i18n/mk.json @@ -315,6 +315,7 @@ "config-nofile": "Податотеката „$1“ не е пронајдена. Да не е избришана?", "config-extension-link": "Дали сте знаеле дека вашето вики поддржува [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions додатоци]?\n\nМожете да ги прелистате [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category по категории]", "config-skins-screenshots": "$1 (екр. снимки: $2)", + "config-extensions-requires": "$1 (бара $2)", "config-screenshot": "екранска снимка", "mainpagetext": "МедијаВики е успешно воспоставен.", "mainpagedocfooter": "Погледнете го [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Упатството за корисници] за подетални информации како се користи вики-програмот.\n\n==Од каде да почнете==\n* [https://meta.wikimedia.org/wiki/Manual:Configuration_settings Список на нагодувања]\n* [https://meta.wikimedia.org/wiki/Manual:FAQ ЧПП (често поставувани прашања) за МедијаВики].\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Поштенски список на МедијаВики за нови верзии]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Локализирајте го МедијаВики на вашиот јазик]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Дознајте како да се борите против спам на вашето вики]" diff --git a/includes/installer/i18n/ml.json b/includes/installer/i18n/ml.json index 55ce44067a..4f158f912c 100644 --- a/includes/installer/i18n/ml.json +++ b/includes/installer/i18n/ml.json @@ -56,6 +56,7 @@ "config-connection-error": "$1.\n\nതാഴെ നൽകിയിരിക്കുന്ന ഹോസ്റ്റ്, ഉപയോക്തൃനാമം, രഹസ്യവാക്ക് എന്നിവ പരിശോധിച്ച് വീണ്ടും ശ്രമിക്കുക.", "config-regenerate": "LocalSettings.php പുനഃസൃഷ്ടിക്കുക →", "config-mysql-engine": "സ്റ്റോറേജ് എൻജിൻ:", + "config-mysql-utf8": "യു.ടി.എഫ്.-8", "config-site-name": "വിക്കിയുടെ പേര്:", "config-site-name-help": "ഇത് ബ്രൗസറിന്റെ ടൈറ്റിൽ ബാറിലും മറ്റനേകം ഇടങ്ങളിലും പ്രദർശിപ്പിക്കപ്പെടും.", "config-site-name-blank": "സൈറ്റിന്റെ പേര് നൽകുക.", diff --git a/includes/installer/i18n/nb.json b/includes/installer/i18n/nb.json index 276d8a9435..c00cf18bc8 100644 --- a/includes/installer/i18n/nb.json +++ b/includes/installer/i18n/nb.json @@ -320,6 +320,7 @@ "config-nofile": "Filen \"$1\" ble ikke funnet. Kan den være blitt slettet?", "config-extension-link": "Visste du at wikien din kan brukes sammen med en mengde [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions utvidelser]?\n\nDu kan sjekke gjennom [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category utvidelser per kategori] eller [https://www.mediawiki.org/wiki/Extension_Matrix utvidelsesmatrisen] for å se den komplette listen av utvidelser.", "config-skins-screenshots": "$1 (skjermbilder: $2)", + "config-extensions-requires": "$1 (krever $2)", "config-screenshot": "skjermbilde", "mainpagetext": "MediaWiki har blitt installert.", "mainpagedocfooter": "Sjekk [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents brukerveiledningen] for å få informasjon om hvordan du bruker wiki-programvaren.\n\n==Hvordan komme igang==\n*[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Innstillingsliste]\n*[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Ofte stilte spørsmål om MediaWiki]\n*[https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki e-postliste]\n*[https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Tilpass MediaWiki for ditt språk]\n*[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Lær deg å beskytte deg mot spam på wikien din]" diff --git a/includes/installer/i18n/pt-br.json b/includes/installer/i18n/pt-br.json index b6b1357ad4..723b25e2e6 100644 --- a/includes/installer/i18n/pt-br.json +++ b/includes/installer/i18n/pt-br.json @@ -333,6 +333,7 @@ "config-nofile": "O arquivo \"$1\" não foi encontrado. Ele foi apagado?", "config-extension-link": "Você sabia que sua wiki suporta [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions extensões]?\n\nVocê pode explorar as [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category extensões por categoria] ou visitar a [https://www.mediawiki.org/wiki/Extension_Matrix Matriz de Extensões] para ver a lista completa.", "config-skins-screenshots": "$1 (screenshots: $2)", + "config-extensions-requires": "$1 (requer $2)", "config-screenshot": "screenshot", "mainpagetext": "O MediaWiki foi instalado.", "mainpagedocfooter": "Consulte o [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Manual de Usuário] para informações de como usar o software wiki.\n\n== Começando ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista de opções de configuração]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ FAQ do MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Lista de discussão com avisos de novas versões do MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Traduza o MediaWiki para seu idioma]" diff --git a/includes/installer/i18n/pt.json b/includes/installer/i18n/pt.json index 433b56721f..11f0255d96 100644 --- a/includes/installer/i18n/pt.json +++ b/includes/installer/i18n/pt.json @@ -231,9 +231,9 @@ "config-profile-help": "As wikis funcionam melhor quando se deixa tantas pessoas editá-las quanto possível.\nNo MediaWiki, é fácil rever as alterações recentes e reverter quaisquer estragos causados por utilizadores novatos ou maliciosos.\n\nNo entanto, muitas pessoas consideram o MediaWiki útil de variadas formas e nem sempre é fácil convencer todas as pessoas dos benefícios desta filosofia wiki.\nPor isso pode optar.\n\nUma '''{{int:config-profile-wiki}}''' permite que todos a editem, sem sequer necessitar de autenticação.\nUma wiki com '''{{int:config-profile-no-anon}}''' atribui mais responsabilidade, mas pode afastar os colaboradores ocasionais.\n\nUm cenário '''{{int:config-profile-fishbowl}}''' permite que os utilizadores aprovados editem, mas que o público visione as páginas, incluindo o historial das mesmas.\nUma '''{{int:config-profile-private}}''' só permite que os utilizadores aprovados visionem as páginas e as editem.\n\nApós a instalação, estarão disponíveis mais configurações de privilégios. Consulte [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights a entrada relevante no Manual].", "config-license": "Direitos de autor e licença:", "config-license-none": "Sem rodapé com a licença", - "config-license-cc-by-sa": "Creative Commons - Atribuição - Partilha nos Mesmos Termos", + "config-license-cc-by-sa": "Creative Commons - Atribuição-CompartilhaIgual", "config-license-cc-by": "Creative Commons - Atribuição", - "config-license-cc-by-nc-sa": "Creative Commons - Atribuição - Uso Não Comercial - Partilha nos Mesmos Termos", + "config-license-cc-by-nc-sa": "Creative Commons - Atribuição-NãoComercial-CompartilhaIgual", "config-license-cc-0": "Creative Commons Zero (Domínio Público)", "config-license-gfdl": "GNU Free Documentation License 1.3 ou posterior", "config-license-pd": "Domínio Público", @@ -323,7 +323,7 @@ "config-install-extension-tables": "A criar as tabelas das extensões ativadas", "config-install-mainpage-failed": "Não foi possível inserir a página principal: $1", "config-install-done": "Parabéns!\nTerminou a instalação do MediaWiki.\n\nO instalador gerou um ficheiro LocalSettings.php.\nEste ficheiro contém todas as configurações.\n\nPrecisa de descarregar o ficheiro e colocá-lo no diretório de raiz da sua instalação (o mesmo diretório onde está o ficheiro index.php). Este descarregamento deverá ter sido iniciado automaticamente.\n\nSe o descarregamento não foi iniciado, ou se o cancelou, pode recomeçá-lo clicando na hiperligação abaixo:\n\n$3\n\nNota: Se não o descarregar agora, o ficheiro que foi gerado deixará de estar disponível quando sair do processo de instalação.\n\nDepois de terminar o passo anterior, pode [$2 entrar na wiki].", - "config-install-done-path": "Parabéns!\nTerminou a instalação do MediaWiki.\n\nO instalador gerou um ficheiro LocalSettings.php.\nEste ficheiro contém todas as configurações.\n\nPrecisa de descarregar o ficheiro e colocá-lo no diretório $4. Este descarregamento deverá ter sido iniciado automaticamente.\n\nSe o descarregamento não foi iniciado, ou se o cancelou, pode recomeçá-lo clicando na ligação abaixo:\n\n$3\n\nNota: Se não fizer o descarregamento agora, o ficheiro que foi gerado deixará de estar disponível quando sair do processo de instalação.\n\nDepois de terminar o passo anterior, pode [$2 entrar na wiki].", + "config-install-done-path": "Parabéns!\nTerminou a instalação do MediaWiki.\n\nO instalador gerou um ficheiro LocalSettings.php.\nEste ficheiro contém todas as configurações.\n\nPrecisa de descarregar o ficheiro e colocá-lo no diretório $4. Este descarregamento deverá ter sido iniciado automaticamente.\n\nSe o descarregamento não foi iniciado, ou se o cancelou, pode recomeçá-lo clicando a hiperligação abaixo:\n\n$3\n\nNota: Se não fizer o descarregamento agora, o ficheiro que foi gerado deixará de estar disponível quando sair do processo de instalação.\n\nDepois de terminar o passo anterior, pode [$2 entrar na wiki].", "config-install-success": "O MediaWiki foi instalado. Já pode visitar <$1$2> para ver a sua wiki.\nSe tiver dúvidas, veja a nossa lista de perguntas frequentes,\n, ou utilize um dos fóruns de suporte indicados nessa página.", "config-download-localsettings": "Descarregar LocalSettings.php", "config-help": "ajuda", @@ -331,6 +331,7 @@ "config-nofile": "Não foi possível encontrar o ficheiro \"$1\". Terá sido apagado?", "config-extension-link": "Sabia que a sua wiki suporta [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions extensões]?\n\nPode consultar as [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category extensões por categoria] ou a [https://www.mediawiki.org/wiki/Extension_Matrix Matriz de Extensões] para ver a lista completa de extensões.", "config-skins-screenshots": "$1 (capturas de ecrã: $2)", + "config-extensions-requires": "$1 (requer $2)", "config-screenshot": "captura de ecrã", "mainpagetext": "O MediaWiki foi instalado.", "mainpagedocfooter": "Consulte a [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents Ajuda do MediaWiki] para informações sobre o uso do software wiki.\n\n== Onde começar ==\n\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista de opções de configuração]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ Perguntas e respostas frequentes sobre o MediaWiki]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Subscreva a lista de divulgação de novas versões do MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Regionalize o MediaWiki para a sua língua]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Aprenda a combater spam na sua wiki]" diff --git a/includes/installer/i18n/qqq.json b/includes/installer/i18n/qqq.json index d82c74b162..fb546ab9c5 100644 --- a/includes/installer/i18n/qqq.json +++ b/includes/installer/i18n/qqq.json @@ -332,6 +332,7 @@ "config-extension-link": "Shown on last page of installation to inform about possible extensions.\n{{Identical|Did you know}}", "config-skins-screenshots": "Radio button text, $1 is the skin name, and $2 is a list of links to screenshots of that skin", "config-skins-screenshot": "Radio button text, $1 is the skin name, and $2 is a link to a screenshot of that skin, where the link text is {{msg-mw|config-screenshot}}.", + "config-extensions-requires": "Radio button text, $1 is the extension name, and $2 are links to other extensions that this one requires.\n{{Identical|Require}}", "config-screenshot": "Link text for the link in {{msg-mw|config-skins-screenshot}}\n{{Identical|Screenshot}}", "mainpagetext": "Along with {{msg-mw|mainpagedocfooter}}, the text you will see on the Main Page when your wiki is installed.", "mainpagedocfooter": "Along with {{msg-mw|mainpagetext}}, the text you will see on the Main Page when your wiki is installed.\nThis might be a good place to put information about {{GRAMMAR:}}. See [[{{NAMESPACE}}:{{BASEPAGENAME}}/fi]] for an example. For languages having grammatical distinctions and not having an appropriate {{GRAMMAR:}} software available, a suggestion to check and possibly amend the messages having {{SITENAME}} may be valuable. See [[{{NAMESPACE}}:{{BASEPAGENAME}}/ksh]] for an example." diff --git a/includes/installer/i18n/ru.json b/includes/installer/i18n/ru.json index f47e3f76b2..58637f8bac 100644 --- a/includes/installer/i18n/ru.json +++ b/includes/installer/i18n/ru.json @@ -95,7 +95,7 @@ "config-no-uri": "'''Ошибка:''' Не могу определить текущий URI.\nУстановка прервана.", "config-no-cli-uri": "'''Предупреждение''': нет задан параметр --scriptpath, используется по умолчанию: $1 .", "config-using-server": "Используется имя сервера «$1».", - "config-using-uri": "Используется имя сервера \"$1$2\".", + "config-using-uri": "Используется URL сервера \"$1$2\".", "config-uploads-not-safe": "'''Внимание:''' директория, используемая по умолчанию для загрузок ($1) уязвима к выполнению произвольных скриптов.\nХотя MediaWiki проверяет все загружаемые файлы на наличие угроз, настоятельно рекомендуется [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security#Upload_security закрыть данную уязвимость] перед включением загрузки файлов.", "config-no-cli-uploads-check": "'''Предупреждение:''' каталог для загрузки по умолчанию ( $1 ) не проверялся на уязвимости\n на выполнение произвольного сценария во время установки CLI.", "config-brokenlibxml": "В вашей системе имеется сочетание версий PHP и libxml2, которое может привести к скрытым повреждениям данных в MediaWiki и других веб-приложениях.\nОбновите libxml2 до версии 2.7.3 или старше ([https://bugs.php.net/bug.php?id=45996 сведения об ошибке]).\nУстановка прервана.", @@ -336,6 +336,7 @@ "config-nofile": "Файл \"$1\" не удается найти. Он был удален?", "config-extension-link": "Знаете ли вы, что ваш вики-проект поддерживает [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions расширения]?\n\nВы можете просмотреть [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category расширения по категориям] или [https://www.mediawiki.org/wiki/Extension_Matrix матрицу расширений], чтобы увидеть их полный список.", "config-skins-screenshots": "$1 (скриншоты: $2)", + "config-extensions-requires": "$1 (требуется $2)", "config-screenshot": "скриншот", "mainpagetext": "MediaWiki успешно установлена.", "mainpagedocfooter": "Информацию по работе с этой вики можно найти в [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents справочном руководстве].\n\n== Некоторые полезные ресурсы ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Список возможных настроек];\n* [https://www.mediawiki.org/wiki/Manual:FAQ/ru Часто задаваемые вопросы и ответы по MediaWiki];\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce Рассылка уведомлений о выходе новых версий MediaWiki].\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Перевод MediaWiki на свой язык]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Узнайте, как бороться со спамом в вашей вики]" diff --git a/includes/installer/i18n/sv.json b/includes/installer/i18n/sv.json index 0ca73d3d8f..ba2822c4ad 100644 --- a/includes/installer/i18n/sv.json +++ b/includes/installer/i18n/sv.json @@ -317,6 +317,7 @@ "config-nofile": "Filen \"$1\" kunde inte hittas. Har den raderats?", "config-extension-link": "Visste du att din wiki stödjer [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions tillägg]?\n\nDu kan bläddra [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category tillägg efter kategori].", "config-skins-screenshots": "$1 (skärmbilder: $2)", + "config-extensions-requires": "$1 (kräver $2)", "config-screenshot": "skärmbild", "mainpagetext": "MediaWiki har installerats utan problem.", "mainpagedocfooter": "Information om hur wiki-programvaran används finns i [https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents användarguiden].\n\n== Att komma igång ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings Lista över konfigurationsinställningar]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki FAQ]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce E-postlista för nya versioner av MediaWiki]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources Lokalisera MediaWiki för ditt språk]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam Läs om hur du bekämpar spam på din wiki]" diff --git a/includes/installer/i18n/yi.json b/includes/installer/i18n/yi.json index 5c5aff5a01..fe32b2254a 100644 --- a/includes/installer/i18n/yi.json +++ b/includes/installer/i18n/yi.json @@ -70,6 +70,7 @@ "config-download-localsettings": "אראפלאדן LocalSettings.php", "config-help": "הילף", "config-nofile": "מ'האט נישט געקענט טרעפן די טעקע \"$1\". צי האט מען זי אויסגעמעקט?", + "config-extensions-requires": "$1 (פֿאדערט $2)", "mainpagetext": " מעדיעוויקי אינסטאלירט.", "mainpagedocfooter": "גיט זיך אן עצה מיט [https://meta.wikimedia.org/wiki/Help:Contents באניצער'ס וועגווײַזער] פֿאר אינפֿארמאציע וויאזוי זיך באנוצן מיט וויקי ווייכוואַרג.\n\n== נוצליכע וועבלינקען פֿאַר אנהייבערס ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings רשימה פון קאנפֿיגוראציעס]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ אפֿט געפֿרעגטע שאלות]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce מעדיעוויקי באפֿרײַאונג פאסטליסטע]* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources איבערזעצן מעדיעוויקי אין אײַער שפראך]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam לערנט וויאזוי צו באקעמפן בפעם אויף אייער וויקי]" } diff --git a/includes/installer/i18n/zh-hans.json b/includes/installer/i18n/zh-hans.json index 635fc34793..f3a4321842 100644 --- a/includes/installer/i18n/zh-hans.json +++ b/includes/installer/i18n/zh-hans.json @@ -330,6 +330,7 @@ "config-nofile": "找不到文件“$1”。它是否已被删除?", "config-extension-link": "您是否知道您的wiki支持[https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions 扩展]?\n\n您可以浏览[https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category 扩展分类]或[https://www.mediawiki.org/wiki/Extension_Matrix 扩展矩阵]以查看完整的扩展列表。", "config-skins-screenshots": "$1(截图:$2)", + "config-extensions-requires": "$1(需要$2)", "config-screenshot": "截图", "mainpagetext": "已安装MediaWiki。", "mainpagedocfooter": "请查阅[https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents 用户指导]以获取使用本wiki软件的信息!\n\n== 入门 ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings MediaWiki配置设置列表]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ/zh-hans MediaWiki常见问题]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki发布邮件列表]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources 本地化MediaWiki到您的语言]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam 了解如何在您的wiki上打击破坏]" diff --git a/includes/installer/i18n/zh-hant.json b/includes/installer/i18n/zh-hant.json index acc830266d..93c042e6fd 100644 --- a/includes/installer/i18n/zh-hant.json +++ b/includes/installer/i18n/zh-hant.json @@ -329,6 +329,7 @@ "config-nofile": "查無檔案 \"$1\",是否已被刪除?", "config-extension-link": "您是否了解您的 Wiki 支援 [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Extensions 擴充套件]?\n\n\n您可以瀏覽 [https://www.mediawiki.org/wiki/Special:MyLanguage/Category:Extensions_by_category 擴充套件分類] 或 [https://www.mediawiki.org/wiki/Extension_Matrix 擴充套件資料表] 以取得相關的資訊。", "config-skins-screenshots": "$1 (螢幕截圖: $2)", + "config-extensions-requires": "$1(需要 $2)", "config-screenshot": "螢幕截圖", "mainpagetext": "已安裝 MediaWiki。", "mainpagedocfooter": "有關使用wiki的訊息,請參閱[https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Contents 使用者指南]。\n\n== 新手入門 ==\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Configuration_settings 系統設定]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:FAQ MediaWiki常見問題]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki郵寄清單]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Localisation#Translation_resources 將MediaWiki翻譯至您的語言]\n* [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Combating_spam 了解如何在您的wiki上防禦破壞]" diff --git a/includes/installer/i18n/zh-hk.json b/includes/installer/i18n/zh-hk.json index dfffd68db7..f64add14f7 100644 --- a/includes/installer/i18n/zh-hk.json +++ b/includes/installer/i18n/zh-hk.json @@ -1,8 +1,12 @@ { - "@metadata": { - "authors": [ - "Mark85296341" - ] - }, - "mainpagedocfooter": "請參閱[https://meta.wikimedia.org/wiki/Help:Contents 用戶手冊]以獲得使用此 wiki 軟件的訊息!\n\n== 入門 ==\n* [https://www.mediawiki.org/wiki/Manual:Configuration_settings MediaWiki 配置設定清單]\n* [https://www.mediawiki.org/wiki/Manual:FAQ MediaWiki 常見問題解答]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki 發佈郵件清單]" + "@metadata": { + "authors": [ + "Mark85296341", + "Liuxinyu970226" + ] + }, + "config-license-cc-by-sa": "共享創意姓名標示-相同方式分享", + "config-license-cc-by": "共享創意姓名標示", + "config-cc-not-chosen": "請選擇您要使用的共享創意授權條款,然後點選 \"proceed\"。", + "mainpagedocfooter": "請參閱[https://meta.wikimedia.org/wiki/Help:Contents 用戶手冊]以獲得使用此 wiki 軟件的訊息!\n\n== 入門 ==\n* [https://www.mediawiki.org/wiki/Manual:Configuration_settings MediaWiki 配置設定清單]\n* [https://www.mediawiki.org/wiki/Manual:FAQ MediaWiki 常見問題解答]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-announce MediaWiki 發佈郵件清單]" } diff --git a/includes/interwiki/Interwiki.php b/includes/interwiki/Interwiki.php index 5a996d9d11..657849adab 100644 --- a/includes/interwiki/Interwiki.php +++ b/includes/interwiki/Interwiki.php @@ -36,7 +36,7 @@ class Interwiki { protected $mAPI; /** @var string The name of the database (for a connection to be established - * with wfGetLB( 'wikiid' )) + * with LBFactory::getMainLB( 'wikiid' )) */ protected $mWikiID; diff --git a/includes/jobqueue/Job.php b/includes/jobqueue/Job.php index 8508861fbc..f9c416f3af 100644 --- a/includes/jobqueue/Job.php +++ b/includes/jobqueue/Job.php @@ -50,6 +50,12 @@ abstract class Job implements IJobSpecification { /** @var callable[] */ protected $teardownCallbacks = []; + /** @var int Bitfield of JOB_* class constants */ + protected $executionFlags = 0; + + /** @var int Job must not be wrapped in the usual explicit LBFactory transaction round */ + const JOB_NO_EXPLICIT_TRX_ROUND = 1; + /** * Run the job * @return bool Success @@ -108,6 +114,15 @@ abstract class Job implements IJobSpecification { } } + /** + * @param int $flag JOB_* class constant + * @return bool + * @since 1.31 + */ + public function hasExecutionFlag( $flag ) { + return ( $this->executionFlags && $flag ) === $flag; + } + /** * Batch-insert a group of jobs into the queue. * This will be wrapped in a transaction with a forced commit. diff --git a/includes/jobqueue/JobQueueDB.php b/includes/jobqueue/JobQueueDB.php index b68fdaefb3..c13f5396af 100644 --- a/includes/jobqueue/JobQueueDB.php +++ b/includes/jobqueue/JobQueueDB.php @@ -190,13 +190,13 @@ class JobQueueDB extends JobQueue { // If the connection is busy with a transaction, then defer the job writes // until right before the main round commit step. Any errors that bubble // up will rollback the main commit round. - // b) mysql/postgres; DB connection is generally a separate CONN_TRX_AUTO handle. + // b) mysql/postgres; DB connection is generally a separate CONN_TRX_AUTOCOMMIT handle. // No transaction is active nor will be started by writes, so enqueue the jobs // now so that any errors will show up immediately as the interface expects. Any // errors that bubble up will rollback the main commit round. $fname = __METHOD__; $dbw->onTransactionPreCommitOrIdle( - function () use ( $dbw, $jobs, $flags, $fname ) { + function ( IDatabase $dbw ) use ( $jobs, $flags, $fname ) { $this->doBatchPushInternal( $dbw, $jobs, $flags, $fname ); }, $fname @@ -507,8 +507,8 @@ class JobQueueDB extends JobQueue { // jobs to become no-ops without any actual jobs that made them redundant. $dbw = $this->getMasterDB(); $cache = $this->dupCache; - $dbw->onTransactionIdle( - function () use ( $cache, $params, $key, $dbw ) { + $dbw->onTransactionCommitOrIdle( + function () use ( $cache, $params, $key ) { $timestamp = $cache->get( $key ); // current last timestamp of this job if ( $timestamp && $timestamp >= $params['rootJobTimestamp'] ) { return true; // a newer version of this root job was enqueued @@ -780,7 +780,7 @@ class JobQueueDB extends JobQueue { return ( $lb->getServerType( $lb->getWriterIndex() ) !== 'sqlite' ) // Keep a separate connection to avoid contention and deadlocks; // However, SQLite has the opposite behavior due to DB-level locking. - ? $lb->getConnectionRef( $index, [], $this->wiki, $lb::CONN_TRX_AUTO ) + ? $lb->getConnectionRef( $index, [], $this->wiki, $lb::CONN_TRX_AUTOCOMMIT ) // Jobs insertion will be defered until the PRESEND stage to reduce contention. : $lb->getConnectionRef( $index, [], $this->wiki ); } diff --git a/includes/jobqueue/JobRunner.php b/includes/jobqueue/JobRunner.php index fa7d605731..977fbdaaa5 100644 --- a/includes/jobqueue/JobRunner.php +++ b/includes/jobqueue/JobRunner.php @@ -290,7 +290,9 @@ class JobRunner implements LoggerAwareInterface { $jobStartTime = microtime( true ); try { $fnameTrxOwner = get_class( $job ) . '::run'; // give run() outer scope - $lbFactory->beginMasterChanges( $fnameTrxOwner ); + if ( !$job->hasExecutionFlag( $job::JOB_NO_EXPLICIT_TRX_ROUND ) ) { + $lbFactory->beginMasterChanges( $fnameTrxOwner ); + } $status = $job->run(); $error = $job->getLastError(); $this->commitMasterChanges( $lbFactory, $job, $fnameTrxOwner ); diff --git a/includes/jobqueue/jobs/RecentChangesUpdateJob.php b/includes/jobqueue/jobs/RecentChangesUpdateJob.php index 77daca7676..8f508283d7 100644 --- a/includes/jobqueue/jobs/RecentChangesUpdateJob.php +++ b/includes/jobqueue/jobs/RecentChangesUpdateJob.php @@ -35,6 +35,7 @@ class RecentChangesUpdateJob extends Job { throw new Exception( "Missing 'type' parameter." ); } + $this->executionFlags |= self::JOB_NO_EXPLICIT_TRX_ROUND; $this->removeDuplicates = true; } @@ -127,124 +128,118 @@ class RecentChangesUpdateJob extends Job { $window = $wgActiveUserDays * 86400; $dbw = wfGetDB( DB_MASTER ); - // JobRunner uses DBO_TRX, but doesn't call begin/commit itself; - // onTransactionIdle() will run immediately since there is no trx. - $dbw->onTransactionIdle( - function () use ( $dbw, $days, $window ) { - $factory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory(); - $ticket = $factory->getEmptyTransactionTicket( __METHOD__ ); - // Avoid disconnect/ping() cycle that makes locks fall off - $dbw->setSessionOptions( [ 'connTimeout' => 900 ] ); - - $lockKey = wfWikiID() . '-activeusers'; - if ( !$dbw->lock( $lockKey, __METHOD__, 0 ) ) { - // Exclusive update (avoids duplicate entries)… it's usually fine to just drop out here, - // if the Job is already running. - return; - } + $factory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory(); + $ticket = $factory->getEmptyTransactionTicket( __METHOD__ ); - $nowUnix = time(); - // Get the last-updated timestamp for the cache - $cTime = $dbw->selectField( 'querycache_info', - 'qci_timestamp', - [ 'qci_type' => 'activeusers' ] - ); - $cTimeUnix = $cTime ? wfTimestamp( TS_UNIX, $cTime ) : 1; - - // Pick the date range to fetch from. This is normally from the last - // update to till the present time, but has a limited window for sanity. - // If the window is limited, multiple runs are need to fully populate it. - $sTimestamp = max( $cTimeUnix, $nowUnix - $days * 86400 ); - $eTimestamp = min( $sTimestamp + $window, $nowUnix ); - - // Get all the users active since the last update - $actorQuery = ActorMigration::newMigration()->getJoin( 'rc_user' ); - $res = $dbw->select( - [ 'recentchanges' ] + $actorQuery['tables'], - [ - 'rc_user_text' => $actorQuery['fields']['rc_user_text'], - 'lastedittime' => 'MAX(rc_timestamp)' - ], - [ - $actorQuery['fields']['rc_user'] . ' > 0', // actual accounts - 'rc_type != ' . $dbw->addQuotes( RC_EXTERNAL ), // no wikidata - 'rc_log_type IS NULL OR rc_log_type != ' . $dbw->addQuotes( 'newusers' ), - 'rc_timestamp >= ' . $dbw->addQuotes( $dbw->timestamp( $sTimestamp ) ), - 'rc_timestamp <= ' . $dbw->addQuotes( $dbw->timestamp( $eTimestamp ) ) - ], - __METHOD__, - [ - 'GROUP BY' => [ 'rc_user_text' ], - 'ORDER BY' => 'NULL' // avoid filesort - ], - $actorQuery['joins'] - ); - $names = []; - foreach ( $res as $row ) { - $names[$row->rc_user_text] = $row->lastedittime; - } + $lockKey = wfWikiID() . '-activeusers'; + if ( !$dbw->lock( $lockKey, __METHOD__, 0 ) ) { + // Exclusive update (avoids duplicate entries)… it's usually fine to just + // drop out here, if the Job is already running. + return; + } - // Find which of the recently active users are already accounted for - if ( count( $names ) ) { - $res = $dbw->select( 'querycachetwo', - [ 'user_name' => 'qcc_title' ], - [ - 'qcc_type' => 'activeusers', - 'qcc_namespace' => NS_USER, - 'qcc_title' => array_keys( $names ), - 'qcc_value >= ' . $dbw->addQuotes( $nowUnix - $days * 86400 ), // TS_UNIX - ], - __METHOD__ - ); - // Note: In order for this to be actually consistent, we would need - // to update these rows with the new lastedittime. - foreach ( $res as $row ) { - unset( $names[$row->user_name] ); - } - } + // Long-running queries expected + $dbw->setSessionOptions( [ 'connTimeout' => 900 ] ); - // Insert the users that need to be added to the list - if ( count( $names ) ) { - $newRows = []; - foreach ( $names as $name => $lastEditTime ) { - $newRows[] = [ - 'qcc_type' => 'activeusers', - 'qcc_namespace' => NS_USER, - 'qcc_title' => $name, - 'qcc_value' => wfTimestamp( TS_UNIX, $lastEditTime ), - 'qcc_namespacetwo' => 0, // unused - 'qcc_titletwo' => '' // unused - ]; - } - foreach ( array_chunk( $newRows, 500 ) as $rowBatch ) { - $dbw->insert( 'querycachetwo', $rowBatch, __METHOD__ ); - $factory->commitAndWaitForReplication( __METHOD__, $ticket ); - } - } + $nowUnix = time(); + // Get the last-updated timestamp for the cache + $cTime = $dbw->selectField( 'querycache_info', + 'qci_timestamp', + [ 'qci_type' => 'activeusers' ] + ); + $cTimeUnix = $cTime ? wfTimestamp( TS_UNIX, $cTime ) : 1; + + // Pick the date range to fetch from. This is normally from the last + // update to till the present time, but has a limited window for sanity. + // If the window is limited, multiple runs are need to fully populate it. + $sTimestamp = max( $cTimeUnix, $nowUnix - $days * 86400 ); + $eTimestamp = min( $sTimestamp + $window, $nowUnix ); + + // Get all the users active since the last update + $actorQuery = ActorMigration::newMigration()->getJoin( 'rc_user' ); + $res = $dbw->select( + [ 'recentchanges' ] + $actorQuery['tables'], + [ + 'rc_user_text' => $actorQuery['fields']['rc_user_text'], + 'lastedittime' => 'MAX(rc_timestamp)' + ], + [ + $actorQuery['fields']['rc_user'] . ' > 0', // actual accounts + 'rc_type != ' . $dbw->addQuotes( RC_EXTERNAL ), // no wikidata + 'rc_log_type IS NULL OR rc_log_type != ' . $dbw->addQuotes( 'newusers' ), + 'rc_timestamp >= ' . $dbw->addQuotes( $dbw->timestamp( $sTimestamp ) ), + 'rc_timestamp <= ' . $dbw->addQuotes( $dbw->timestamp( $eTimestamp ) ) + ], + __METHOD__, + [ + 'GROUP BY' => [ 'rc_user_text' ], + 'ORDER BY' => 'NULL' // avoid filesort + ], + $actorQuery['joins'] + ); + $names = []; + foreach ( $res as $row ) { + $names[$row->rc_user_text] = $row->lastedittime; + } + + // Find which of the recently active users are already accounted for + if ( count( $names ) ) { + $res = $dbw->select( 'querycachetwo', + [ 'user_name' => 'qcc_title' ], + [ + 'qcc_type' => 'activeusers', + 'qcc_namespace' => NS_USER, + 'qcc_title' => array_keys( $names ), + 'qcc_value >= ' . $dbw->addQuotes( $nowUnix - $days * 86400 ), // TS_UNIX + ], + __METHOD__ + ); + // Note: In order for this to be actually consistent, we would need + // to update these rows with the new lastedittime. + foreach ( $res as $row ) { + unset( $names[$row->user_name] ); + } + } + + // Insert the users that need to be added to the list + if ( count( $names ) ) { + $newRows = []; + foreach ( $names as $name => $lastEditTime ) { + $newRows[] = [ + 'qcc_type' => 'activeusers', + 'qcc_namespace' => NS_USER, + 'qcc_title' => $name, + 'qcc_value' => wfTimestamp( TS_UNIX, $lastEditTime ), + 'qcc_namespacetwo' => 0, // unused + 'qcc_titletwo' => '' // unused + ]; + } + foreach ( array_chunk( $newRows, 500 ) as $rowBatch ) { + $dbw->insert( 'querycachetwo', $rowBatch, __METHOD__ ); + $factory->commitAndWaitForReplication( __METHOD__, $ticket ); + } + } + + // If a transaction was already started, it might have an old + // snapshot, so kludge the timestamp range back as needed. + $asOfTimestamp = min( $eTimestamp, (int)$dbw->trxTimestamp() ); + + // Touch the data freshness timestamp + $dbw->replace( 'querycache_info', + [ 'qci_type' ], + [ 'qci_type' => 'activeusers', + 'qci_timestamp' => $dbw->timestamp( $asOfTimestamp ) ], // not always $now + __METHOD__ + ); + + $dbw->unlock( $lockKey, __METHOD__ ); - // If a transaction was already started, it might have an old - // snapshot, so kludge the timestamp range back as needed. - $asOfTimestamp = min( $eTimestamp, (int)$dbw->trxTimestamp() ); - - // Touch the data freshness timestamp - $dbw->replace( 'querycache_info', - [ 'qci_type' ], - [ 'qci_type' => 'activeusers', - 'qci_timestamp' => $dbw->timestamp( $asOfTimestamp ) ], // not always $now - __METHOD__ - ); - - $dbw->unlock( $lockKey, __METHOD__ ); - - // Rotate out users that have not edited in too long (according to old data set) - $dbw->delete( 'querycachetwo', - [ - 'qcc_type' => 'activeusers', - 'qcc_value < ' . $dbw->addQuotes( $nowUnix - $days * 86400 ) // TS_UNIX - ], - __METHOD__ - ); - }, + // Rotate out users that have not edited in too long (according to old data set) + $dbw->delete( 'querycachetwo', + [ + 'qcc_type' => 'activeusers', + 'qcc_value < ' . $dbw->addQuotes( $nowUnix - $days * 86400 ) // TS_UNIX + ], __METHOD__ ); } diff --git a/includes/jobqueue/utils/PurgeJobUtils.php b/includes/jobqueue/utils/PurgeJobUtils.php index ba80c8e450..5d8a6cf243 100644 --- a/includes/jobqueue/utils/PurgeJobUtils.php +++ b/includes/jobqueue/utils/PurgeJobUtils.php @@ -37,7 +37,9 @@ class PurgeJobUtils { return; } - $dbw->onTransactionIdle( + DeferredUpdates::addUpdate( new AutoCommitUpdate( + $dbw, + __METHOD__, function () use ( $dbw, $namespace, $dbkeys ) { $services = MediaWikiServices::getInstance(); $lbFactory = $services->getDBLoadBalancerFactory(); @@ -74,8 +76,7 @@ class PurgeJobUtils { ); $lbFactory->commitAndWaitForReplication( __METHOD__, $ticket ); } - }, - __METHOD__ - ); + } + ) ); } } diff --git a/includes/libs/CSSMin.php b/includes/libs/CSSMin.php index 3d1c8b800d..a6014b1ecd 100644 --- a/includes/libs/CSSMin.php +++ b/includes/libs/CSSMin.php @@ -173,18 +173,14 @@ class CSSMin { /** * Serialize a string (escape and quote) for use as a CSS string value. - * http://www.w3.org/TR/2013/WD-cssom-20131205/#serialize-a-string + * https://www.w3.org/TR/2016/WD-cssom-1-20160317/#serialize-a-string * * @param string $value * @return string - * @throws Exception */ public static function serializeStringValue( $value ) { - if ( strstr( $value, "\0" ) ) { - throw new Exception( "Invalid character in CSS string" ); - } - $value = strtr( $value, [ '\\' => '\\\\', '"' => '\\"' ] ); - $value = preg_replace_callback( '/[\x01-\x1f\x7f-\x9f]/', function ( $match ) { + $value = strtr( $value, [ "\0" => "\\fffd ", '\\' => '\\\\', '"' => '\\"' ] ); + $value = preg_replace_callback( '/[\x01-\x1f\x7f]/', function ( $match ) { return '\\' . base_convert( ord( $match[0] ), 10, 16 ) . ' '; }, $value ); return '"' . $value . '"'; @@ -424,11 +420,11 @@ class CSSMin { // is only supported in PHP 5.6. Use a getter method for now. $urlRegex = '(' . // Unquoted url - 'url\(\s*(?P[^\'"][^\?\)]*?)(?P\?[^\)]*?|)\s*\)' . + 'url\(\s*(?P[^\s\'"][^\?\)]+?)(?P\?[^\)]*?|)\s*\)' . // Single quoted url - '|url\(\s*\'(?P[^\?\']*?)(?P\?[^\']*?|)\'\s*\)' . + '|url\(\s*\'(?P[^\?\']+?)(?P\?[^\']*?|)\'\s*\)' . // Double quoted url - '|url\(\s*"(?P[^\?"]*?)(?P\?[^"]*?|)"\s*\)' . + '|url\(\s*"(?P[^\?"]+?)(?P\?[^"]*?|)"\s*\)' . ')'; } return $urlRegex; @@ -446,6 +442,9 @@ class CSSMin { $match['file'] = $match['file1']; $match['query'] = $match['query1']; } else { + if ( !isset( $match['file2'] ) || $match['file2'][1] === -1 ) { + throw new Exception( 'URL must be non-empty' ); + } $match['file'] = $match['file2']; $match['query'] = $match['query2']; } @@ -457,6 +456,9 @@ class CSSMin { $match['file'] = $match['file1']; $match['query'] = $match['query1']; } else { + if ( !isset( $match['file2'] ) || $match['file2'] === '' ) { + throw new Exception( 'URL must be non-empty' ); + } $match['file'] = $match['file2']; $match['query'] = $match['query2']; } diff --git a/includes/libs/CryptRand.php b/includes/libs/CryptRand.php index 474c564aeb..f7702dd3ac 100644 --- a/includes/libs/CryptRand.php +++ b/includes/libs/CryptRand.php @@ -259,43 +259,40 @@ class CryptRand { } } - if ( strlen( $buffer ) < $bytes ) { + if ( strlen( $buffer ) < $bytes && function_exists( 'mcrypt_create_iv' ) ) { // If available make use of mcrypt_create_iv URANDOM source to generate randomness // On unix-like systems this reads from /dev/urandom but does it without any buffering // and bypasses openbasedir restrictions, so it's preferable to reading directly // On Windows starting in PHP 5.3.0 Windows' native CryptGenRandom is used to generate // entropy so this is also preferable to just trying to read urandom because it may work // on Windows systems as well. - if ( function_exists( 'mcrypt_create_iv' ) ) { - $rem = $bytes - strlen( $buffer ); - $iv = mcrypt_create_iv( $rem, MCRYPT_DEV_URANDOM ); - if ( $iv === false ) { - $this->logger->debug( "mcrypt_create_iv returned false." ); - } else { - $buffer .= $iv; - $this->logger->debug( "mcrypt_create_iv generated " . strlen( $iv ) . - " bytes of randomness." ); - } + $rem = $bytes - strlen( $buffer ); + $iv = mcrypt_create_iv( $rem, MCRYPT_DEV_URANDOM ); + if ( $iv === false ) { + $this->logger->debug( "mcrypt_create_iv returned false." ); + } else { + $buffer .= $iv; + $this->logger->debug( "mcrypt_create_iv generated " . strlen( $iv ) . + " bytes of randomness." ); } } - if ( strlen( $buffer ) < $bytes ) { - if ( function_exists( 'openssl_random_pseudo_bytes' ) ) { - $rem = $bytes - strlen( $buffer ); - $openssl_bytes = openssl_random_pseudo_bytes( $rem, $openssl_strong ); - if ( $openssl_bytes === false ) { - $this->logger->debug( "openssl_random_pseudo_bytes returned false." ); - } else { - $buffer .= $openssl_bytes; - $this->logger->debug( "openssl_random_pseudo_bytes generated " . - strlen( $openssl_bytes ) . " bytes of " . - ( $openssl_strong ? "strong" : "weak" ) . " randomness." ); - } - if ( strlen( $buffer ) >= $bytes ) { - // openssl tells us if the random source was strong, if some of our data was generated - // using it use it's say on whether the randomness is strong - $this->strong = !!$openssl_strong; - } + if ( strlen( $buffer ) < $bytes && function_exists( 'openssl_random_pseudo_bytes' ) ) { + $rem = $bytes - strlen( $buffer ); + $openssl_strong = false; + $openssl_bytes = openssl_random_pseudo_bytes( $rem, $openssl_strong ); + if ( $openssl_bytes === false ) { + $this->logger->debug( "openssl_random_pseudo_bytes returned false." ); + } else { + $buffer .= $openssl_bytes; + $this->logger->debug( "openssl_random_pseudo_bytes generated " . + strlen( $openssl_bytes ) . " bytes of " . + ( $openssl_strong ? "strong" : "weak" ) . " randomness." ); + } + if ( strlen( $buffer ) >= $bytes ) { + // openssl tells us if the random source was strong, if some of our data was generated + // using it use it's say on whether the randomness is strong + $this->strong = !!$openssl_strong; } } diff --git a/includes/libs/StringUtils.php b/includes/libs/StringUtils.php index 9638706dc2..7915ccf191 100644 --- a/includes/libs/StringUtils.php +++ b/includes/libs/StringUtils.php @@ -39,19 +39,7 @@ class StringUtils { * @return bool Whether the given $value is a valid UTF-8 encoded string */ static function isUtf8( $value ) { - $value = (string)$value; - - // HHVM 3.4 and older come with an outdated version of libmbfl that - // incorrectly allows values above U+10FFFF, so we have to check - // for them separately. (This issue also exists in PHP 5.3 and - // older, which are no longer supported.) - static $newPHP; - if ( $newPHP === null ) { - $newPHP = !mb_check_encoding( "\xf4\x90\x80\x80", 'UTF-8' ); - } - - return mb_check_encoding( $value, 'UTF-8' ) && - ( $newPHP || preg_match( "/\xf4[\x90-\xbf]|[\xf5-\xff]/S", $value ) === 0 ); + return mb_check_encoding( (string)$value, 'UTF-8' ); } /** diff --git a/includes/libs/filebackend/HTTPFileStreamer.php b/includes/libs/filebackend/HTTPFileStreamer.php index 9f8959cba0..46cd6befd9 100644 --- a/includes/libs/filebackend/HTTPFileStreamer.php +++ b/includes/libs/filebackend/HTTPFileStreamer.php @@ -64,7 +64,6 @@ class HTTPFileStreamer { * @param bool $sendErrors Send error messages if errors occur (like 404) * @param array $optHeaders HTTP request header map (e.g. "range") (use lowercase keys) * @param int $flags Bitfield of STREAM_* constants - * @throws MWException * @return bool Success */ public function stream( diff --git a/includes/libs/rdbms/ChronologyProtector.php b/includes/libs/rdbms/ChronologyProtector.php index e11528659d..90e697ec16 100644 --- a/includes/libs/rdbms/ChronologyProtector.php +++ b/includes/libs/rdbms/ChronologyProtector.php @@ -212,10 +212,10 @@ class ChronologyProtector implements LoggerAwareInterface { $store->unlock( $this->key ); } else { $ok = false; - $cpIndex = null; // nothing saved } if ( !$ok ) { + $cpIndex = null; // nothing saved $bouncedPositions = $this->shutdownPositions; // Raced out too many times or stash is down $this->logger->warning( __METHOD__ . ": failed to save master pos for " . @@ -269,14 +269,16 @@ class ChronologyProtector implements LoggerAwareInterface { // already be expired and thus treated as non-existing, maintaining correctness. if ( $this->waitForPosIndex > 0 ) { $data = null; + $indexReached = null; // highest index reached in the position store $loop = new WaitConditionLoop( - function () use ( &$data ) { + function () use ( &$data, &$indexReached ) { $data = $this->store->get( $this->key ); if ( !is_array( $data ) ) { return WaitConditionLoop::CONDITION_CONTINUE; // not found yet } elseif ( !isset( $data['writeIndex'] ) ) { return WaitConditionLoop::CONDITION_REACHED; // b/c } + $indexReached = max( $data['writeIndex'], $indexReached ); return ( $data['writeIndex'] >= $this->waitForPosIndex ) ? WaitConditionLoop::CONDITION_REACHED @@ -288,11 +290,22 @@ class ChronologyProtector implements LoggerAwareInterface { $waitedMs = $loop->getLastWaitTime() * 1e3; if ( $result == $loop::CONDITION_REACHED ) { - $msg = "expected and found pos index {$this->waitForPosIndex} ({$waitedMs}ms)"; - $this->logger->debug( $msg ); + $this->logger->debug( + __METHOD__ . ": expected and found position index.", + [ + 'cpPosIndex' => $this->waitForPosIndex, + 'waitTimeMs' => $waitedMs + ] + ); } else { - $msg = "expected but missed pos index {$this->waitForPosIndex} ({$waitedMs}ms)"; - $this->logger->info( $msg ); + $this->logger->warning( + __METHOD__ . ": expected but failed to find position index.", + [ + 'cpPosIndex' => $this->waitForPosIndex, + 'indexReached' => $indexReached, + 'waitTimeMs' => $waitedMs + ] + ); } } else { $data = $this->store->get( $this->key ); diff --git a/includes/libs/rdbms/database/AtomicSectionIdentifier.php b/includes/libs/rdbms/database/AtomicSectionIdentifier.php new file mode 100644 index 0000000000..c6e3d44c0d --- /dev/null +++ b/includes/libs/rdbms/database/AtomicSectionIdentifier.php @@ -0,0 +1,27 @@ +__call( __FUNCTION__, func_get_args() ); } + public function preCommitCallbacksPending() { + return $this->__call( __FUNCTION__, func_get_args() ); + } + public function writesOrCallbacksPending() { return $this->__call( __FUNCTION__, func_get_args() ); } @@ -483,6 +487,10 @@ class DBConnRef implements IDatabase { return $this->__call( __FUNCTION__, func_get_args() ); } + public function onTransactionCommitOrIdle( callable $callback, $fname = __METHOD__ ) { + return $this->__call( __FUNCTION__, func_get_args() ); + } + public function onTransactionIdle( callable $callback, $fname = __METHOD__ ) { return $this->__call( __FUNCTION__, func_get_args() ); } @@ -505,11 +513,13 @@ class DBConnRef implements IDatabase { return $this->__call( __FUNCTION__, func_get_args() ); } - public function cancelAtomic( $fname = __METHOD__ ) { + public function cancelAtomic( $fname = __METHOD__, AtomicSectionIdentifier $sectionId = null ) { return $this->__call( __FUNCTION__, func_get_args() ); } - public function doAtomicSection( $fname, callable $callback ) { + public function doAtomicSection( + $fname, callable $callback, $cancelable = self::ATOMIC_NOT_CANCELABLE + ) { return $this->__call( __FUNCTION__, func_get_args() ); } diff --git a/includes/libs/rdbms/database/Database.php b/includes/libs/rdbms/database/Database.php index 056f18959f..aeda5b9ce0 100644 --- a/includes/libs/rdbms/database/Database.php +++ b/includes/libs/rdbms/database/Database.php @@ -101,17 +101,19 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware protected $queryLogger; /** @var callback Error logging callback */ protected $errorLogger; + /** @var callback Deprecation logging callback */ + protected $deprecationLogger; /** @var resource|null Database connection */ protected $conn = null; /** @var bool */ protected $opened = false; - /** @var array[] List of (callable, method name) */ + /** @var array[] List of (callable, method name, atomic section id) */ protected $trxIdleCallbacks = []; - /** @var array[] List of (callable, method name) */ + /** @var array[] List of (callable, method name, atomic section id) */ protected $trxPreCommitCallbacks = []; - /** @var array[] List of (callable, method name) */ + /** @var array[] List of (callable, method name, atomic section id) */ protected $trxEndCallbacks = []; /** @var callable[] Map of (name => callable) */ protected $trxRecurringCallbacks = []; @@ -141,6 +143,19 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware /** @var integer|null Rows affected by the last query to query() or its CRUD wrappers */ protected $affectedRowCount; + /** + * @var int Transaction status + */ + protected $trxStatus = self::STATUS_TRX_NONE; + /** + * @var Exception|null The last error that caused the status to become STATUS_TRX_ERROR + */ + protected $trxStatusCause; + /** + * @var array|null If wasKnownStatementRollbackError() prevented trxStatus from being set, + * the relevant details are stored here. + */ + protected $trxStatusIgnoredCause; /** * Either 1 if a transaction is active or 0 otherwise. * The other Trx fields may not be meaningfull if this is 0. @@ -197,7 +212,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware /** * Array of levels of atomicity within transactions * - * @var array + * @var array List of (name, unique ID, savepoint ID) */ private $trxAtomicLevels = []; /** @@ -259,6 +274,18 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware /** @var int */ protected $nonNativeInsertSelectBatchSize = 10000; + /** @var string Idiom used when a cancelable atomic section started the transaction */ + private static $NOT_APPLICABLE = 'n/a'; + /** @var string Prefix to the atomic section counter used to make savepoint IDs */ + private static $SAVEPOINT_PREFIX = 'wikimedia_rdbms_atomic'; + + /** @var int Transaction is in a error state requiring a full or savepoint rollback */ + const STATUS_TRX_ERROR = 1; + /** @var int Transaction is active and in a normal state */ + const STATUS_TRX_OK = 2; + /** @var int No transaction is active */ + const STATUS_TRX_NONE = 3; + /** * @note: exceptions for missing libraries/drivers should be thrown in initConnection() * @param array $params Parameters passed from Database::factory() @@ -297,6 +324,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->connLogger = $params['connLogger']; $this->queryLogger = $params['queryLogger']; $this->errorLogger = $params['errorLogger']; + $this->deprecationLogger = $params['deprecationLogger']; if ( isset( $params['nonNativeInsertSelectBatchSize'] ) ) { $this->nonNativeInsertSelectBatchSize = $params['nonNativeInsertSelectBatchSize']; @@ -381,6 +409,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * includes the agent as a SQL comment. * - trxProfiler: Optional TransactionProfiler instance. * - errorLogger: Optional callback that takes an Exception and logs it. + * - deprecationLogger: Optional callback that takes a string and logs it. * - cliMode: Whether to consider the execution context that of a CLI script. * - agent: Optional name used to identify the end-user in query profiling/logging. * - srvCache: Optional BagOStuff instance to an APC-style cache. @@ -422,6 +451,11 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware trigger_error( get_class( $e ) . ': ' . $e->getMessage(), E_USER_WARNING ); }; } + if ( !isset( $p['deprecationLogger'] ) ) { + $p['deprecationLogger'] = function ( $msg ) { + trigger_error( $msg, E_USER_DEPRECATED ); + }; + } /** @var Database $conn */ $conn = new $class( $p ); @@ -548,6 +582,14 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware return $this->trxLevel ? $this->trxTimestamp : null; } + /** + * @return int One of the STATUS_TRX_* class constants + * @since 1.31 + */ + public function trxStatus() { + return $this->trxStatus; + } + public function tablePrefix( $prefix = null ) { $old = $this->tablePrefix; if ( $prefix !== null ) { @@ -635,6 +677,10 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware ); } + public function preCommitCallbacksPending() { + return $this->trxLevel && $this->trxPreCommitCallbacks; + } + /** * @return string|null */ @@ -680,17 +726,15 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } /** - * Get the list of method names that have pending write queries or callbacks - * for this transaction + * List the methods that have write queries or callbacks for the current transaction * - * @return array + * This method should not be used outside of Database/LoadBalancer + * + * @return string[] + * @since 1.32 */ - protected function pendingWriteAndCallbackCallers() { - if ( !$this->trxLevel ) { - return []; - } - - $fnames = $this->trxWriteCallers; + public function pendingWriteAndCallbackCallers() { + $fnames = $this->pendingWriteCallers(); foreach ( [ $this->trxIdleCallbacks, $this->trxPreCommitCallbacks, @@ -704,6 +748,15 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware return $fnames; } + /** + * @return string + */ + private function flatAtomicSectionList() { + return array_reduce( $this->trxAtomicLevels, function ( $accum, $v ) { + return $accum === null ? $v[0] : "$accum, " . $v[0]; + } ); + } + public function isOpen() { return $this->opened; } @@ -846,42 +899,76 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware ); } - public function close() { + final public function close() { + $exception = null; // error to throw after disconnecting + if ( $this->conn ) { // Resolve any dangling transaction first - if ( $this->trxLevel() ) { - // Meaningful transactions should ideally have been resolved by now - if ( $this->writesOrCallbacksPending() ) { + if ( $this->trxLevel ) { + if ( $this->trxAtomicLevels ) { + // Cannot let incomplete atomic sections be committed + $levels = $this->flatAtomicSectionList(); + $exception = new DBUnexpectedError( + $this, + __METHOD__ . ": atomic sections $levels are still open." + ); + } elseif ( $this->trxAutomatic ) { + // Only the connection manager can commit non-empty DBO_TRX transactions + if ( $this->writesOrCallbacksPending() ) { + $exception = new DBUnexpectedError( + $this, + __METHOD__ . + ": mass commit/rollback of peer transaction required (DBO_TRX set)." + ); + } + } elseif ( $this->trxLevel ) { + // Commit explicit transactions as if this was commit() $this->queryLogger->warning( __METHOD__ . ": writes or callbacks still pending.", [ 'trace' => ( new RuntimeException() )->getTraceAsString() ] ); } - // Check if it is possible to properly commit and trigger callbacks + if ( $this->trxEndCallbacksSuppressed ) { - throw new DBUnexpectedError( + $exception = $exception ?: new DBUnexpectedError( $this, __METHOD__ . ': callbacks are suppressed; cannot properly commit.' ); } - // Commit the changes and run any callbacks as needed - $this->commit( __METHOD__, self::FLUSHING_INTERNAL ); + + // Commit or rollback the changes and run any callbacks as needed + if ( $this->trxStatus === self::STATUS_TRX_OK && !$exception ) { + $this->commit( + __METHOD__, + $this->trxAutomatic ? self::FLUSHING_INTERNAL : self::FLUSHING_ONE + ); + } else { + $this->rollback( __METHOD__, self::FLUSHING_INTERNAL ); + } } + // Close the actual connection in the binding handle $closed = $this->closeConnection(); $this->conn = false; - // Sanity check that no callbacks are dangling - if ( - $this->trxIdleCallbacks || $this->trxPreCommitCallbacks || $this->trxEndCallbacks - ) { - throw new RuntimeException( "Transaction callbacks still pending." ); - } } else { $closed = true; // already closed; nothing to do } $this->opened = false; + // Throw any unexpected errors after having disconnected + if ( $exception instanceof Exception ) { + throw $exception; + } + + // Sanity check that no callbacks are dangling + $fnames = $this->pendingWriteAndCallbackCallers(); + if ( $fnames ) { + throw new RuntimeException( + "Transaction callbacks are still pending:\n" . implode( ', ', $fnames ) + ); + } + return $closed; } @@ -904,17 +991,13 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware abstract protected function closeConnection(); /** - * @param string $error Fallback error message, used if none is given by DB + * @deprecated since 1.32 + * @param string $error Fallback message, if none is given by DB * @throws DBConnectionError */ public function reportConnectionError( $error = 'Unknown error' ) { - $myError = $this->lastError(); - if ( $myError ) { - $error = $myError; - } - - # New method - throw new DBConnectionError( $this, $error ); + call_user_func( $this->deprecationLogger, 'Use of ' . __METHOD__ . ' is deprecated.' ); + throw new DBConnectionError( $this, $this->lastError() ?: $error ); } /** @@ -1005,6 +1088,11 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } public function query( $sql, $fname = __METHOD__, $tempIgnore = false ) { + $this->assertTransactionStatus( $sql, $fname ); + + # Avoid fatals if close() was called + $this->assertOpen(); + $priorWritesPending = $this->writesOrCallbacksPending(); $this->lastQuery = $sql; @@ -1055,9 +1143,6 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->queryLogger->debug( "{$this->dbName} {$commentedSql}" ); } - # Avoid fatals if close() was called - $this->assertOpen(); - # Send the query to the server and fetch any corresponding errors $ret = $this->doProfiledQuery( $sql, $commentedSql, $isNonTempWrite, $fname ); $lastError = $this->lastError(); @@ -1083,20 +1168,29 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } if ( $ret === false ) { - # Deadlocks cause the entire transaction to abort, not just the statement. - # https://dev.mysql.com/doc/refman/5.7/en/innodb-error-handling.html - # https://www.postgresql.org/docs/9.1/static/explicit-locking.html - if ( $this->wasDeadlock() ) { - if ( $this->explicitTrxActive() || $priorWritesPending ) { - $tempIgnore = false; // not recoverable + if ( $this->trxLevel ) { + if ( !$this->wasKnownStatementRollbackError() ) { + # Either the query was aborted or all queries after BEGIN where aborted. + if ( $this->explicitTrxActive() || $priorWritesPending ) { + # In the first case, the only options going forward are (a) ROLLBACK, or + # (b) ROLLBACK TO SAVEPOINT (if one was set). If the later case, the only + # option is ROLLBACK, since the snapshots would have been released. + $this->trxStatus = self::STATUS_TRX_ERROR; + $this->trxStatusCause = + $this->makeQueryException( $lastError, $lastErrno, $sql, $fname ); + $tempIgnore = false; // cannot recover + } else { + # Nothing prior was there to lose from the transaction, + # so just roll it back. + $this->rollback( __METHOD__ . " ($fname)", self::FLUSHING_INTERNAL ); + } + $this->trxStatusIgnoredCause = null; + } else { + # We're ignoring an error that caused just the current query to be aborted. + # But log the cause so we can log a deprecation notice if a + # caller actually does ignore it. + $this->trxStatusIgnoredCause = [ $lastError, $lastErrno, $fname ]; } - # Usually the transaction is rolled back to BEGIN, leaving an empty transaction. - # Destroy any such transaction so the rollback callbacks run in AUTO-COMMIT mode - # as normal. Also, if DBO_TRX is set and an explicit transaction rolled back here, - # further queries should be back in AUTO-COMMIT mode, not stuck in a transaction. - $this->doRollback( __METHOD__ ); - # Update state tracking to reflect transaction loss - $this->handleTransactionLoss(); } $this->reportQueryError( $lastError, $lastErrno, $sql, $fname, $tempIgnore ); @@ -1200,6 +1294,33 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } } + /** + * @param string $sql + * @param string $fname + * @throws DBTransactionStateError + */ + private function assertTransactionStatus( $sql, $fname ) { + if ( $this->getQueryVerb( $sql ) === 'ROLLBACK' ) { // transaction/savepoint + return; + } + + if ( $this->trxStatus < self::STATUS_TRX_OK ) { + throw new DBTransactionStateError( + $this, + "Cannot execute query from $fname while transaction status is ERROR.", + [], + $this->trxStatusCause + ); + } elseif ( $this->trxStatus === self::STATUS_TRX_OK && $this->trxStatusIgnoredCause ) { + list( $iLastError, $iLastErrno, $iFname ) = $this->trxStatusIgnoredCause; + call_user_func( $this->deprecationLogger, + "Caller from $fname ignored an error originally raised from $iFname: " . + "[$iLastErrno] $iLastError" + ); + $this->trxStatusIgnoredCause = null; + } + } + /** * Determine whether or not it is safe to retry queries after a database * connection is lost @@ -1224,7 +1345,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } elseif ( $sql === 'ROLLBACK' ) { return true; // transaction lost...which is also what was requested :) } elseif ( $this->explicitTrxActive() ) { - return false; // don't drop atomocity + return false; // don't drop atomocity and explicit snapshots } elseif ( $priorWritesPending ) { return false; // prior writes lost from implicit transaction } @@ -1238,7 +1359,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware private function handleSessionLoss() { // Clean up tracking of session-level things... // https://dev.mysql.com/doc/refman/5.7/en/implicit-commit.html - // https://www.postgresql.org/docs/9.1/static/sql-createtable.html (ignoring ON COMMIT) + // https://www.postgresql.org/docs/9.2/static/sql-createtable.html (ignoring ON COMMIT) $this->sessionTempTables = []; // https://dev.mysql.com/doc/refman/5.7/en/miscellaneous-functions.html#function_get-lock // https://www.postgresql.org/docs/9.4/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS @@ -1299,25 +1420,40 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware if ( $tempIgnore ) { $this->queryLogger->debug( "SQL ERROR (ignored): $error\n" ); } else { - $sql1line = mb_substr( str_replace( "\n", "\\n", $sql ), 0, 5 * 1024 ); - $this->queryLogger->error( - "{fname}\t{db_server}\t{errno}\t{error}\t{sql1line}", - $this->getLogContext( [ - 'method' => __METHOD__, - 'errno' => $errno, - 'error' => $error, - 'sql1line' => $sql1line, - 'fname' => $fname, - ] ) - ); - $this->queryLogger->debug( "SQL ERROR: " . $error . "\n" ); - $wasQueryTimeout = $this->wasQueryTimeout( $error, $errno ); - if ( $wasQueryTimeout ) { - throw new DBQueryTimeoutError( $this, $error, $errno, $sql, $fname ); - } else { - throw new DBQueryError( $this, $error, $errno, $sql, $fname ); - } + $exception = $this->makeQueryException( $error, $errno, $sql, $fname ); + + throw $exception; + } + } + + /** + * @param string $error + * @param string|int $errno + * @param string $sql + * @param string $fname + * @return DBError + */ + private function makeQueryException( $error, $errno, $sql, $fname ) { + $sql1line = mb_substr( str_replace( "\n", "\\n", $sql ), 0, 5 * 1024 ); + $this->queryLogger->error( + "{fname}\t{db_server}\t{errno}\t{error}\t{sql1line}", + $this->getLogContext( [ + 'method' => __METHOD__, + 'errno' => $errno, + 'error' => $error, + 'sql1line' => $sql1line, + 'fname' => $fname, + ] ) + ); + $this->queryLogger->debug( "SQL ERROR: " . $error . "\n" ); + $wasQueryTimeout = $this->wasQueryTimeout( $error, $errno ); + if ( $wasQueryTimeout ) { + $e = new DBQueryTimeoutError( $this, $error, $errno, $sql, $fname ); + } else { + $e = new DBQueryError( $this, $error, $errno, $sql, $fname ); } + + return $e; } public function freeResult( $res ) { @@ -1506,8 +1642,9 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware return ''; } - public function select( $table, $vars, $conds = '', $fname = __METHOD__, - $options = [], $join_conds = [] ) { + public function select( + $table, $vars, $conds = '', $fname = __METHOD__, $options = [], $join_conds = [] + ) { $sql = $this->selectSQLText( $table, $vars, $conds, $fname, $options, $join_conds ); return $this->query( $sql, $fname ); @@ -1517,7 +1654,9 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $options = [], $join_conds = [] ) { if ( is_array( $vars ) ) { - $vars = implode( ',', $this->fieldNamesWithAlias( $vars ) ); + $fields = implode( ',', $this->fieldNamesWithAlias( $vars ) ); + } else { + $fields = $vars; } $options = (array)$options; @@ -1531,6 +1670,18 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware ? $options['IGNORE INDEX'] : []; + if ( + $this->selectOptionsIncludeLocking( $options ) && + $this->selectFieldsOrOptionsAggregate( $vars, $options ) + ) { + // Some DB types (postgres/oracle) disallow FOR UPDATE with aggregate + // functions. Discourage use of such queries to encourage compatibility. + call_user_func( + $this->deprecationLogger, + __METHOD__ . ": aggregation used with a locking SELECT ($fname)." + ); + } + if ( is_array( $table ) ) { $from = ' FROM ' . $this->tableNamesWithIndexClauseOrJOIN( @@ -1561,9 +1712,9 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } if ( $conds === '' ) { - $sql = "SELECT $startOpts $vars $from $useIndex $ignoreIndex $preLimitTail"; + $sql = "SELECT $startOpts $fields $from $useIndex $ignoreIndex $preLimitTail"; } elseif ( is_string( $conds ) ) { - $sql = "SELECT $startOpts $vars $from $useIndex $ignoreIndex " . + $sql = "SELECT $startOpts $fields $from $useIndex $ignoreIndex " . "WHERE $conds $preLimitTail"; } else { throw new DBUnexpectedError( $this, __METHOD__ . ' called with incorrect parameters' ); @@ -1648,6 +1799,49 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware return isset( $row['rowcount'] ) ? (int)$row['rowcount'] : 0; } + /** + * @param string|array $options + * @return bool + */ + private function selectOptionsIncludeLocking( $options ) { + $options = (array)$options; + foreach ( [ 'FOR UPDATE', 'LOCK IN SHARE MODE' ] as $lock ) { + if ( in_array( $lock, $options, true ) ) { + return true; + } + } + + return false; + } + + /** + * @param array|string $fields + * @param array|string $options + * @return bool + */ + private function selectFieldsOrOptionsAggregate( $fields, $options ) { + foreach ( (array)$options as $key => $value ) { + if ( is_string( $key ) ) { + if ( preg_match( '/^(?:GROUP BY|HAVING)$/i', $key ) ) { + return true; + } + } elseif ( is_string( $value ) ) { + if ( preg_match( '/^(?:DISTINCT|DISTINCTROW)$/i', $value ) ) { + return true; + } + } + } + + $regex = '/^(?:COUNT|MIN|MAX|SUM|GROUP_CONCAT|LISTAGG|ARRAY_AGG)\s*\\(/i'; + foreach ( (array)$fields as $field ) { + if ( is_string( $field ) && preg_match( $regex, $field ) ) { + return true; + } + } + + return false; + } + /** * @param array|string $conds * @param string $fname @@ -3026,6 +3220,16 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware return false; } + /** + * @return bool Whether it is safe to assume the given error only caused statement rollback + * @note This is for backwards compatibility for callers catching DBError exceptions in + * order to ignore problems like duplicate key errors or foriegn key violations + * @since 1.31 + */ + protected function wasKnownStatementRollbackError() { + return false; // don't know; it could have caused a transaction rollback + } + public function deadlockLoop() { $args = func_get_args(); $function = array_shift( $args ); @@ -3085,22 +3289,26 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware if ( !$this->trxLevel ) { throw new DBUnexpectedError( $this, "No transaction is active." ); } - $this->trxEndCallbacks[] = [ $callback, $fname ]; + $this->trxEndCallbacks[] = [ $callback, $fname, $this->currentAtomicSectionId() ]; } - final public function onTransactionIdle( callable $callback, $fname = __METHOD__ ) { + final public function onTransactionCommitOrIdle( callable $callback, $fname = __METHOD__ ) { if ( !$this->trxLevel && $this->getTransactionRoundId() ) { // Start an implicit transaction similar to how query() does $this->begin( __METHOD__, self::TRANSACTION_INTERNAL ); $this->trxAutomatic = true; } - $this->trxIdleCallbacks[] = [ $callback, $fname ]; + $this->trxIdleCallbacks[] = [ $callback, $fname, $this->currentAtomicSectionId() ]; if ( !$this->trxLevel ) { $this->runOnTransactionIdleCallbacks( self::TRIGGER_IDLE ); } } + final public function onTransactionIdle( callable $callback, $fname = __METHOD__ ) { + $this->onTransactionCommitOrIdle( $callback, $fname ); + } + final public function onTransactionPreCommitOrIdle( callable $callback, $fname = __METHOD__ ) { if ( !$this->trxLevel && $this->getTransactionRoundId() ) { // Start an implicit transaction similar to how query() does @@ -3109,12 +3317,12 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } if ( $this->trxLevel ) { - $this->trxPreCommitCallbacks[] = [ $callback, $fname ]; + $this->trxPreCommitCallbacks[] = [ $callback, $fname, $this->currentAtomicSectionId() ]; } else { // No transaction is active nor will start implicitly, so make one for this callback $this->startAtomic( __METHOD__, self::ATOMIC_CANCELABLE ); try { - call_user_func( $callback ); + call_user_func( $callback, $this ); $this->endAtomic( __METHOD__ ); } catch ( Exception $e ) { $this->cancelAtomic( __METHOD__ ); @@ -3123,6 +3331,72 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } } + /** + * @return AtomicSectionIdentifier|null ID of the topmost atomic section level + */ + private function currentAtomicSectionId() { + if ( $this->trxLevel && $this->trxAtomicLevels ) { + $levelInfo = end( $this->trxAtomicLevels ); + + return $levelInfo[1]; + } + + return null; + } + + /** + * @param AtomicSectionIdentifier $old + * @param AtomicSectionIdentifier $new + */ + private function reassignCallbacksForSection( + AtomicSectionIdentifier $old, AtomicSectionIdentifier $new + ) { + foreach ( $this->trxPreCommitCallbacks as $key => $info ) { + if ( $info[2] === $old ) { + $this->trxPreCommitCallbacks[$key][2] = $new; + } + } + foreach ( $this->trxIdleCallbacks as $key => $info ) { + if ( $info[2] === $old ) { + $this->trxIdleCallbacks[$key][2] = $new; + } + } + foreach ( $this->trxEndCallbacks as $key => $info ) { + if ( $info[2] === $old ) { + $this->trxEndCallbacks[$key][2] = $new; + } + } + } + + /** + * @param AtomicSectionIdentifier[] $sectionIds ID of an actual savepoint + * @throws UnexpectedValueException + */ + private function modifyCallbacksForCancel( array $sectionIds ) { + // Cancel the "on commit" callbacks owned by this savepoint + $this->trxIdleCallbacks = array_filter( + $this->trxIdleCallbacks, + function ( $entry ) use ( $sectionIds ) { + return !in_array( $entry[2], $sectionIds, true ); + } + ); + $this->trxPreCommitCallbacks = array_filter( + $this->trxPreCommitCallbacks, + function ( $entry ) use ( $sectionIds ) { + return !in_array( $entry[2], $sectionIds, true ); + } + ); + // Make "on resolution" callbacks owned by this savepoint to perceive a rollback + foreach ( $this->trxEndCallbacks as $key => $entry ) { + if ( in_array( $entry[2], $sectionIds, true ) ) { + $callback = $entry[0]; + $this->trxEndCallbacks[$key][0] = function () use ( $callback ) { + return $callback( self::TRIGGER_ROLLBACK, $this ); + }; + } + } + } + final public function setTransactionListener( $name, callable $callback = null ) { if ( $callback ) { $this->trxRecurringCallbacks[$name] = $callback; @@ -3144,19 +3418,25 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } /** - * Actually run and consume any "on transaction idle/resolution" callbacks. + * Actually consume and run any "on transaction idle/resolution" callbacks. * * This method should not be used outside of Database/LoadBalancer * * @param int $trigger IDatabase::TRIGGER_* constant + * @return int Number of callbacks attempted * @since 1.20 * @throws Exception */ public function runOnTransactionIdleCallbacks( $trigger ) { + if ( $this->trxLevel ) { // sanity + throw new DBUnexpectedError( $this, __METHOD__ . ': a transaction is still open.' ); + } + if ( $this->trxEndCallbacksSuppressed ) { - return; + return 0; } + $count = 0; $autoTrx = $this->getFlag( self::DBO_TRX ); // automatic begin() enabled? /** @var Exception $e */ $e = null; // first exception @@ -3169,9 +3449,10 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->trxEndCallbacks = []; // consumed (recursion guard) foreach ( $callbacks as $callback ) { try { + ++$count; list( $phpCallback ) = $callback; $this->clearFlag( self::DBO_TRX ); // make each query its own transaction - call_user_func_array( $phpCallback, [ $trigger ] ); + call_user_func( $phpCallback, $trigger, $this ); if ( $autoTrx ) { $this->setFlag( self::DBO_TRX ); // restore automatic begin() } else { @@ -3192,25 +3473,31 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware if ( $e instanceof Exception ) { throw $e; // re-throw any first exception } + + return $count; } /** - * Actually run and consume any "on transaction pre-commit" callbacks. + * Actually consume and run any "on transaction pre-commit" callbacks. * * This method should not be used outside of Database/LoadBalancer * * @since 1.22 + * @return int Number of callbacks attempted * @throws Exception */ public function runOnTransactionPreCommitCallbacks() { + $count = 0; + $e = null; // first exception do { // callbacks may add callbacks :) $callbacks = $this->trxPreCommitCallbacks; $this->trxPreCommitCallbacks = []; // consumed (and recursion guard) foreach ( $callbacks as $callback ) { try { + ++$count; list( $phpCallback ) = $callback; - call_user_func( $phpCallback ); + call_user_func( $phpCallback, $this ); } catch ( Exception $ex ) { call_user_func( $this->errorLogger, $ex ); $e = $e ?: $ex; @@ -3221,6 +3508,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware if ( $e instanceof Exception ) { throw $e; // re-throw any first exception } + + return $count; } /** @@ -3296,81 +3585,159 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->query( 'ROLLBACK TO SAVEPOINT ' . $this->addIdentifierQuotes( $identifier ), $fname ); } + /** + * @param string $fname + * @return string + */ + private function nextSavepointId( $fname ) { + $savepointId = self::$SAVEPOINT_PREFIX . ++$this->trxAtomicCounter; + if ( strlen( $savepointId ) > 30 ) { + // 30 == Oracle's identifier length limit (pre 12c) + // With a 22 character prefix, that puts the highest number at 99999999. + throw new DBUnexpectedError( + $this, + 'There have been an excessively large number of atomic sections in a transaction' + . " started by $this->trxFname (at $fname)" + ); + } + + return $savepointId; + } + final public function startAtomic( $fname = __METHOD__, $cancelable = self::ATOMIC_NOT_CANCELABLE ) { - $savepointId = $cancelable === self::ATOMIC_CANCELABLE ? 'n/a' : null; + $savepointId = $cancelable === self::ATOMIC_CANCELABLE ? self::$NOT_APPLICABLE : null; + if ( !$this->trxLevel ) { - $this->begin( $fname, self::TRANSACTION_INTERNAL ); + $this->begin( $fname, self::TRANSACTION_INTERNAL ); // sets trxAutomatic // If DBO_TRX is set, a series of startAtomic/endAtomic pairs will result // in all changes being in one transaction to keep requests transactional. - if ( !$this->getFlag( self::DBO_TRX ) ) { + if ( $this->getFlag( self::DBO_TRX ) ) { + // Since writes could happen in between the topmost atomic sections as part + // of the transaction, those sections will need savepoints. + $savepointId = $this->nextSavepointId( $fname ); + $this->doSavepoint( $savepointId, $fname ); + } else { $this->trxAutomaticAtomic = true; } } elseif ( $cancelable === self::ATOMIC_CANCELABLE ) { - $savepointId = 'wikimedia_rdbms_atomic' . ++$this->trxAtomicCounter; - if ( strlen( $savepointId ) > 30 ) { // 30 == Oracle's identifier length limit (pre 12c) - $this->queryLogger->warning( - 'There have been an excessively large number of atomic sections in a transaction' - . " started by $this->trxFname, reusing IDs (at $fname)", - [ 'trace' => ( new RuntimeException() )->getTraceAsString() ] - ); - $this->trxAtomicCounter = 0; - $savepointId = 'wikimedia_rdbms_atomic' . ++$this->trxAtomicCounter; - } + $savepointId = $this->nextSavepointId( $fname ); $this->doSavepoint( $savepointId, $fname ); } - $this->trxAtomicLevels[] = [ $fname, $savepointId ]; + $sectionId = new AtomicSectionIdentifier; + $this->trxAtomicLevels[] = [ $fname, $sectionId, $savepointId ]; + + return $sectionId; } final public function endAtomic( $fname = __METHOD__ ) { - if ( !$this->trxLevel ) { - throw new DBUnexpectedError( $this, "No atomic transaction is open (got $fname)." ); + if ( !$this->trxLevel || !$this->trxAtomicLevels ) { + throw new DBUnexpectedError( $this, "No atomic section is open (got $fname)." ); } - list( $savedFname, $savepointId ) = $this->trxAtomicLevels - ? array_pop( $this->trxAtomicLevels ) : [ null, null ]; + // Check if the current section matches $fname + $pos = count( $this->trxAtomicLevels ) - 1; + list( $savedFname, $sectionId, $savepointId ) = $this->trxAtomicLevels[$pos]; + if ( $savedFname !== $fname ) { - throw new DBUnexpectedError( $this, "Invalid atomic section ended (got $fname)." ); + throw new DBUnexpectedError( + $this, + "Invalid atomic section ended (got $fname but expected $savedFname)." + ); } + // Remove the last section (no need to re-index the array) + array_pop( $this->trxAtomicLevels ); + if ( !$this->trxAtomicLevels && $this->trxAutomaticAtomic ) { $this->commit( $fname, self::FLUSHING_INTERNAL ); - } elseif ( $savepointId && $savepointId !== 'n/a' ) { + } elseif ( $savepointId !== null && $savepointId !== self::$NOT_APPLICABLE ) { $this->doReleaseSavepoint( $savepointId, $fname ); } + + // Hoist callback ownership for callbacks in the section that just ended; + // all callbacks should have an owner that is present in trxAtomicLevels. + $currentSectionId = $this->currentAtomicSectionId(); + if ( $currentSectionId ) { + $this->reassignCallbacksForSection( $sectionId, $currentSectionId ); + } } - final public function cancelAtomic( $fname = __METHOD__ ) { - if ( !$this->trxLevel ) { - throw new DBUnexpectedError( $this, "No atomic transaction is open (got $fname)." ); + final public function cancelAtomic( + $fname = __METHOD__, AtomicSectionIdentifier $sectionId = null + ) { + if ( !$this->trxLevel || !$this->trxAtomicLevels ) { + throw new DBUnexpectedError( $this, "No atomic section is open (got $fname)." ); } - list( $savedFname, $savepointId ) = $this->trxAtomicLevels - ? array_pop( $this->trxAtomicLevels ) : [ null, null ]; - if ( $savedFname !== $fname ) { - throw new DBUnexpectedError( $this, "Invalid atomic section ended (got $fname)." ); + if ( $sectionId !== null ) { + // Find the (last) section with the given $sectionId + $pos = -1; + foreach ( $this->trxAtomicLevels as $i => list( $asFname, $asId, $spId ) ) { + if ( $asId === $sectionId ) { + $pos = $i; + } + } + if ( $pos < 0 ) { + throw new DBUnexpectedError( "Atomic section not found (for $fname)" ); + } + // Remove all descendant sections and re-index the array + $excisedIds = []; + $len = count( $this->trxAtomicLevels ); + for ( $i = $pos + 1; $i < $len; ++$i ) { + $excisedIds[] = $this->trxAtomicLevels[$i][1]; + } + $this->trxAtomicLevels = array_slice( $this->trxAtomicLevels, 0, $pos + 1 ); + $this->modifyCallbacksForCancel( $excisedIds ); } - if ( !$savepointId ) { - throw new DBUnexpectedError( $this, "Uncancelable atomic section canceled (got $fname)." ); + + // Check if the current section matches $fname + $pos = count( $this->trxAtomicLevels ) - 1; + list( $savedFname, $savedSectionId, $savepointId ) = $this->trxAtomicLevels[$pos]; + + if ( $savedFname !== $fname ) { + throw new DBUnexpectedError( + $this, + "Invalid atomic section ended (got $fname but expected $savedFname)." + ); } - if ( !$this->trxAtomicLevels && $this->trxAutomaticAtomic ) { - $this->rollback( $fname, self::FLUSHING_INTERNAL ); - } elseif ( $savepointId !== 'n/a' ) { - $this->doRollbackToSavepoint( $savepointId, $fname ); + // Remove the last section (no need to re-index the array) + array_pop( $this->trxAtomicLevels ); + $this->modifyCallbacksForCancel( [ $savedSectionId ] ); + + if ( $savepointId !== null ) { + // Rollback the transaction to the state just before this atomic section + if ( $savepointId === self::$NOT_APPLICABLE ) { + $this->rollback( $fname, self::FLUSHING_INTERNAL ); + } else { + $this->doRollbackToSavepoint( $savepointId, $fname ); + $this->trxStatus = self::STATUS_TRX_OK; // no exception; recovered + $this->trxStatusIgnoredCause = null; + } + } elseif ( $this->trxStatus > self::STATUS_TRX_ERROR ) { + // Put the transaction into an error state if it's not already in one + $this->trxStatus = self::STATUS_TRX_ERROR; + $this->trxStatusCause = new DBUnexpectedError( + $this, + "Uncancelable atomic section canceled (got $fname)." + ); } $this->affectedRowCount = 0; // for the sake of consistency } - final public function doAtomicSection( $fname, callable $callback ) { - $this->startAtomic( $fname, self::ATOMIC_CANCELABLE ); + final public function doAtomicSection( + $fname, callable $callback, $cancelable = self::ATOMIC_NOT_CANCELABLE + ) { + $sectionId = $this->startAtomic( $fname, $cancelable ); try { $res = call_user_func_array( $callback, [ $this, $fname ] ); } catch ( Exception $e ) { - $this->cancelAtomic( $fname ); + $this->cancelAtomic( $fname, $sectionId ); + throw $e; } $this->endAtomic( $fname ); @@ -3379,12 +3746,15 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } final public function begin( $fname = __METHOD__, $mode = self::TRANSACTION_EXPLICIT ) { + static $modes = [ self::TRANSACTION_EXPLICIT, self::TRANSACTION_INTERNAL ]; + if ( !in_array( $mode, $modes, true ) ) { + throw new DBUnexpectedError( $this, "$fname: invalid mode parameter '$mode'." ); + } + // Protect against mismatched atomic section, transaction nesting, and snapshot loss if ( $this->trxLevel ) { if ( $this->trxAtomicLevels ) { - $levels = array_reduce( $this->trxAtomicLevels, function ( $accum, $v ) { - return $accum === null ? $v[0] : "$accum, " . $v[0]; - } ); + $levels = $this->flatAtomicSectionList(); $msg = "$fname: Got explicit BEGIN while atomic section(s) $levels are open."; throw new DBUnexpectedError( $this, $msg ); } elseif ( !$this->trxAutomatic ) { @@ -3403,6 +3773,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->assertOpen(); $this->doBegin( $fname ); + $this->trxStatus = self::STATUS_TRX_OK; + $this->trxStatusIgnoredCause = null; $this->trxAtomicCounter = 0; $this->trxTimestamp = microtime( true ); $this->trxFname = $fname; @@ -3418,6 +3790,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->trxWriteCallers = []; // First SELECT after BEGIN will establish the snapshot in REPEATABLE-READ. // Get an estimate of the replication lag before any such queries. + $this->trxReplicaLag = null; // clear cached value first $this->trxReplicaLag = $this->getApproximateLagStatus()['lag']; // T147697: make explicitTrxActive() return true until begin() finishes. This way, no // caller will think its OK to muck around with the transaction just because startAtomic() @@ -3436,12 +3809,15 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->trxLevel = 1; } - final public function commit( $fname = __METHOD__, $flush = '' ) { + final public function commit( $fname = __METHOD__, $flush = self::FLUSHING_ONE ) { + static $modes = [ self::FLUSHING_ONE, self::FLUSHING_ALL_PEERS, self::FLUSHING_INTERNAL ]; + if ( !in_array( $flush, $modes, true ) ) { + throw new DBUnexpectedError( $this, "$fname: invalid flush parameter '$flush'." ); + } + if ( $this->trxLevel && $this->trxAtomicLevels ) { - // There are still atomic sections open. This cannot be ignored - $levels = array_reduce( $this->trxAtomicLevels, function ( $accum, $v ) { - return $accum === null ? $v[0] : "$accum, " . $v[0]; - } ); + // There are still atomic sections open; this cannot be ignored + $levels = $this->flatAtomicSectionList(); throw new DBUnexpectedError( $this, "$fname: Got COMMIT while atomic sections $levels are still open." @@ -3476,6 +3852,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->runOnTransactionPreCommitCallbacks(); $writeTime = $this->pendingWriteQueryDuration( self::ESTIMATE_DB_APPLY ); $this->doCommit( $fname ); + $this->trxStatus = self::STATUS_TRX_NONE; if ( $this->trxDoneWrites ) { $this->lastWriteTime = microtime( true ); $this->trxProfiler->transactionWritingOut( @@ -3487,8 +3864,11 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware ); } - $this->runOnTransactionIdleCallbacks( self::TRIGGER_COMMIT ); - $this->runTransactionListenerCallbacks( self::TRIGGER_COMMIT ); + // With FLUSHING_ALL_PEERS, callbacks will be explicitly run later + if ( $flush !== self::FLUSHING_ALL_PEERS ) { + $this->runOnTransactionIdleCallbacks( self::TRIGGER_COMMIT ); + $this->runTransactionListenerCallbacks( self::TRIGGER_COMMIT ); + } } /** @@ -3521,6 +3901,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->assertOpen(); $this->doRollback( $fname ); + $this->trxStatus = self::STATUS_TRX_NONE; $this->trxAtomicLevels = []; if ( $this->trxDoneWrites ) { $this->trxProfiler->transactionWritingOut( @@ -3536,7 +3917,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware $this->trxIdleCallbacks = []; $this->trxPreCommitCallbacks = []; - if ( $trxActive ) { + // With FLUSHING_ALL_PEERS, callbacks will be explicitly run later + if ( $trxActive && $flush !== self::FLUSHING_ALL_PEERS ) { try { $this->runOnTransactionIdleCallbacks( self::TRIGGER_ROLLBACK ); } catch ( Exception $e ) { @@ -3707,7 +4089,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware } public function getSessionLagStatus() { - return $this->getTransactionLagStatus() ?: $this->getApproximateLagStatus(); + return $this->getRecordedTransactionLagStatus() ?: $this->getApproximateLagStatus(); } /** @@ -3718,11 +4100,13 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware * is this lag plus transaction duration. If they don't, it is still * safe to be pessimistic. This returns null if there is no transaction. * + * This returns null if the lag status for this transaction was not yet recorded. + * * @return array|null ('lag': seconds or false on error, 'since': UNIX timestamp of BEGIN) * @since 1.27 */ - final protected function getTransactionLagStatus() { - return $this->trxLevel + final protected function getRecordedTransactionLagStatus() { + return ( $this->trxLevel && $this->trxReplicaLag !== null ) ? [ 'lag' => $this->trxReplicaLag, 'since' => $this->trxTimestamp() ] : null; } diff --git a/includes/libs/rdbms/database/DatabaseMssql.php b/includes/libs/rdbms/database/DatabaseMssql.php index 773e548d87..4c187f2357 100644 --- a/includes/libs/rdbms/database/DatabaseMssql.php +++ b/includes/libs/rdbms/database/DatabaseMssql.php @@ -359,6 +359,28 @@ class DatabaseMssql extends Database { } } + protected function wasKnownStatementRollbackError() { + $errors = sqlsrv_errors( SQLSRV_ERR_ALL ); + if ( !$errors ) { + return false; + } + // The transaction vs statement rollback behavior depends on XACT_ABORT, so make sure + // that the "statement has been terminated" error (3621) is specifically present. + // https://docs.microsoft.com/en-us/sql/t-sql/statements/set-xact-abort-transact-sql + $statementOnly = false; + $codeWhitelist = [ '2601', '2627', '547' ]; + foreach ( $errors as $error ) { + if ( $error['code'] == '3621' ) { + $statementOnly = true; + } elseif ( !in_array( $error['code'], $codeWhitelist ) ) { + $statementOnly = false; + break; + } + } + + return $statementOnly; + } + /** * @return int */ diff --git a/includes/libs/rdbms/database/DatabaseMysqlBase.php b/includes/libs/rdbms/database/DatabaseMysqlBase.php index 58bc5ac8e3..047213908e 100644 --- a/includes/libs/rdbms/database/DatabaseMysqlBase.php +++ b/includes/libs/rdbms/database/DatabaseMysqlBase.php @@ -152,9 +152,7 @@ abstract class DatabaseMysqlBase extends Database { # Always log connection errors if ( !$this->conn ) { - if ( !$error ) { - $error = $this->lastError(); - } + $error = $error ?: $this->lastError(); $this->connLogger->error( "Error connecting to {db_server}: {error}", $this->getLogContext( [ @@ -166,7 +164,7 @@ abstract class DatabaseMysqlBase extends Database { "Server: $server, User: $user, Password: " . substr( $password, 0, 3 ) . "..., error: " . $error . "\n" ); - $this->reportConnectionError( $error ); + throw new DBConnectionError( $this, $error ); } if ( strlen( $dbName ) ) { @@ -174,22 +172,29 @@ abstract class DatabaseMysqlBase extends Database { $success = $this->selectDB( $dbName ); Wikimedia\restoreWarnings(); if ( !$success ) { + $error = $this->lastError(); $this->queryLogger->error( - "Error selecting database {db_name} on server {db_server}", + "Error selecting database {db_name} on server {db_server}: {error}", $this->getLogContext( [ 'method' => __METHOD__, + 'error' => $error, ] ) ); - $this->queryLogger->debug( - "Error selecting database $dbName on server {$this->server}" ); - - $this->reportConnectionError( "Error selecting database $dbName" ); + throw new DBConnectionError( $this, "Error selecting database $dbName: $error" ); } } // Tell the server what we're communicating with if ( !$this->connectInitCharset() ) { - $this->reportConnectionError( "Error setting character set" ); + $error = $this->lastError(); + $this->queryLogger->error( + "Error setting character set: {error}", + $this->getLogContext( [ + 'method' => __METHOD__, + 'error' => $this->lastError(), + ] ) + ); + throw new DBConnectionError( $this, "Error setting character set: $error" ); } // Abstract over any insane MySQL defaults @@ -212,14 +217,15 @@ abstract class DatabaseMysqlBase extends Database { // Use doQuery() to avoid opening implicit transactions (DBO_TRX) $success = $this->doQuery( 'SET ' . implode( ', ', $set ) ); if ( !$success ) { + $error = $this->lastError(); $this->queryLogger->error( - 'Error setting MySQL variables on server {db_server} (check $wgSQLMode)', + 'Error setting MySQL variables on server {db_server}: {error}', $this->getLogContext( [ 'method' => __METHOD__, + 'error' => $error, ] ) ); - $this->reportConnectionError( - 'Error setting MySQL variables on server {db_server} (check $wgSQLMode)' ); + throw new DBConnectionError( $this, "Error setting MySQL variables: $error" ); } } @@ -766,18 +772,20 @@ abstract class DatabaseMysqlBase extends Database { protected function getLagFromPtHeartbeat() { $options = $this->lagDetectionOptions; - $staleness = $this->trxLevel - ? microtime( true ) - $this->trxTimestamp() - : 0; - if ( $staleness > self::LAG_STALE_WARN_THRESHOLD ) { - // Avoid returning higher and higher lag value due to snapshot age - // given that the isolation level will typically be REPEATABLE-READ - $this->queryLogger->warning( - "Using cached lag value for {db_server} due to active transaction", - $this->getLogContext( [ 'method' => __METHOD__ ] ) - ); + $currentTrxInfo = $this->getRecordedTransactionLagStatus(); + if ( $currentTrxInfo ) { + // There is an active transaction and the initial lag was already queried + $staleness = microtime( true ) - $currentTrxInfo['since']; + if ( $staleness > self::LAG_STALE_WARN_THRESHOLD ) { + // Avoid returning higher and higher lag value due to snapshot age + // given that the isolation level will typically be REPEATABLE-READ + $this->queryLogger->warning( + "Using cached lag value for {db_server} due to active transaction", + $this->getLogContext( [ 'method' => __METHOD__, 'age' => $staleness ] ) + ); + } - return $this->getTransactionLagStatus()['lag']; + return $currentTrxInfo['lag']; } if ( isset( $options['conds'] ) ) { @@ -931,18 +939,23 @@ abstract class DatabaseMysqlBase extends Database { } // Wait on the GTID set (MariaDB only) $gtidArg = $this->addQuotes( implode( ',', $gtidsWait ) ); - $res = $this->doQuery( "SELECT MASTER_GTID_WAIT($gtidArg, $timeout)" ); + if ( strpos( $gtidArg, ':' ) !== false ) { + // MySQL GTIDs, e.g "source_id:transaction_id" + $res = $this->doQuery( "SELECT WAIT_FOR_EXECUTED_GTID_SET($gtidArg, $timeout)" ); + } else { + // MariaDB GTIDs, e.g."domain:server:sequence" + $res = $this->doQuery( "SELECT MASTER_GTID_WAIT($gtidArg, $timeout)" ); + } } else { // Wait on the binlog coordinates $encFile = $this->addQuotes( $pos->getLogFile() ); - $encPos = intval( $pos->pos[1] ); + $encPos = intval( $pos->getLogPosition()[$pos::CORD_EVENT] ); $res = $this->doQuery( "SELECT MASTER_POS_WAIT($encFile, $encPos, $timeout)" ); } $row = $res ? $this->fetchRow( $res ) : false; if ( !$row ) { - throw new DBExpectedError( $this, - "MASTER_POS_WAIT() or MASTER_GTID_WAIT() failed: {$this->lastError()}" ); + throw new DBExpectedError( $this, "Replication wait failed: {$this->lastError()}" ); } // Result can be NULL (error), -1 (timeout), or 0+ per the MySQL manual @@ -974,21 +987,23 @@ abstract class DatabaseMysqlBase extends Database { * @return MySQLMasterPos|bool */ public function getReplicaPos() { - $now = microtime( true ); - - if ( $this->useGTIDs ) { - $res = $this->query( "SELECT @@global.gtid_slave_pos AS Value", __METHOD__ ); - $gtidRow = $this->fetchObject( $res ); - if ( $gtidRow && strlen( $gtidRow->Value ) ) { - return new MySQLMasterPos( $gtidRow->Value, $now ); + $now = microtime( true ); // as-of-time *before* fetching GTID variables + + if ( $this->useGTIDs() ) { + // Try to use GTIDs, fallbacking to binlog positions if not possible + $data = $this->getServerGTIDs( __METHOD__ ); + // Use gtid_slave_pos for MariaDB and gtid_executed for MySQL + foreach ( [ 'gtid_slave_pos', 'gtid_executed' ] as $name ) { + if ( isset( $data[$name] ) && strlen( $data[$name] ) ) { + return new MySQLMasterPos( $data[$name], $now ); + } } } - $res = $this->query( 'SHOW SLAVE STATUS', __METHOD__ ); - $row = $this->fetchObject( $res ); - if ( $row && strlen( $row->Relay_Master_Log_File ) ) { + $data = $this->getServerRoleStatus( 'SLAVE', __METHOD__ ); + if ( $data && strlen( $data['Relay_Master_Log_File'] ) ) { return new MySQLMasterPos( - "{$row->Relay_Master_Log_File}/{$row->Exec_Master_Log_Pos}", + "{$data['Relay_Master_Log_File']}/{$data['Exec_Master_Log_Pos']}", $now ); } @@ -1002,23 +1017,97 @@ abstract class DatabaseMysqlBase extends Database { * @return MySQLMasterPos|bool */ public function getMasterPos() { - $now = microtime( true ); + $now = microtime( true ); // as-of-time *before* fetching GTID variables + + $pos = false; + if ( $this->useGTIDs() ) { + // Try to use GTIDs, fallbacking to binlog positions if not possible + $data = $this->getServerGTIDs( __METHOD__ ); + // Use gtid_binlog_pos for MariaDB and gtid_executed for MySQL + foreach ( [ 'gtid_binlog_pos', 'gtid_executed' ] as $name ) { + if ( isset( $data[$name] ) && strlen( $data[$name] ) ) { + $pos = new MySQLMasterPos( $data[$name], $now ); + break; + } + } + // Filter domains that are inactive or not relevant to the session + if ( $pos ) { + $pos->setActiveOriginServerId( $this->getServerId() ); + $pos->setActiveOriginServerUUID( $this->getServerUUID() ); + if ( isset( $data['gtid_domain_id'] ) ) { + $pos->setActiveDomain( $data['gtid_domain_id'] ); + } + } + } - if ( $this->useGTIDs ) { - $res = $this->query( "SELECT @@global.gtid_binlog_pos AS Value", __METHOD__ ); - $gtidRow = $this->fetchObject( $res ); - if ( $gtidRow && strlen( $gtidRow->Value ) ) { - return new MySQLMasterPos( $gtidRow->Value, $now ); + if ( !$pos ) { + $data = $this->getServerRoleStatus( 'MASTER', __METHOD__ ); + if ( $data && strlen( $data['File'] ) ) { + $pos = new MySQLMasterPos( "{$data['File']}/{$data['Position']}", $now ); } } - $res = $this->query( 'SHOW MASTER STATUS', __METHOD__ ); - $row = $this->fetchObject( $res ); - if ( $row && strlen( $row->File ) ) { - return new MySQLMasterPos( "{$row->File}/{$row->Position}", $now ); + return $pos; + } + + /** + * @return int + * @throws DBQueryError If the variable doesn't exist for some reason + */ + protected function getServerId() { + return $this->srvCache->getWithSetCallback( + $this->srvCache->makeGlobalKey( 'mysql-server-id', $this->getServer() ), + self::SERVER_ID_CACHE_TTL, + function () { + $res = $this->query( "SELECT @@server_id AS id", __METHOD__ ); + return intval( $this->fetchObject( $res )->id ); + } + ); + } + + /** + * @return string|null + */ + protected function getServerUUID() { + return $this->srvCache->getWithSetCallback( + $this->srvCache->makeGlobalKey( 'mysql-server-uuid', $this->getServer() ), + self::SERVER_ID_CACHE_TTL, + function () { + $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'server_uuid'" ); + $row = $this->fetchObject( $res ); + + return $row ? $row->Value : null; + } + ); + } + + /** + * @param string $fname + * @return string[] + */ + protected function getServerGTIDs( $fname = __METHOD__ ) { + $map = []; + // Get global-only variables like gtid_executed + $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'gtid_%'", $fname ); + foreach ( $res as $row ) { + $map[$row->Variable_name] = $row->Value; + } + // Get session-specific (e.g. gtid_domain_id since that is were writes will log) + $res = $this->query( "SHOW SESSION VARIABLES LIKE 'gtid_%'", $fname ); + foreach ( $res as $row ) { + $map[$row->Variable_name] = $row->Value; } - return false; + return $map; + } + + /** + * @param string $role One of "MASTER"/"SLAVE" + * @param string $fname + * @return string[] Latest available server status row + */ + protected function getServerRoleStatus( $role, $fname = __METHOD__ ) { + return $this->query( "SHOW $role STATUS", $fname )->fetchRow() ?: []; } public function serverIsReadOnly() { @@ -1331,6 +1420,26 @@ abstract class DatabaseMysqlBase extends Database { return $errno == 2013 || $errno == 2006; } + protected function wasKnownStatementRollbackError() { + $errno = $this->lastErrno(); + + if ( $errno === 1205 ) { // lock wait timeout + // Note that this is uncached to avoid stale values of SET is used + $row = $this->selectRow( + false, + [ 'innodb_rollback_on_timeout' => '@@innodb_rollback_on_timeout' ], + [], + __METHOD__ + ); + // https://dev.mysql.com/doc/refman/5.7/en/innodb-error-handling.html + // https://dev.mysql.com/doc/refman/5.5/en/innodb-parameters.html + return $row->innodb_rollback_on_timeout ? false : true; + } + + // See https://dev.mysql.com/doc/refman/5.5/en/error-messages-server.html + return in_array( $errno, [ 1022, 1216, 1217, 1137 ], true ); + } + /** * @param string $oldName * @param string $newName @@ -1463,6 +1572,12 @@ abstract class DatabaseMysqlBase extends Database { return 'CAST( ' . $field . ' AS SIGNED )'; } + /* + * @return bool Whether GTID support is used (mockable for testing) + */ + protected function useGTIDs() { + return $this->useGTIDs; + } } class_alias( DatabaseMysqlBase::class, 'DatabaseMysqlBase' ); diff --git a/includes/libs/rdbms/database/DatabasePostgres.php b/includes/libs/rdbms/database/DatabasePostgres.php index e3dd3d00b3..9610839c15 100644 --- a/includes/libs/rdbms/database/DatabasePostgres.php +++ b/includes/libs/rdbms/database/DatabasePostgres.php @@ -36,8 +36,6 @@ class DatabasePostgres extends Database { /** @var resource */ protected $lastResultHandle = null; - /** @var int The number of rows affected as an integer */ - protected $lastAffectedRowCount = null; /** @var float|string */ private $numericVersion = null; @@ -45,6 +43,8 @@ class DatabasePostgres extends Database { private $connectString; /** @var string */ private $coreSchema; + /** @var string */ + private $tempSchema; /** @var string[] Map of (reserved table name => alternate table name) */ private $keywordTableMap = []; @@ -75,15 +75,17 @@ class DatabasePostgres extends Database { } public function hasConstraint( $name ) { - $conn = $this->getBindingHandle(); - - $sql = "SELECT 1 FROM pg_catalog.pg_constraint c, pg_catalog.pg_namespace n " . - "WHERE c.connamespace = n.oid AND conname = '" . - pg_escape_string( $conn, $name ) . "' AND n.nspname = '" . - pg_escape_string( $conn, $this->getCoreSchema() ) . "'"; - $res = $this->doQuery( $sql ); - - return $this->numRows( $res ); + foreach ( $this->getCoreSchemas() as $schema ) { + $sql = "SELECT 1 FROM pg_catalog.pg_constraint c, pg_catalog.pg_namespace n " . + "WHERE c.connamespace = n.oid AND conname = " . + $this->addQuotes( $name ) . " AND n.nspname = " . + $this->addQuotes( $schema ); + $res = $this->doQuery( $sql ); + if ( $res && $this->numRows( $res ) ) { + return true; + } + } + return false; } public function open( $server, $user, $password, $dbName ) { @@ -155,9 +157,7 @@ class DatabasePostgres extends Database { $this->query( "SET datestyle = 'ISO, YMD'", __METHOD__ ); $this->query( "SET timezone = 'GMT'", __METHOD__ ); $this->query( "SET standard_conforming_strings = on", __METHOD__ ); - if ( $this->getServerVersion() >= 9.0 ) { - $this->query( "SET bytea_output = 'escape'", __METHOD__ ); // PHP bug 53127 - } + $this->query( "SET bytea_output = 'escape'", __METHOD__ ); // PHP bug 53127 $this->determineCoreSchema( $this->schema ); // The schema to be used is now in the search path; no need for explicit qualification @@ -219,7 +219,6 @@ class DatabasePostgres extends Database { throw new DBUnexpectedError( $this, "Unable to post new query to PostgreSQL\n" ); } $this->lastResultHandle = pg_get_result( $conn ); - $this->lastAffectedRowCount = null; if ( pg_result_error( $this->lastResultHandle ) ) { return false; } @@ -248,25 +247,6 @@ class DatabasePostgres extends Database { } } - public function reportQueryError( $error, $errno, $sql, $fname, $tempIgnore = false ) { - if ( $tempIgnore ) { - /* Check for constraint violation */ - if ( $errno === '23505' ) { - parent::reportQueryError( $error, $errno, $sql, $fname, $tempIgnore ); - - return; - } - } - /* Transaction stays in the ERROR state until rolled back */ - if ( $this->trxLevel ) { - // Throw away the transaction state, then raise the error as normal. - // Note that if this connection is managed by LBFactory, it's already expected - // that the other transactions LBFactory manages will be rolled back. - $this->rollback( __METHOD__, self::FLUSHING_INTERNAL ); - } - parent::reportQueryError( $error, $errno, $sql, $fname, false ); - } - public function freeResult( $res ) { if ( $res instanceof ResultWrapper ) { $res = $res->result; @@ -390,10 +370,6 @@ class DatabasePostgres extends Database { } protected function fetchAffectedRowCount() { - if ( !is_null( $this->lastAffectedRowCount ) ) { - // Forced result for simulated queries - return $this->lastAffectedRowCount; - } if ( !$this->lastResultHandle ) { return 0; } @@ -456,59 +432,65 @@ class DatabasePostgres extends Database { public function indexAttributes( $index, $schema = false ) { if ( $schema === false ) { - $schema = $this->getCoreSchema(); - } - /* - * A subquery would be not needed if we didn't care about the order - * of attributes, but we do - */ - $sql = <<<__INDEXATTR__ - - SELECT opcname, - attname, - i.indoption[s.g] as option, - pg_am.amname - FROM - (SELECT generate_series(array_lower(isub.indkey,1), array_upper(isub.indkey,1)) AS g - FROM - pg_index isub - JOIN pg_class cis - ON cis.oid=isub.indexrelid - JOIN pg_namespace ns - ON cis.relnamespace = ns.oid - WHERE cis.relname='$index' AND ns.nspname='$schema') AS s, - pg_attribute, - pg_opclass opcls, - pg_am, - pg_class ci - JOIN pg_index i - ON ci.oid=i.indexrelid - JOIN pg_class ct - ON ct.oid = i.indrelid - JOIN pg_namespace n - ON ci.relnamespace = n.oid - WHERE - ci.relname='$index' AND n.nspname='$schema' - AND attrelid = ct.oid - AND i.indkey[s.g] = attnum - AND i.indclass[s.g] = opcls.oid - AND pg_am.oid = opcls.opcmethod + $schemas = $this->getCoreSchemas(); + } else { + $schemas = [ $schema ]; + } + + $eindex = $this->addQuotes( $index ); + + foreach ( $schemas as $schema ) { + $eschema = $this->addQuotes( $schema ); + /* + * A subquery would be not needed if we didn't care about the order + * of attributes, but we do + */ + $sql = <<<__INDEXATTR__ + + SELECT opcname, + attname, + i.indoption[s.g] as option, + pg_am.amname + FROM + (SELECT generate_series(array_lower(isub.indkey,1), array_upper(isub.indkey,1)) AS g + FROM + pg_index isub + JOIN pg_class cis + ON cis.oid=isub.indexrelid + JOIN pg_namespace ns + ON cis.relnamespace = ns.oid + WHERE cis.relname=$eindex AND ns.nspname=$eschema) AS s, + pg_attribute, + pg_opclass opcls, + pg_am, + pg_class ci + JOIN pg_index i + ON ci.oid=i.indexrelid + JOIN pg_class ct + ON ct.oid = i.indrelid + JOIN pg_namespace n + ON ci.relnamespace = n.oid + WHERE + ci.relname=$eindex AND n.nspname=$eschema + AND attrelid = ct.oid + AND i.indkey[s.g] = attnum + AND i.indclass[s.g] = opcls.oid + AND pg_am.oid = opcls.opcmethod __INDEXATTR__; - $res = $this->query( $sql, __METHOD__ ); - $a = []; - if ( $res ) { - foreach ( $res as $row ) { - $a[] = [ - $row->attname, - $row->opcname, - $row->amname, - $row->option ]; + $res = $this->query( $sql, __METHOD__ ); + $a = []; + if ( $res ) { + foreach ( $res as $row ) { + $a[] = [ + $row->attname, + $row->opcname, + $row->amname, + $row->option ]; + } + return $a; } - } else { - return null; } - - return $a; + return null; } public function indexUnique( $table, $index, $fname = __METHOD__ ) { @@ -578,18 +560,7 @@ __INDEXATTR__; return parent::selectSQLText( $table, $vars, $conds, $fname, $options, $join_conds ); } - /** - * INSERT wrapper, inserts an array into a table - * - * $args may be a single associative array, or an array of these with numeric keys, - * for multi-row insert (Postgres version 8.2 and above only). - * - * @param string $table Name of the table to insert to. - * @param array $args Items to insert into the table. - * @param string $fname Name of the function, for profiling - * @param array|string $options String or array. Valid options: IGNORE - * @return bool Success of insert operation. IGNORE always returns true. - */ + /** @inheritDoc */ public function insert( $table, $args, $fname = __METHOD__, $options = [] ) { if ( !count( $args ) ) { return true; @@ -605,98 +576,68 @@ __INDEXATTR__; } if ( isset( $args[0] ) && is_array( $args[0] ) ) { - $multi = true; + $rows = $args; $keys = array_keys( $args[0] ); } else { - $multi = false; + $rows = [ $args ]; $keys = array_keys( $args ); } - // If IGNORE is set, we use savepoints to emulate mysql's behavior - // @todo If PostgreSQL 9.5+, we could use ON CONFLICT DO NOTHING instead - $savepoint = $olde = null; - $numrowsinserted = 0; - if ( in_array( 'IGNORE', $options ) ) { - $savepoint = new SavepointPostgres( $this, 'mw', $this->queryLogger ); - $olde = error_reporting( 0 ); - // For future use, we may want to track the number of actual inserts - // Right now, insert (all writes) simply return true/false - } + $ignore = in_array( 'IGNORE', $options ); $sql = "INSERT INTO $table (" . implode( ',', $keys ) . ') VALUES '; - if ( $multi ) { - if ( $this->numericVersion >= 8.2 && !$savepoint ) { - $first = true; - foreach ( $args as $row ) { - if ( $first ) { - $first = false; - } else { - $sql .= ','; - } - $sql .= '(' . $this->makeList( $row ) . ')'; + if ( $this->numericVersion >= 9.5 || !$ignore ) { + // No IGNORE or our PG has "ON CONFLICT DO NOTHING" + $first = true; + foreach ( $rows as $row ) { + if ( $first ) { + $first = false; + } else { + $sql .= ','; } - $res = (bool)$this->query( $sql, $fname, $savepoint ); - } else { - $res = true; - $origsql = $sql; - foreach ( $args as $row ) { - $tempsql = $origsql; + $sql .= '(' . $this->makeList( $row ) . ')'; + } + if ( $ignore ) { + $sql .= ' ON CONFLICT DO NOTHING'; + } + $this->query( $sql, $fname ); + } else { + // Emulate IGNORE by doing each row individually, with savepoints + // to roll back as necessary. + $numrowsinserted = 0; + + $tok = $this->startAtomic( "$fname (outer)", self::ATOMIC_CANCELABLE ); + try { + foreach ( $rows as $row ) { + $tempsql = $sql; $tempsql .= '(' . $this->makeList( $row ) . ')'; - if ( $savepoint ) { - $savepoint->savepoint(); - } - - $tempres = (bool)$this->query( $tempsql, $fname, $savepoint ); - - if ( $savepoint ) { - $bar = pg_result_error( $this->lastResultHandle ); - if ( $bar != false ) { - $savepoint->rollback(); - } else { - $savepoint->release(); - $numrowsinserted++; + $this->startAtomic( "$fname (inner)", self::ATOMIC_CANCELABLE ); + try { + $this->query( $tempsql, $fname ); + $this->endAtomic( "$fname (inner)" ); + $numrowsinserted++; + } catch ( DBQueryError $e ) { + $this->cancelAtomic( "$fname (inner)" ); + // Our IGNORE is supposed to ignore duplicate key errors, but not others. + // (even though MySQL's version apparently ignores all errors) + if ( $e->errno !== '23505' ) { + throw $e; } } - - // If any of them fail, we fail overall for this function call - // Note that this will be ignored if IGNORE is set - if ( !$tempres ) { - $res = false; - } } + } catch ( Exception $e ) { + $this->cancelAtomic( "$fname (outer)", $tok ); + throw $e; } - } else { - // Not multi, just a lone insert - if ( $savepoint ) { - $savepoint->savepoint(); - } - - $sql .= '(' . $this->makeList( $args ) . ')'; - $res = (bool)$this->query( $sql, $fname, $savepoint ); - if ( $savepoint ) { - $bar = pg_result_error( $this->lastResultHandle ); - if ( $bar != false ) { - $savepoint->rollback(); - } else { - $savepoint->release(); - $numrowsinserted++; - } - } - } - if ( $savepoint ) { - error_reporting( $olde ); - $savepoint->commit(); + $this->endAtomic( "$fname (outer)" ); // Set the affected row count for the whole operation - $this->lastAffectedRowCount = $numrowsinserted; - - // IGNORE always returns true - return true; + $this->affectedRowCount = $numrowsinserted; } - return $res; + return true; } /** @@ -726,14 +667,31 @@ __INDEXATTR__; $insertOptions = [ $insertOptions ]; } - /* - * If IGNORE is set, use the non-native version. - * @todo If PostgreSQL 9.5+, we could use ON CONFLICT DO NOTHING - */ if ( in_array( 'IGNORE', $insertOptions ) ) { - return $this->nonNativeInsertSelect( - $destTable, $srcTable, $varMap, $conds, $fname, $insertOptions, $selectOptions, $selectJoinConds - ); + if ( $this->getServerVersion() >= 9.5 ) { + // Use ON CONFLICT DO NOTHING if we have it for IGNORE + $destTable = $this->tableName( $destTable ); + + $selectSql = $this->selectSQLText( + $srcTable, + array_values( $varMap ), + $conds, + $fname, + $selectOptions, + $selectJoinConds + ); + + $sql = "INSERT INTO $destTable (" . implode( ',', array_keys( $varMap ) ) . ') ' . + $selectSql . ' ON CONFLICT DO NOTHING'; + + return $this->query( $sql, $fname ); + } else { + // IGNORE and we don't have ON CONFLICT DO NOTHING, so just use the non-native version + return $this->nonNativeInsertSelect( + $destTable, $srcTable, $varMap, $conds, $fname, + $insertOptions, $selectOptions, $selectJoinConds + ); + } } return parent::nativeInsertSelect( $destTable, $srcTable, $varMap, $conds, $fname, @@ -805,36 +763,106 @@ __INDEXATTR__; } public function wasDeadlock() { - // https://www.postgresql.org/docs/8.2/static/errcodes-appendix.html + // https://www.postgresql.org/docs/9.2/static/errcodes-appendix.html return $this->lastErrno() === '40P01'; } public function wasLockTimeout() { - // https://www.postgresql.org/docs/8.2/static/errcodes-appendix.html + // https://www.postgresql.org/docs/9.2/static/errcodes-appendix.html return $this->lastErrno() === '55P03'; } public function wasConnectionError( $errno ) { - // https://www.postgresql.org/docs/8.2/static/errcodes-appendix.html + // https://www.postgresql.org/docs/9.2/static/errcodes-appendix.html static $codes = [ '08000', '08003', '08006', '08001', '08004', '57P01', '57P03', '53300' ]; return in_array( $errno, $codes, true ); } + protected function wasKnownStatementRollbackError() { + return false; // transaction has to be rolled-back from error state + } + public function duplicateTableStructure( $oldName, $newName, $temporary = false, $fname = __METHOD__ ) { - $newName = $this->addIdentifierQuotes( $newName ); - $oldName = $this->addIdentifierQuotes( $oldName ); + $newNameE = $this->addIdentifierQuotes( $newName ); + $oldNameE = $this->addIdentifierQuotes( $oldName ); + + $temporary = $temporary ? 'TEMPORARY' : ''; + + $ret = $this->query( "CREATE $temporary TABLE $newNameE " . + "(LIKE $oldNameE INCLUDING DEFAULTS INCLUDING INDEXES)", $fname ); + if ( !$ret ) { + return $ret; + } + + $res = $this->query( 'SELECT attname FROM pg_class c' + . ' JOIN pg_namespace n ON (n.oid = c.relnamespace)' + . ' JOIN pg_attribute a ON (a.attrelid = c.oid)' + . ' JOIN pg_attrdef d ON (c.oid=d.adrelid and a.attnum=d.adnum)' + . ' WHERE relkind = \'r\'' + . ' AND nspname = ' . $this->addQuotes( $this->getCoreSchema() ) + . ' AND relname = ' . $this->addQuotes( $oldName ) + . ' AND adsrc LIKE \'nextval(%\'', + $fname + ); + $row = $this->fetchObject( $res ); + if ( $row ) { + $field = $row->attname; + $newSeq = "{$newName}_{$field}_seq"; + $fieldE = $this->addIdentifierQuotes( $field ); + $newSeqE = $this->addIdentifierQuotes( $newSeq ); + $newSeqQ = $this->addQuotes( $newSeq ); + $this->query( "CREATE $temporary SEQUENCE $newSeqE OWNED BY $newNameE.$fieldE", $fname ); + $this->query( + "ALTER TABLE $newNameE ALTER COLUMN $fieldE SET DEFAULT nextval({$newSeqQ}::regclass)", + $fname + ); + } - return $this->query( 'CREATE ' . ( $temporary ? 'TEMPORARY ' : '' ) . " TABLE $newName " . - "(LIKE $oldName INCLUDING DEFAULTS INCLUDING INDEXES)", $fname ); + return $ret; + } + + public function resetSequenceForTable( $table, $fname = __METHOD__ ) { + $table = $this->tableName( $table, 'raw' ); + foreach ( $this->getCoreSchemas() as $schema ) { + $res = $this->query( + 'SELECT c.oid FROM pg_class c JOIN pg_namespace n ON (n.oid = c.relnamespace)' + . ' WHERE relkind = \'r\'' + . ' AND nspname = ' . $this->addQuotes( $schema ) + . ' AND relname = ' . $this->addQuotes( $table ), + $fname + ); + if ( !$res || !$this->numRows( $res ) ) { + continue; + } + + $oid = $this->fetchObject( $res )->oid; + $res = $this->query( 'SELECT adsrc FROM pg_attribute a' + . ' JOIN pg_attrdef d ON (a.attrelid=d.adrelid and a.attnum=d.adnum)' + . " WHERE a.attrelid = $oid" + . ' AND adsrc LIKE \'nextval(%\'', + $fname + ); + $row = $this->fetchObject( $res ); + if ( $row ) { + $this->query( + 'SELECT ' . preg_replace( '/^nextval\((.+)\)$/', 'setval($1,1,false)', $row->adsrc ), + $fname + ); + return true; + } + return false; + } + + return false; } public function listTables( $prefix = null, $fname = __METHOD__ ) { - $eschema = $this->addQuotes( $this->getCoreSchema() ); + $eschemas = implode( ',', array_map( [ $this, 'addQuotes' ], $this->getCoreSchemas() ) ); $result = $this->query( - "SELECT tablename FROM pg_tables WHERE schemaname = $eschema", $fname ); + "SELECT DISTINCT tablename FROM pg_tables WHERE schemaname IN ($eschemas)", $fname ); $endArray = []; foreach ( $result as $table ) { @@ -1025,6 +1053,29 @@ __INDEXATTR__; return $this->coreSchema; } + /** + * Return schema names for temporary tables and core application tables + * + * @since 1.31 + * @return string[] schema names + */ + public function getCoreSchemas() { + if ( $this->tempSchema ) { + return [ $this->tempSchema, $this->getCoreSchema() ]; + } + + $res = $this->query( + "SELECT nspname FROM pg_catalog.pg_namespace n WHERE n.oid = pg_my_temp_schema()", __METHOD__ + ); + $row = $this->fetchObject( $res ); + if ( $row ) { + $this->tempSchema = $row->nspname; + return [ $this->tempSchema, $this->getCoreSchema() ]; + } + + return [ $this->getCoreSchema() ]; + } + public function getServerVersion() { if ( !isset( $this->numericVersion ) ) { $conn = $this->getBindingHandle(); @@ -1057,18 +1108,24 @@ __INDEXATTR__; $types = [ $types ]; } if ( $schema === false ) { - $schema = $this->getCoreSchema(); + $schemas = $this->getCoreSchemas(); + } else { + $schemas = [ $schema ]; } $table = $this->realTableName( $table, 'raw' ); $etable = $this->addQuotes( $table ); - $eschema = $this->addQuotes( $schema ); - $sql = "SELECT 1 FROM pg_catalog.pg_class c, pg_catalog.pg_namespace n " - . "WHERE c.relnamespace = n.oid AND c.relname = $etable AND n.nspname = $eschema " - . "AND c.relkind IN ('" . implode( "','", $types ) . "')"; - $res = $this->query( $sql ); - $count = $res ? $res->numRows() : 0; + foreach ( $schemas as $schema ) { + $eschema = $this->addQuotes( $schema ); + $sql = "SELECT 1 FROM pg_catalog.pg_class c, pg_catalog.pg_namespace n " + . "WHERE c.relnamespace = n.oid AND c.relname = $etable AND n.nspname = $eschema " + . "AND c.relkind IN ('" . implode( "','", $types ) . "')"; + $res = $this->query( $sql ); + if ( $res && $res->numRows() ) { + return true; + } + } - return (bool)$count; + return false; } /** @@ -1093,20 +1150,21 @@ __INDEXATTR__; AND tgrelid=pg_class.oid AND nspname=%s AND relname=%s AND tgname=%s SQL; - $res = $this->query( - sprintf( - $q, - $this->addQuotes( $this->getCoreSchema() ), - $this->addQuotes( $table ), - $this->addQuotes( $trigger ) - ) - ); - if ( !$res ) { - return null; + foreach ( $this->getCoreSchemas() as $schema ) { + $res = $this->query( + sprintf( + $q, + $this->addQuotes( $schema ), + $this->addQuotes( $table ), + $this->addQuotes( $trigger ) + ) + ); + if ( $res && $res->numRows() ) { + return true; + } } - $rows = $res->numRows(); - return $rows; + return false; } public function ruleExists( $table, $rule ) { @@ -1114,7 +1172,7 @@ SQL; [ 'rulename' => $rule, 'tablename' => $table, - 'schemaname' => $this->getCoreSchema() + 'schemaname' => $this->getCoreSchemas() ] ); @@ -1122,19 +1180,19 @@ SQL; } public function constraintExists( $table, $constraint ) { - $sql = sprintf( "SELECT 1 FROM information_schema.table_constraints " . - "WHERE constraint_schema = %s AND table_name = %s AND constraint_name = %s", - $this->addQuotes( $this->getCoreSchema() ), - $this->addQuotes( $table ), - $this->addQuotes( $constraint ) - ); - $res = $this->query( $sql ); - if ( !$res ) { - return null; + foreach ( $this->getCoreSchemas() as $schema ) { + $sql = sprintf( "SELECT 1 FROM information_schema.table_constraints " . + "WHERE constraint_schema = %s AND table_name = %s AND constraint_name = %s", + $this->addQuotes( $schema ), + $this->addQuotes( $table ), + $this->addQuotes( $constraint ) + ); + $res = $this->query( $sql ); + if ( $res && $res->numRows() ) { + return true; + } } - $rows = $res->numRows(); - - return $rows; + return false; } /** @@ -1228,28 +1286,6 @@ SQL; return "'" . pg_escape_string( $conn, (string)$s ) . "'"; } - /** - * Postgres specific version of replaceVars. - * Calls the parent version in Database.php - * - * @param string $ins SQL string, read from a stream (usually tables.sql) - * @return string SQL string - */ - protected function replaceVars( $ins ) { - $ins = parent::replaceVars( $ins ); - - if ( $this->numericVersion >= 8.3 ) { - // Thanks for not providing backwards-compatibility, 8.3 - $ins = preg_replace( "/to_tsvector\s*\(\s*'default'\s*,/", 'to_tsvector(', $ins ); - } - - if ( $this->numericVersion <= 8.1 ) { // Our minimum version - $ins = str_replace( 'USING gin', 'USING gist', $ins ); - } - - return $ins; - } - public function makeSelectOptions( $options ) { $preLimitTail = $postLimitTail = ''; $startOpts = $useIndex = $ignoreIndex = ''; @@ -1347,7 +1383,7 @@ SQL; if ( !parent::lockIsFree( $lockName, $method ) ) { return false; // already held } - // http://www.postgresql.org/docs/8.2/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS + // http://www.postgresql.org/docs/9.2/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS $key = $this->addQuotes( $this->bigintFromLockName( $lockName ) ); $result = $this->query( "SELECT (CASE(pg_try_advisory_lock($key)) WHEN 'f' THEN 'f' ELSE pg_advisory_unlock($key) END) AS lockstatus", $method ); @@ -1357,7 +1393,7 @@ SQL; } public function lock( $lockName, $method, $timeout = 5 ) { - // http://www.postgresql.org/docs/8.2/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS + // http://www.postgresql.org/docs/9.2/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS $key = $this->addQuotes( $this->bigintFromLockName( $lockName ) ); $loop = new WaitConditionLoop( function () use ( $lockName, $key, $timeout, $method ) { @@ -1377,7 +1413,7 @@ SQL; } public function unlock( $lockName, $method ) { - // http://www.postgresql.org/docs/8.2/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS + // http://www.postgresql.org/docs/9.2/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS $key = $this->addQuotes( $this->bigintFromLockName( $lockName ) ); $result = $this->query( "SELECT pg_advisory_unlock($key) as lockstatus", $method ); $row = $this->fetchObject( $result ); diff --git a/includes/libs/rdbms/database/DatabaseSqlite.php b/includes/libs/rdbms/database/DatabaseSqlite.php index d5a74891be..a6a153a387 100644 --- a/includes/libs/rdbms/database/DatabaseSqlite.php +++ b/includes/libs/rdbms/database/DatabaseSqlite.php @@ -120,7 +120,7 @@ class DatabaseSqlite extends Database { protected function doInitConnection() { if ( $this->dbPath !== null ) { // Standalone .sqlite file mode. - $this->openFile( $this->dbPath ); + $this->openFile( $this->dbPath, $this->connectionParams['dbname'] ); } elseif ( $this->dbDir !== null ) { // Stock wiki mode using standard file names per DB if ( strlen( $this->connectionParams['dbname'] ) ) { @@ -173,11 +173,7 @@ class DatabaseSqlite extends Database { $this->conn = false; throw new DBConnectionError( $this, "SQLite database not accessible" ); } - $this->openFile( $fileName ); - - if ( $this->conn ) { - $this->dbName = $dbName; - } + $this->openFile( $fileName, $dbName ); return (bool)$this->conn; } @@ -186,10 +182,11 @@ class DatabaseSqlite extends Database { * Opens a database file * * @param string $fileName + * @param string $dbName * @throws DBConnectionError * @return PDO|bool SQL connection or false if failed */ - protected function openFile( $fileName ) { + protected function openFile( $fileName, $dbName ) { $err = false; $this->dbPath = $fileName; @@ -211,6 +208,7 @@ class DatabaseSqlite extends Database { $this->opened = is_object( $this->conn ); if ( $this->opened ) { + $this->dbName = $dbName; # Set error codes only, don't raise exceptions $this->conn->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT ); # Enforce LIKE to be case sensitive, just like MySQL @@ -727,6 +725,14 @@ class DatabaseSqlite extends Database { return $errno == 17; // SQLITE_SCHEMA; } + protected function wasKnownStatementRollbackError() { + // ON CONFLICT ROLLBACK clauses make it so that SQLITE_CONSTRAINT error is + // ambiguous with regard to whether it implies a ROLLBACK or an ABORT happened. + // https://sqlite.org/lang_createtable.html#uniqueconst + // https://sqlite.org/lang_conflict.html + return false; + } + /** * @return string Wikitext of a link to the server software's web site */ @@ -1073,6 +1079,16 @@ class DatabaseSqlite extends Database { } } + public function resetSequenceForTable( $table, $fname = __METHOD__ ) { + $encTable = $this->addIdentifierQuotes( 'sqlite_sequence' ); + $encName = $this->addQuotes( $this->tableName( $table, 'raw' ) ); + $this->query( "DELETE FROM $encTable WHERE name = $encName", $fname ); + } + + public function databasesAreIndependent() { + return true; + } + /** * @return string */ diff --git a/includes/libs/rdbms/database/IDatabase.php b/includes/libs/rdbms/database/IDatabase.php index 5876d6bab0..ca3fd52cf4 100644 --- a/includes/libs/rdbms/database/IDatabase.php +++ b/includes/libs/rdbms/database/IDatabase.php @@ -22,7 +22,6 @@ namespace Wikimedia\Rdbms; use InvalidArgumentException; use Wikimedia\ScopedCallback; use RuntimeException; -use UnexpectedValueException; use stdClass; /** @@ -54,10 +53,12 @@ interface IDatabase { /** @var string Atomic section is cancelable */ const ATOMIC_CANCELABLE = 'cancelable'; - /** @var string Transaction operation comes from service managing all DBs */ + /** @var string Commit/rollback is from outside the IDatabase handle and connection manager */ + const FLUSHING_ONE = ''; + /** @var string Commit/rollback is from the connection manager for the IDatabase handle */ const FLUSHING_ALL_PEERS = 'flush'; - /** @var string Transaction operation comes from the database class internally */ - const FLUSHING_INTERNAL = 'flush'; + /** @var string Commit/rollback is from the IDatabase handle internally */ + const FLUSHING_INTERNAL = 'flush-internal'; /** @var string Do not remember the prior flags */ const REMEMBER_NOTHING = ''; @@ -253,8 +254,15 @@ interface IDatabase { public function writesPending(); /** - * Returns true if there is a transaction/round open with possible write - * queries or transaction pre-commit/idle callbacks waiting on it to finish. + * @return bool Whether there is a transaction open with pre-commit callbacks pending + * @since 1.32 + */ + public function preCommitCallbacksPending(); + + /** + * Whether there is a transaction open with either possible write queries + * or unresolved pre-commit/commit/resolution callbacks pending + * * This does *not* count recurring callbacks, e.g. from setTransactionListener(). * * @return bool @@ -1472,13 +1480,16 @@ interface IDatabase { /** * Run a callback as soon as the current transaction commits or rolls back. * An error is thrown if no transaction is pending. Queries in the function will run in - * AUTO-COMMIT mode unless there are begin() calls. Callbacks must commit any transactions + * AUTOCOMMIT mode unless there are begin() calls. Callbacks must commit any transactions * that they begin. * * This is useful for combining cooperative locks and DB transactions. * - * The callback takes one argument: + * @note: do not assume that *other* IDatabase instances will be AUTOCOMMIT mode + * + * The callback takes the following arguments: * - How the transaction ended (IDatabase::TRIGGER_COMMIT or IDatabase::TRIGGER_ROLLBACK) + * - This IDatabase instance (since 1.32) * * @param callable $callback * @param string $fname Caller name @@ -1495,7 +1506,7 @@ interface IDatabase { * of the round, just after all peer transactions COMMIT. If the transaction round * is rolled back, then the callback is cancelled. * - * Queries in the function will run in AUTO-COMMIT mode unless there are begin() calls. + * Queries in the function will run in AUTOCOMMIT mode unless there are begin() calls. * Callbacks must commit any transactions that they begin. * * This is useful for updates to different systems or when separate transactions are needed. @@ -1504,14 +1515,31 @@ interface IDatabase { * It can also be used for updates that easily suffer from lock timeouts and deadlocks, * but where atomicity is not essential. * + * Avoid using IDatabase instances aside from this one in the callback, unless such instances + * never have IDatabase::DBO_TRX set. This keeps callbacks from interfering with one another. + * * Updates will execute in the order they were enqueued. * - * The callback takes one argument: + * @note: do not assume that *other* IDatabase instances will be AUTOCOMMIT mode + * + * The callback takes the following arguments: * - How the transaction ended (IDatabase::TRIGGER_COMMIT or IDatabase::TRIGGER_IDLE) + * - This IDatabase instance (since 1.32) * * @param callable $callback * @param string $fname Caller name + * @since 1.32 + */ + public function onTransactionCommitOrIdle( callable $callback, $fname = __METHOD__ ); + + /** + * Alias for onTransactionCommitOrIdle() for backwards-compatibility + * + * @param callable $callback + * @param string $fname + * @return mixed * @since 1.20 + * @deprecated Since 1.32 */ public function onTransactionIdle( callable $callback, $fname = __METHOD__ ); @@ -1531,6 +1559,9 @@ interface IDatabase { * * Updates will execute in the order they were enqueued. * + * The callback takes the one argument: + * - This IDatabase instance (since 1.32) + * * @param callable $callback * @param string $fname Caller name * @since 1.22 @@ -1545,7 +1576,10 @@ interface IDatabase { * - This IDatabase object * Callbacks must commit any transactions that they begin. * - * Registering a callback here will not affect writesOrCallbacks() pending + * Registering a callback here will not affect writesOrCallbacks() pending. + * + * Since callbacks from this or onTransactionCommitOrIdle() can start and end transactions, + * a single call to IDatabase::commit might trigger multiple runs of the listener callbacks. * * @param string $name Callback name * @param callable|null $callback Use null to unset a listener @@ -1555,26 +1589,77 @@ interface IDatabase { public function setTransactionListener( $name, callable $callback = null ); /** - * Begin an atomic section of statements + * Begin an atomic section of SQL statements * - * If a transaction has been started already, (optionally) sets a savepoint - * and tracks the given section name to make sure the transaction is not - * committed pre-maturely. This function can be used in layers (with - * sub-sections), so use a stack to keep track of the different atomic - * sections. If there is no transaction, one is started implicitly. + * Start an implicit transaction if no transaction is already active, set a savepoint + * (if $cancelable is ATOMIC_CANCELABLE), and track the given section name to enforce + * that the transaction is not committed prematurely. The end of the section must be + * signified exactly once, either by endAtomic() or cancelAtomic(). Sections can have + * have layers of inner sections (sub-sections), but all sections must be ended in order + * of innermost to outermost. Transactions cannot be started or committed until all + * atomic sections are closed. * - * The goal of this function is to create an atomic section of SQL queries - * without having to start a new transaction if it already exists. + * ATOMIC_CANCELABLE is useful when the caller needs to handle specific failure cases + * by discarding the section's writes. This should not be used for failures when: + * - upsert() could easily be used instead + * - insert() with IGNORE could easily be used instead + * - select() with FOR UPDATE could be checked before issuing writes instead + * - The failure is from code that runs after the first write but doesn't need to + * - The failures are from contention solvable via onTransactionPreCommitOrIdle() + * - The failures are deadlocks; the RDBMs usually discard the whole transaction * - * All atomic levels *must* be explicitly closed using IDatabase::endAtomic() - * or IDatabase::cancelAtomic(), and any database transactions cannot be - * began or committed until all atomic levels are closed. There is no such - * thing as implicitly opening or closing an atomic section. + * @note: callers must use additional measures for situations involving two or more + * (peer) transactions (e.g. updating two database servers at once). The transaction + * and savepoint logic of this method only applies to this specific IDatabase instance. + * + * Example usage: + * @code + * // Start a transaction if there isn't one already + * $dbw->startAtomic( __METHOD__ ); + * // Serialize these thread table updates + * $dbw->select( 'thread', '1', [ 'td_id' => $tid ], __METHOD__, 'FOR UPDATE' ); + * // Add a new comment for the thread + * $dbw->insert( 'comment', $row, __METHOD__ ); + * $cid = $db->insertId(); + * // Update thread reference to last comment + * $dbw->update( 'thread', [ 'td_latest' => $cid ], [ 'td_id' => $tid ], __METHOD__ ); + * // Demark the end of this conceptual unit of updates + * $dbw->endAtomic( __METHOD__ ); + * @endcode + * + * Example usage (atomic changes that might have to be discarded): + * @code + * // Start a transaction if there isn't one already + * $sectionId = $dbw->startAtomic( __METHOD__, $dbw::ATOMIC_CANCELABLE ); + * // Create new record metadata row + * $dbw->insert( 'records', $row, __METHOD__ ); + * // Figure out where to store the data based on the new row's ID + * $path = $recordDirectory . '/' . $dbw->insertId(); + * // Write the record data to the storage system + * $status = $fileBackend->create( [ 'dst' => $path, 'content' => $data ] ); + * if ( $status->isOK() ) { + * // Try to cleanup files orphaned by transaction rollback + * $dbw->onTransactionResolution( + * function ( $type ) use ( $fileBackend, $path ) { + * if ( $type === IDatabase::TRIGGER_ROLLBACK ) { + * $fileBackend->delete( [ 'src' => $path ] ); + * } + * }, + * __METHOD__ + * ); + * // Demark the end of this conceptual unit of updates + * $dbw->endAtomic( __METHOD__ ); + * } else { + * // Discard these writes from the transaction (preserving prior writes) + * $dbw->cancelAtomic( __METHOD__, $sectionId ); + * } + * @endcode * * @since 1.23 * @param string $fname * @param string $cancelable Pass self::ATOMIC_CANCELABLE to use a * savepoint and enable self::cancelAtomic() for this section. + * @return AtomicSectionIdentifier section ID token * @throws DBError */ public function startAtomic( $fname = __METHOD__, $cancelable = self::ATOMIC_NOT_CANCELABLE ); @@ -1602,33 +1687,79 @@ interface IDatabase { * corresponding startAtomic() implicitly started a transaction, that * transaction is rolled back. * - * Note that a call to IDatabase::rollback() will also roll back any open - * atomic sections. + * @note: callers must use additional measures for situations involving two or more + * (peer) transactions (e.g. updating two database servers at once). The transaction + * and savepoint logic of startAtomic() are bound to specific IDatabase instances. + * + * Note that a call to IDatabase::rollback() will also roll back any open atomic sections. * * @note As a micro-optimization to save a few DB calls, this method may only * be called when startAtomic() was called with the ATOMIC_CANCELABLE flag. * @since 1.31 * @see IDatabase::startAtomic * @param string $fname + * @param AtomicSectionIdentifier $sectionId Section ID from startAtomic(); + * passing this enables cancellation of unclosed nested sections [optional] * @throws DBError */ - public function cancelAtomic( $fname = __METHOD__ ); + public function cancelAtomic( $fname = __METHOD__, AtomicSectionIdentifier $sectionId = null ); /** - * Run a callback to do an atomic set of updates for this database + * Perform an atomic section of reversable SQL statements from a callback * * The $callback takes the following arguments: * - This database object * - The value of $fname * - * If any exception occurs in the callback, then cancelAtomic() will be - * called to back out any statements executed by the callback and the error - * will be re-thrown. It may also be that the cancel itself fails with an - * exception before then. In any case, such errors are expected to - * terminate the request, without any outside caller attempting to catch - * errors and commit anyway. + * This will execute the callback inside a pair of startAtomic()/endAtomic() calls. + * If any exception occurs during execution of the callback, it will be handled as follows: + * - If $cancelable is ATOMIC_CANCELABLE, cancelAtomic() will be called to back out any + * (and only) statements executed during the atomic section. If that succeeds, then the + * exception will be re-thrown; if it fails, then a different exception will be thrown + * and any further query attempts will fail until rollback() is called. + * - If $cancelable is ATOMIC_NOT_CANCELABLE, cancelAtomic() will be called to mark the + * end of the section and the error will be re-thrown. Any further query attempts will + * fail until rollback() is called. + * + * This method is convenient for letting calls to the caller of this method be wrapped + * in a try/catch blocks for exception types that imply that the caller failed but was + * able to properly discard the changes it made in the transaction. This method can be + * an alternative to explicit calls to startAtomic()/endAtomic()/cancelAtomic(). + * + * Example usage, "RecordStore::save" method: + * @code + * $dbw->doAtomicSection( __METHOD__, function ( $dbw ) use ( $record ) { + * // Create new record metadata row + * $dbw->insert( 'records', $record->toArray(), __METHOD__ ); + * // Figure out where to store the data based on the new row's ID + * $path = $this->recordDirectory . '/' . $dbw->insertId(); + * // Write the record data to the storage system; + * // blob store throughs StoreFailureException on failure + * $this->blobStore->create( $path, $record->getJSON() ); + * // Try to cleanup files orphaned by transaction rollback + * $dbw->onTransactionResolution( + * function ( $type ) use ( $path ) { + * if ( $type === IDatabase::TRIGGER_ROLLBACK ) { + * $this->blobStore->delete( $path ); + * } + * }, + * __METHOD__ + * ); + * }, $dbw::ATOMIC_CANCELABLE ); + * @endcode * - * This can be an alternative to explicit startAtomic()/endAtomic()/cancelAtomic() calls. + * Example usage, caller of the "RecordStore::save" method: + * @code + * $dbw->startAtomic( __METHOD__ ); + * // ...various SQL writes happen... + * try { + * $recordStore->save( $record ); + * } catch ( StoreFailureException $e ) { + * // ...various SQL writes happen... + * } + * // ...various SQL writes happen... + * $dbw->endAtomic( __METHOD__ ); + * @endcode * * @see Database::startAtomic * @see Database::endAtomic @@ -1636,15 +1767,18 @@ interface IDatabase { * * @param string $fname Caller name (usually __METHOD__) * @param callable $callback Callback that issues DB updates + * @param string $cancelable Pass self::ATOMIC_CANCELABLE to use a + * savepoint and enable self::cancelAtomic() for this section. * @return mixed $res Result of the callback (since 1.28) * @throws DBError * @throws RuntimeException - * @throws UnexpectedValueException * @since 1.27; prior to 1.31 this did a rollback() instead of * cancelAtomic(), and assumed no callers up the stack would ever try to * catch the exception. */ - public function doAtomicSection( $fname, callable $callback ); + public function doAtomicSection( + $fname, callable $callback, $cancelable = self::ATOMIC_NOT_CANCELABLE + ); /** * Begin a transaction. If a transaction is already in progress, @@ -1779,7 +1913,7 @@ interface IDatabase { * This is useful when transactions might use snapshot isolation * (e.g. REPEATABLE-READ in innodb), so the "real" lag of that data * is this lag plus transaction duration. If they don't, it is still - * safe to be pessimistic. In AUTO-COMMIT mode, this still gives an + * safe to be pessimistic. In AUTOCOMMIT mode, this still gives an * indication of the staleness of subsequent reads. * * @return array ('lag': seconds or false on error, 'since': UNIX timestamp of BEGIN) diff --git a/includes/libs/rdbms/database/position/MySQLMasterPos.php b/includes/libs/rdbms/database/position/MySQLMasterPos.php index cdcb79cde6..54eca79a44 100644 --- a/includes/libs/rdbms/database/position/MySQLMasterPos.php +++ b/includes/libs/rdbms/database/position/MySQLMasterPos.php @@ -12,16 +12,36 @@ use UnexpectedValueException; * - Binlog-based usage assumes single-source replication and non-hierarchical replication. * - GTID-based usage allows getting/syncing with multi-source replication. It is assumed * that GTID sets are complete (e.g. include all domains on the server). + * + * @see https://mariadb.com/kb/en/library/gtid/ + * @see https://dev.mysql.com/doc/refman/5.6/en/replication-gtids-concepts.html */ class MySQLMasterPos implements DBMasterPos { - /** @var string|null Binlog file base name */ - public $binlog; - /** @var int[]|null Binglog file position tuple */ - public $pos; - /** @var string[] GTID list */ - public $gtids = []; + /** @var int One of (BINARY_LOG, GTID_MYSQL, GTID_MARIA) */ + private $style; + /** @var string|null Base name of all Binary Log files */ + private $binLog; + /** @var int[]|null Binary Log position tuple (index number, event number) */ + private $logPos; + /** @var string[] Map of (server_uuid/gtid_domain_id => GTID) */ + private $gtids = []; + /** @var int|null Active GTID domain ID */ + private $activeDomain; + /** @var int|null ID of the server were DB writes originate */ + private $activeServerId; + /** @var string|null UUID of the server were DB writes originate */ + private $activeServerUUID; /** @var float UNIX timestamp */ - public $asOfTime = 0.0; + private $asOfTime = 0.0; + + const BINARY_LOG = 'binary-log'; + const GTID_MARIA = 'gtid-maria'; + const GTID_MYSQL = 'gtid-mysql'; + + /** @var int Key name of the binary log index number of a position tuple */ + const CORD_INDEX = 0; + /** @var int Key name of the binary log event number of a position tuple */ + const CORD_EVENT = 1; /** * @param string $position One of (comma separated GTID list, /) @@ -38,18 +58,38 @@ class MySQLMasterPos implements DBMasterPos { protected function init( $position, $asOfTime ) { $m = []; if ( preg_match( '!^(.+)\.(\d+)/(\d+)$!', $position, $m ) ) { - $this->binlog = $m[1]; // ideally something like host name - $this->pos = [ (int)$m[2], (int)$m[3] ]; + $this->binLog = $m[1]; // ideally something like host name + $this->logPos = [ self::CORD_INDEX => (int)$m[2], self::CORD_EVENT => (int)$m[3] ]; + $this->style = self::BINARY_LOG; } else { $gtids = array_filter( array_map( 'trim', explode( ',', $position ) ) ); foreach ( $gtids as $gtid ) { - if ( !self::parseGTID( $gtid ) ) { + $components = self::parseGTID( $gtid ); + if ( !$components ) { throw new InvalidArgumentException( "Invalid GTID '$gtid'." ); } - $this->gtids[] = $gtid; + + list( $domain, $pos ) = $components; + if ( isset( $this->gtids[$domain] ) ) { + // For MySQL, handle the case where some past issue caused a gap in the + // executed GTID set, e.g. [last_purged+1,N-1] and [N+1,N+2+K]. Ignore the + // gap by using the GTID with the highest ending sequence number. + list( , $otherPos ) = self::parseGTID( $this->gtids[$domain] ); + if ( $pos > $otherPos ) { + $this->gtids[$domain] = $gtid; + } + } else { + $this->gtids[$domain] = $gtid; + } + + if ( is_int( $domain ) ) { + $this->style = self::GTID_MARIA; // gtid_domain_id + } else { + $this->style = self::GTID_MYSQL; // server_uuid + } } if ( !$this->gtids ) { - throw new InvalidArgumentException( "Got empty GTID set." ); + throw new InvalidArgumentException( "GTID set cannot be empty." ); } } @@ -66,8 +106,8 @@ class MySQLMasterPos implements DBMasterPos { } // Prefer GTID comparisons, which work with multi-tier replication - $thisPosByDomain = $this->getGtidCoordinates(); - $thatPosByDomain = $pos->getGtidCoordinates(); + $thisPosByDomain = $this->getActiveGtidCoordinates(); + $thatPosByDomain = $pos->getActiveGtidCoordinates(); if ( $thisPosByDomain && $thatPosByDomain ) { $comparisons = []; // Check that this has positions reaching those in $pos for all domains in common @@ -100,8 +140,8 @@ class MySQLMasterPos implements DBMasterPos { } // Prefer GTID comparisons, which work with multi-tier replication - $thisPosDomains = array_keys( $this->getGtidCoordinates() ); - $thatPosDomains = array_keys( $pos->getGtidCoordinates() ); + $thisPosDomains = array_keys( $this->getActiveGtidCoordinates() ); + $thatPosDomains = array_keys( $pos->getActiveGtidCoordinates() ); if ( $thisPosDomains && $thatPosDomains ) { // Check that $this has a GTID for at least one domain also in $pos; due to MariaDB // quirks, prior master switch-overs may result in inactive garbage GTIDs that cannot @@ -118,74 +158,119 @@ class MySQLMasterPos implements DBMasterPos { } /** - * @return string|null + * @return string|null Base name of binary log files + * @since 1.31 + */ + public function getLogName() { + return $this->gtids ? null : $this->binLog; + } + + /** + * @return int[]|null Tuple of (binary log file number, event number) + * @since 1.31 + */ + public function getLogPosition() { + return $this->gtids ? null : $this->logPos; + } + + /** + * @return string|null Name of the binary log file for this position + * @since 1.31 */ public function getLogFile() { - return $this->gtids ? null : "{$this->binlog}.{$this->pos[0]}"; + return $this->gtids ? null : "{$this->binLog}.{$this->logPos[self::CORD_INDEX]}"; } /** - * @return string[] + * @return string[] Map of (server_uuid/gtid_domain_id => GTID) + * @since 1.31 */ public function getGTIDs() { return $this->gtids; } /** - * @return string GTID set or / (e.g db1034-bin.000976/843431247) + * @param int|null $id @@gtid_domain_id of the active replication stream + * @since 1.31 */ - public function __toString() { - return $this->gtids - ? implode( ',', $this->gtids ) - : $this->getLogFile() . "/{$this->pos[1]}"; + public function setActiveDomain( $id ) { + $this->activeDomain = (int)$id; + } + + /** + * @param int|null $id @@server_id of the server were writes originate + * @since 1.31 + */ + public function setActiveOriginServerId( $id ) { + $this->activeServerId = (int)$id; + } + + /** + * @param string|null $id @@server_uuid of the server were writes originate + * @since 1.31 + */ + public function setActiveOriginServerUUID( $id ) { + $this->activeServerUUID = $id; } /** * @param MySQLMasterPos $pos * @param MySQLMasterPos $refPos * @return string[] List of GTIDs from $pos that have domains in $refPos + * @since 1.31 */ public static function getCommonDomainGTIDs( MySQLMasterPos $pos, MySQLMasterPos $refPos ) { - $gtidsCommon = []; - - $relevantDomains = $refPos->getGtidCoordinates(); // (domain => unused) - foreach ( $pos->gtids as $gtid ) { - list( $domain ) = self::parseGTID( $gtid ); - if ( isset( $relevantDomains[$domain] ) ) { - $gtidsCommon[] = $gtid; - } - } - - return $gtidsCommon; + return array_values( + array_intersect_key( $pos->gtids, $refPos->getActiveGtidCoordinates() ) + ); } /** * @see https://mariadb.com/kb/en/mariadb/gtid * @see https://dev.mysql.com/doc/refman/5.6/en/replication-gtids-concepts.html - * @return array Map of (domain => integer position); possibly empty + * @return array Map of (server_uuid/gtid_domain_id => integer position); possibly empty */ - protected function getGtidCoordinates() { + protected function getActiveGtidCoordinates() { $gtidInfos = []; - foreach ( $this->gtids as $gtid ) { - list( $domain, $pos ) = self::parseGTID( $gtid ); - $gtidInfos[$domain] = $pos; + + foreach ( $this->gtids as $domain => $gtid ) { + list( $domain, $pos, $server ) = self::parseGTID( $gtid ); + + $ignore = false; + // Filter out GTIDs from non-active replication domains + if ( $this->style === self::GTID_MARIA && $this->activeDomain !== null ) { + $ignore |= ( $domain !== $this->activeDomain ); + } + // Likewise for GTIDs from non-active replication origin servers + if ( $this->style === self::GTID_MARIA && $this->activeServerId !== null ) { + $ignore |= ( $server !== $this->activeServerId ); + } elseif ( $this->style === self::GTID_MYSQL && $this->activeServerUUID !== null ) { + $ignore |= ( $server !== $this->activeServerUUID ); + } + + if ( !$ignore ) { + $gtidInfos[$domain] = $pos; + } } return $gtidInfos; } /** - * @param string $gtid - * @return array|null [domain, integer position] or null + * @param string $id GTID + * @return array|null [domain ID or server UUID, sequence number, server ID/UUID] or null */ - protected static function parseGTID( $gtid ) { + protected static function parseGTID( $id ) { $m = []; - if ( preg_match( '!^(\d+)-\d+-(\d+)$!', $gtid, $m ) ) { + if ( preg_match( '!^(\d+)-(\d+)-(\d+)$!', $id, $m ) ) { // MariaDB style: -- - return [ (int)$m[1], (int)$m[2] ]; - } elseif ( preg_match( '!^(\w{8}-\w{4}-\w{4}-\w{4}-\w{12}):(\d+)$!', $gtid, $m ) ) { - // MySQL style: : - return [ $m[1], (int)$m[2] ]; + return [ (int)$m[1], (int)$m[3], (int)$m[2] ]; + } elseif ( preg_match( '!^(\w{8}-\w{4}-\w{4}-\w{4}-\w{12}):(?:\d+-|)(\d+)$!', $id, $m ) ) { + // MySQL style: :- + // Normally, the first number should reflect the point (gtid_purged) where older + // binary logs where purged to save space. When doing comparisons, it may as well + // be 1 in that case. Assume that this is generally the situation. + return [ $m[1], (int)$m[2], $m[1] ]; } return null; @@ -194,16 +279,22 @@ class MySQLMasterPos implements DBMasterPos { /** * @see https://dev.mysql.com/doc/refman/5.7/en/show-master-status.html * @see https://dev.mysql.com/doc/refman/5.7/en/show-slave-status.html - * @return array|bool (binlog, (integer file number, integer position)) or false + * @return array|bool Map of (binlog:, pos:(, )) or false */ protected function getBinlogCoordinates() { - return ( $this->binlog !== null && $this->pos !== null ) - ? [ 'binlog' => $this->binlog, 'pos' => $this->pos ] + return ( $this->binLog !== null && $this->logPos !== null ) + ? [ 'binlog' => $this->binLog, 'pos' => $this->logPos ] : false; } public function serialize() { - return serialize( [ 'position' => $this->__toString(), 'asOfTime' => $this->asOfTime ] ); + return serialize( [ + 'position' => $this->__toString(), + 'activeDomain' => $this->activeDomain, + 'activeServerId' => $this->activeServerId, + 'activeServerUUID' => $this->activeServerUUID, + 'asOfTime' => $this->asOfTime + ] ); } public function unserialize( $serialized ) { @@ -213,5 +304,23 @@ class MySQLMasterPos implements DBMasterPos { } $this->init( $data['position'], $data['asOfTime'] ); + if ( isset( $data['activeDomain'] ) ) { + $this->setActiveDomain( $data['activeDomain'] ); + } + if ( isset( $data['activeServerId'] ) ) { + $this->setActiveOriginServerId( $data['activeServerId'] ); + } + if ( isset( $data['activeServerUUID'] ) ) { + $this->setActiveOriginServerUUID( $data['activeServerUUID'] ); + } + } + + /** + * @return string GTID set or / (e.g db1034-bin.000976/843431247) + */ + public function __toString() { + return $this->gtids + ? implode( ',', $this->gtids ) + : $this->getLogFile() . "/{$this->logPos[self::CORD_EVENT]}"; } } diff --git a/includes/libs/rdbms/database/utils/SavepointPostgres.php b/includes/libs/rdbms/database/utils/SavepointPostgres.php index cf5060e446..edbcdfe141 100644 --- a/includes/libs/rdbms/database/utils/SavepointPostgres.php +++ b/includes/libs/rdbms/database/utils/SavepointPostgres.php @@ -27,6 +27,7 @@ use Psr\Log\LoggerInterface; * Manage savepoints within a transaction * @ingroup Database * @since 1.19 + * @deprecated since 1.31, use IDatabase::startAtomic() and such instead. */ class SavepointPostgres { /** @var DatabasePostgres Establish a savepoint within a transaction */ diff --git a/includes/libs/rdbms/exception/DBError.php b/includes/libs/rdbms/exception/DBError.php index 50238003a2..aad219dd29 100644 --- a/includes/libs/rdbms/exception/DBError.php +++ b/includes/libs/rdbms/exception/DBError.php @@ -35,10 +35,11 @@ class DBError extends RuntimeException { * Construct a database error * @param IDatabase $db Object which threw the error * @param string $error A simple error message to be used for debugging + * @param \Exception|\Throwable|null $prev Previous exception */ - public function __construct( IDatabase $db = null, $error ) { + public function __construct( IDatabase $db = null, $error, $prev = null ) { + parent::__construct( $error, 0, $prev ); $this->db = $db; - parent::__construct( $error ); } } diff --git a/includes/libs/rdbms/exception/DBExpectedError.php b/includes/libs/rdbms/exception/DBExpectedError.php index 406d82c139..7e46420daa 100644 --- a/includes/libs/rdbms/exception/DBExpectedError.php +++ b/includes/libs/rdbms/exception/DBExpectedError.php @@ -33,8 +33,16 @@ class DBExpectedError extends DBError implements MessageSpecifier { /** @var string[] Message parameters */ protected $params; - public function __construct( IDatabase $db = null, $error, array $params = [] ) { - parent::__construct( $db, $error ); + /** + * @param IDatabase|null $db + * @param string $error + * @param array $params + * @param \Exception|\Throwable|null $prev + */ + public function __construct( + IDatabase $db = null, $error, array $params = [], $prev = null + ) { + parent::__construct( $db, $error, $prev ); $this->params = $params; } diff --git a/includes/libs/rdbms/exception/DBTransactionStateError.php b/includes/libs/rdbms/exception/DBTransactionStateError.php new file mode 100644 index 0000000000..3e21848236 --- /dev/null +++ b/includes/libs/rdbms/exception/DBTransactionStateError.php @@ -0,0 +1,28 @@ +remappedTableName( $table ); - $res = $db->query( - sprintf( $q, - $db->addQuotes( $db->getCoreSchema() ), - $db->addQuotes( $table ), - $db->addQuotes( $field ) - ) - ); - $row = $db->fetchObject( $res ); - if ( !$row ) { - return null; + foreach ( $db->getCoreSchemas() as $schema ) { + $res = $db->query( + sprintf( $q, + $db->addQuotes( $schema ), + $db->addQuotes( $table ), + $db->addQuotes( $field ) + ) + ); + $row = $db->fetchObject( $res ); + if ( !$row ) { + continue; + } + $n = new PostgresField; + $n->type = $row->typname; + $n->nullable = ( $row->attnotnull == 'f' ); + $n->name = $field; + $n->tablename = $table; + $n->max_length = $row->attlen; + $n->deferrable = ( $row->deferrable == 't' ); + $n->deferred = ( $row->deferred == 't' ); + $n->conname = $row->conname; + $n->has_default = ( $row->atthasdef === 't' ); + $n->default = $row->adsrc; + + return $n; } - $n = new PostgresField; - $n->type = $row->typname; - $n->nullable = ( $row->attnotnull == 'f' ); - $n->name = $field; - $n->tablename = $table; - $n->max_length = $row->attlen; - $n->deferrable = ( $row->deferrable == 't' ); - $n->deferred = ( $row->deferred == 't' ); - $n->conname = $row->conname; - $n->has_default = ( $row->atthasdef === 't' ); - $n->default = $row->adsrc; - return $n; + return null; } function name() { diff --git a/includes/libs/rdbms/lbfactory/ILBFactory.php b/includes/libs/rdbms/lbfactory/ILBFactory.php index 32d90082d3..45e7cbb756 100644 --- a/includes/libs/rdbms/lbfactory/ILBFactory.php +++ b/includes/libs/rdbms/lbfactory/ILBFactory.php @@ -55,6 +55,7 @@ interface ILBFactory { * - queryLogger: PSR-3 logger instance. [optional] * - perfLogger: PSR-3 logger instance. [optional] * - errorLogger: Callback that takes an Exception and logs it. [optional] + * - deprecationLogger: Callback to log a deprecation warning. [optional] * @throws InvalidArgumentException */ public function __construct( array $conf ); @@ -194,12 +195,22 @@ interface ILBFactory { public function rollbackMasterChanges( $fname = __METHOD__ ); /** - * Check if a transaction round is active + * Check if an explicit transaction round is active * @return bool * @since 1.29 */ public function hasTransactionRound(); + /** + * Check if transaction rounds can be started, committed, or rolled back right now + * + * This can be used as a recusion guard to avoid exceptions in transaction callbacks + * + * @return bool + * @since 1.32 + */ + public function isReadyForRoundOperations(); + /** * Determine if any master connection has pending changes * @return bool diff --git a/includes/libs/rdbms/lbfactory/LBFactory.php b/includes/libs/rdbms/lbfactory/LBFactory.php index bc428ece00..955c28de16 100644 --- a/includes/libs/rdbms/lbfactory/LBFactory.php +++ b/includes/libs/rdbms/lbfactory/LBFactory.php @@ -30,6 +30,7 @@ use EmptyBagOStuff; use WANObjectCache; use Exception; use RuntimeException; +use LogicException; /** * An interface for generating database load balancers @@ -52,6 +53,8 @@ abstract class LBFactory implements ILBFactory { protected $perfLogger; /** @var callable Error logger */ protected $errorLogger; + /** @var callable Deprecation logger */ + protected $deprecationLogger; /** @var BagOStuff */ protected $srvCache; /** @var BagOStuff */ @@ -85,6 +88,16 @@ abstract class LBFactory implements ILBFactory { /** @var string Agent name for query profiling */ protected $agent; + /** @var string One of the ROUND_* class constants */ + private $trxRoundStage = self::ROUND_CURSORY; + + const ROUND_CURSORY = 'cursory'; + const ROUND_BEGINNING = 'within-begin'; + const ROUND_COMMITTING = 'within-commit'; + const ROUND_ROLLING_BACK = 'within-rollback'; + const ROUND_COMMIT_CALLBACKS = 'within-commit-callbacks'; + const ROUND_ROLLBACK_CALLBACKS = 'within-rollback-callbacks'; + private static $loggerFields = [ 'replLogger', 'connLogger', 'queryLogger', 'perfLogger' ]; @@ -109,7 +122,12 @@ abstract class LBFactory implements ILBFactory { $this->errorLogger = isset( $conf['errorLogger'] ) ? $conf['errorLogger'] : function ( Exception $e ) { - trigger_error( E_USER_WARNING, get_class( $e ) . ': ' . $e->getMessage() ); + trigger_error( get_class( $e ) . ': ' . $e->getMessage(), E_USER_WARNING ); + }; + $this->deprecationLogger = isset( $conf['deprecationLogger'] ) + ? $conf['deprecationLogger'] + : function ( $msg ) { + trigger_error( $msg, E_USER_DEPRECATED ); }; $this->profiler = isset( $conf['profiler'] ) ? $conf['profiler'] : null; @@ -124,7 +142,9 @@ abstract class LBFactory implements ILBFactory { 'ChronologyPositionIndex' => isset( $_GET['cpPosIndex'] ) ? $_GET['cpPosIndex'] : null ]; - $this->cliMode = isset( $conf['cliMode'] ) ? $conf['cliMode'] : PHP_SAPI === 'cli'; + $this->cliMode = isset( $conf['cliMode'] ) + ? $conf['cliMode'] + : ( PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg' ); $this->hostname = isset( $conf['hostname'] ) ? $conf['hostname'] : gethostname(); $this->agent = isset( $conf['agent'] ) ? $conf['agent'] : ''; @@ -152,28 +172,28 @@ abstract class LBFactory implements ILBFactory { /** * @see ILBFactory::newMainLB() * @param bool $domain - * @return LoadBalancer + * @return ILoadBalancer */ abstract public function newMainLB( $domain = false ); /** * @see ILBFactory::getMainLB() * @param bool $domain - * @return LoadBalancer + * @return ILoadBalancer */ abstract public function getMainLB( $domain = false ); /** * @see ILBFactory::newExternalLB() * @param string $cluster - * @return LoadBalancer + * @return ILoadBalancer */ abstract public function newExternalLB( $cluster ); /** * @see ILBFactory::getExternalLB() * @param string $cluster - * @return LoadBalancer + * @return ILoadBalancer */ abstract public function getExternalLB( $cluster ); @@ -196,12 +216,15 @@ abstract class LBFactory implements ILBFactory { $this->forEachLBCallMethod( 'flushReplicaSnapshots', [ $fname ] ); } - public function commitAll( $fname = __METHOD__, array $options = [] ) { + final public function commitAll( $fname = __METHOD__, array $options = [] ) { $this->commitMasterChanges( $fname, $options ); - $this->forEachLBCallMethod( 'commitAll', [ $fname ] ); + $this->forEachLBCallMethod( 'flushMasterSnapshots', [ $fname ] ); + $this->forEachLBCallMethod( 'flushReplicaSnapshots', [ $fname ] ); } - public function beginMasterChanges( $fname = __METHOD__ ) { + final public function beginMasterChanges( $fname = __METHOD__ ) { + $this->assertTransactionRoundStage( self::ROUND_CURSORY ); + $this->trxRoundStage = self::ROUND_BEGINNING; if ( $this->trxRoundId !== false ) { throw new DBTransactionError( null, @@ -211,9 +234,12 @@ abstract class LBFactory implements ILBFactory { $this->trxRoundId = $fname; // Set DBO_TRX flags on all appropriate DBs $this->forEachLBCallMethod( 'beginMasterChanges', [ $fname ] ); + $this->trxRoundStage = self::ROUND_CURSORY; } - public function commitMasterChanges( $fname = __METHOD__, array $options = [] ) { + final public function commitMasterChanges( $fname = __METHOD__, array $options = [] ) { + $this->assertTransactionRoundStage( self::ROUND_CURSORY ); + $this->trxRoundStage = self::ROUND_COMMITTING; if ( $this->trxRoundId !== false && $this->trxRoundId !== $fname ) { throw new DBTransactionError( null, @@ -223,7 +249,12 @@ abstract class LBFactory implements ILBFactory { /** @noinspection PhpUnusedLocalVariableInspection */ $scope = $this->getScopedPHPBehaviorForCommit(); // try to ignore client aborts // Run pre-commit callbacks and suppress post-commit callbacks, aborting on failure - $this->forEachLBCallMethod( 'finalizeMasterChanges' ); + do { + $count = 0; // number of callbacks executed this iteration + $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$count ) { + $count += $lb->finalizeMasterChanges(); + } ); + } while ( $count > 0 ); $this->trxRoundId = false; // Perform pre-commit checks, aborting on failure $this->forEachLBCallMethod( 'approveMasterChanges', [ $options ] ); @@ -231,35 +262,56 @@ abstract class LBFactory implements ILBFactory { $this->logIfMultiDbTransaction(); // Actually perform the commit on all master DB connections and revert DBO_TRX $this->forEachLBCallMethod( 'commitMasterChanges', [ $fname ] ); - // Run all post-commit callbacks - /** @var Exception $e */ - $e = null; // first callback exception - $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$e ) { - $ex = $lb->runMasterPostTrxCallbacks( IDatabase::TRIGGER_COMMIT ); - $e = $e ?: $ex; - } ); - // Commit any dangling DBO_TRX transactions from callbacks on one DB to another DB - $this->forEachLBCallMethod( 'commitMasterChanges', [ $fname ] ); + // Run all post-commit callbacks in a separate step + $this->trxRoundStage = self::ROUND_COMMIT_CALLBACKS; + $e = $this->executePostTransactionCallbacks(); + $this->trxRoundStage = self::ROUND_CURSORY; // Throw any last post-commit callback error if ( $e instanceof Exception ) { throw $e; } } - public function rollbackMasterChanges( $fname = __METHOD__ ) { + final public function rollbackMasterChanges( $fname = __METHOD__ ) { + $this->trxRoundStage = self::ROUND_ROLLING_BACK; $this->trxRoundId = false; - $this->forEachLBCallMethod( 'suppressTransactionEndCallbacks' ); + // Actually perform the rollback on all master DB connections and revert DBO_TRX $this->forEachLBCallMethod( 'rollbackMasterChanges', [ $fname ] ); - // Run all post-rollback callbacks - $this->forEachLB( function ( ILoadBalancer $lb ) { - $lb->runMasterPostTrxCallbacks( IDatabase::TRIGGER_ROLLBACK ); + // Run all post-commit callbacks in a separate step + $this->trxRoundStage = self::ROUND_ROLLBACK_CALLBACKS; + $this->executePostTransactionCallbacks(); + $this->trxRoundStage = self::ROUND_CURSORY; + } + + /** + * @return Exception|null + */ + private function executePostTransactionCallbacks() { + // Run all post-commit callbacks until new ones stop getting added + $e = null; // first callback exception + do { + $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$e ) { + $ex = $lb->runMasterTransactionIdleCallbacks(); + $e = $e ?: $ex; + } ); + } while ( $this->hasMasterChanges() ); + // Run all listener callbacks once + $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$e ) { + $ex = $lb->runMasterTransactionListenerCallbacks(); + $e = $e ?: $ex; } ); + + return $e; } public function hasTransactionRound() { return ( $this->trxRoundId !== false ); } + public function isReadyForRoundOperations() { + return ( $this->trxRoundStage === self::ROUND_CURSORY ); + } + /** * Log query info if multi DB transactions are going to be committed now */ @@ -398,7 +450,7 @@ abstract class LBFactory implements ILBFactory { return $this->ticket; } - public function commitAndWaitForReplication( $fname, $ticket, array $opts = [] ) { + final public function commitAndWaitForReplication( $fname, $ticket, array $opts = [] ) { if ( $ticket !== $this->ticket ) { $this->perfLogger->error( __METHOD__ . ": $fname does not have outer scope.\n" . ( new RuntimeException() )->getTraceAsString() ); @@ -499,10 +551,18 @@ abstract class LBFactory implements ILBFactory { } /** - * Base parameters to LoadBalancer::__construct() + * Base parameters to ILoadBalancer::__construct() * @return array */ final protected function baseLoadBalancerParams() { + if ( $this->trxRoundStage === self::ROUND_COMMIT_CALLBACKS ) { + $initStage = ILoadBalancer::STAGE_POSTCOMMIT_CALLBACKS; + } elseif ( $this->trxRoundStage === self::ROUND_ROLLBACK_CALLBACKS ) { + $initStage = ILoadBalancer::STAGE_POSTROLLBACK_CALLBACKS; + } else { + $initStage = null; + } + return [ 'localDomain' => $this->localDomain, 'readOnlyReason' => $this->readOnlyReason, @@ -514,10 +574,16 @@ abstract class LBFactory implements ILBFactory { 'connLogger' => $this->connLogger, 'replLogger' => $this->replLogger, 'errorLogger' => $this->errorLogger, + 'deprecationLogger' => $this->deprecationLogger, 'hostname' => $this->hostname, 'cliMode' => $this->cliMode, 'agent' => $this->agent, - 'chronologyProtector' => $this->getChronologyProtector() + 'chronologyCallback' => function ( ILoadBalancer $lb ) { + // Defer ChronologyProtector construction in case setRequestInfo() ends up + // being called later (but before the first connection attempt) (T192611) + $this->getChronologyProtector()->initLB( $lb ); + }, + 'roundStage' => $initStage ]; } @@ -575,9 +641,25 @@ abstract class LBFactory implements ILBFactory { } public function setRequestInfo( array $info ) { + if ( $this->chronProt ) { + throw new LogicException( 'ChronologyProtector already initialized.' ); + } + $this->requestInfo = $info + $this->requestInfo; } + /** + * @param string $stage + */ + private function assertTransactionRoundStage( $stage ) { + if ( $this->trxRoundStage !== $stage ) { + throw new DBTransactionError( + null, + "Transaction round stage must be '$stage' (not '{$this->trxRoundStage}')" + ); + } + } + /** * Make PHP ignore user aborts/disconnects until the returned * value leaves scope. This returns null and does nothing in CLI mode. diff --git a/includes/libs/rdbms/loadbalancer/ILoadBalancer.php b/includes/libs/rdbms/loadbalancer/ILoadBalancer.php index 767cc49f59..81ce4baeaf 100644 --- a/includes/libs/rdbms/loadbalancer/ILoadBalancer.php +++ b/includes/libs/rdbms/loadbalancer/ILoadBalancer.php @@ -85,8 +85,15 @@ interface ILoadBalancer { const DOMAIN_ANY = ''; /** @var int DB handle should have DBO_TRX disabled and the caller will leave it as such */ + const CONN_TRX_AUTOCOMMIT = 1; + /** @var int Alias for CONN_TRX_AUTOCOMMIT for b/c; deprecated since 1.31 */ const CONN_TRX_AUTO = 1; + /** @var string Manager of ILoadBalancer instances is running post-commit callbacks */ + const STAGE_POSTCOMMIT_CALLBACKS = 'stage-postcommit-callbacks'; + /** @var string Manager of ILoadBalancer instances is running post-rollback callbacks */ + const STAGE_POSTROLLBACK_CALLBACKS = 'stage-postrollback-callbacks'; + /** * Construct a manager of IDatabase connection objects * @@ -99,7 +106,7 @@ interface ILoadBalancer { * - maxLag: Avoid replica DB servers with more lag than this [optional] * - srvCache : BagOStuff object for server cache [optional] * - wanCache : WANObjectCache object [optional] - * - chronologyProtector: ChronologyProtector object [optional] + * - chronologyCallback: Callback to run before the first connection attempt [optional] * - hostname : The name of the current server [optional] * - cliMode: Whether the execution context is a CLI script. [optional] * - profiler : Class name or instance with profileIn()/profileOut() methods. [optional] @@ -109,6 +116,8 @@ interface ILoadBalancer { * - queryLogger: PSR-3 logger instance. [optional] * - perfLogger: PSR-3 logger instance. [optional] * - errorLogger : Callback that takes an Exception and logs it. [optional] + * - deprecationLogger: Callback to log a deprecation warning. [optional] + * - roundStage: STAGE_POSTCOMMIT_* class constant; for internal use [optional] * @throws InvalidArgumentException */ public function __construct( array $params ); @@ -164,23 +173,26 @@ interface ILoadBalancer { /** * Get any open connection to a given server index, local or foreign * + * Use CONN_TRX_AUTOCOMMIT to only look for connections opened with that flag + * * @param int $i Server index or DB_MASTER/DB_REPLICA + * @param int $flags Bitfield of CONN_* class constants * @return Database|bool False if no such connection is open */ - public function getAnyOpenConnection( $i ); + public function getAnyOpenConnection( $i, $flags = 0 ); /** * Get a connection handle by server index * - * The CONN_TRX_AUTO flag is ignored for databases with ATTR_DB_LEVEL_LOCKING + * The CONN_TRX_AUTOCOMMIT flag is ignored for databases with ATTR_DB_LEVEL_LOCKING * (e.g. sqlite) in order to avoid deadlocks. ILoadBalancer::getServerAttributes() * can be used to check such flags beforehand. * - * If the caller uses $domain or sets CONN_TRX_AUTO in $flags, then it must also + * If the caller uses $domain or sets CONN_TRX_AUTOCOMMIT in $flags, then it must also * call ILoadBalancer::reuseConnection() on the handle when finished using it. * In all other cases, this is not necessary, though not harmful either. * - * @param int $i Server index or DB_MASTER/DB_REPLICA + * @param int $i Server index (overrides $groups) or DB_MASTER/DB_REPLICA * @param array|string|bool $groups Query group(s), or false for the generic reader * @param string|bool $domain Domain ID, or false for the current domain * @param int $flags Bitfield of CONN_* class constants @@ -208,7 +220,7 @@ interface ILoadBalancer { * * The handle's methods simply wrap those of a Database handle * - * The CONN_TRX_AUTO flag is ignored for databases with ATTR_DB_LEVEL_LOCKING + * The CONN_TRX_AUTOCOMMIT flag is ignored for databases with ATTR_DB_LEVEL_LOCKING * (e.g. sqlite) in order to avoid deadlocks. ILoadBalancer::getServerAttributes() * can be used to check such flags beforehand. * @@ -217,7 +229,7 @@ interface ILoadBalancer { * @param int $i Server index or DB_MASTER/DB_REPLICA * @param array|string|bool $groups Query group(s), or false for the generic reader * @param string|bool $domain Domain ID, or false for the current domain - * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTO) + * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTOCOMMIT) * @return DBConnRef */ public function getConnectionRef( $i, $groups = [], $domain = false, $flags = 0 ); @@ -227,7 +239,7 @@ interface ILoadBalancer { * * The handle's methods simply wrap those of a Database handle * - * The CONN_TRX_AUTO flag is ignored for databases with ATTR_DB_LEVEL_LOCKING + * The CONN_TRX_AUTOCOMMIT flag is ignored for databases with ATTR_DB_LEVEL_LOCKING * (e.g. sqlite) in order to avoid deadlocks. ILoadBalancer::getServerAttributes() * can be used to check such flags beforehand. * @@ -236,7 +248,7 @@ interface ILoadBalancer { * @param int $i Server index or DB_MASTER/DB_REPLICA * @param array|string|bool $groups Query group(s), or false for the generic reader * @param string|bool $domain Domain ID, or false for the current domain - * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTO) + * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTOCOMMIT) * @return DBConnRef */ public function getLazyConnectionRef( $i, $groups = [], $domain = false, $flags = 0 ); @@ -246,7 +258,7 @@ interface ILoadBalancer { * * The handle's methods simply wrap those of a Database handle * - * The CONN_TRX_AUTO flag is ignored for databases with ATTR_DB_LEVEL_LOCKING + * The CONN_TRX_AUTOCOMMIT flag is ignored for databases with ATTR_DB_LEVEL_LOCKING * (e.g. sqlite) in order to avoid deadlocks. ILoadBalancer::getServerAttributes() * can be used to check such flags beforehand. * @@ -255,7 +267,7 @@ interface ILoadBalancer { * @param int $db Server index or DB_MASTER/DB_REPLICA * @param array|string|bool $groups Query group(s), or false for the generic reader * @param string|bool $domain Domain ID, or false for the current domain - * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTO) + * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTOCOMMIT) * @return MaintainableDBConnRef */ public function getMaintenanceConnectionRef( $db, $groups = [], $domain = false, $flags = 0 ); @@ -266,11 +278,11 @@ interface ILoadBalancer { * The index must be an actual index into the array. If a connection to the server is * already open and not considered an "in use" foreign connection, this simply returns it. * - * Avoid using CONN_TRX_AUTO for databases with ATTR_DB_LEVEL_LOCKING (e.g. sqlite) in + * Avoid using CONN_TRX_AUTOCOMMIT for databases with ATTR_DB_LEVEL_LOCKING (e.g. sqlite) in * order to avoid deadlocks. ILoadBalancer::getServerAttributes() can be used to check * such flags beforehand. * - * If the caller uses $domain or sets CONN_TRX_AUTO in $flags, then it must also + * If the caller uses $domain or sets CONN_TRX_AUTOCOMMIT in $flags, then it must also * call ILoadBalancer::reuseConnection() on the handle when finished using it. * In all other cases, this is not necessary, though not harmful either. * @@ -278,7 +290,7 @@ interface ILoadBalancer { * * @param int $i Server index (does not support DB_MASTER/DB_REPLICA) * @param string|bool $domain Domain ID, or false for the current domain - * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTO) + * @param int $flags Bitfield of CONN_* class constants (e.g. CONN_TRX_AUTOCOMMIT) * @return Database|bool Returns false on errors * @throws DBAccessError */ @@ -371,10 +383,11 @@ interface ILoadBalancer { public function commitAll( $fname = __METHOD__ ); /** - * Perform all pre-commit callbacks that remain part of the atomic transactions - * and disable any post-commit callbacks until runMasterPostTrxCallbacks() + * Run pre-commit callbacks and defer execution of post-commit callbacks * * Use this only for mutli-database commits + * + * @return int Number of pre-commit callbacks run (since 1.32) */ public function finalizeMasterChanges(); @@ -411,14 +424,18 @@ interface ILoadBalancer { public function commitMasterChanges( $fname = __METHOD__ ); /** - * Issue all pending post-COMMIT/ROLLBACK callbacks + * Consume and run all pending post-COMMIT/ROLLBACK callbacks and commit dangling transactions * - * Use this only for mutli-database commits + * @return Exception|null The first exception or null if there were none + */ + public function runMasterTransactionIdleCallbacks(); + + /** + * Run all recurring post-COMMIT/ROLLBACK listener callbacks * - * @param int $type IDatabase::TRIGGER_* constant * @return Exception|null The first exception or null if there were none */ - public function runMasterPostTrxCallbacks( $type ); + public function runMasterTransactionListenerCallbacks(); /** * Issue ROLLBACK only on master, only if queries were done on connection @@ -428,20 +445,20 @@ interface ILoadBalancer { public function rollbackMasterChanges( $fname = __METHOD__ ); /** - * Suppress all pending post-COMMIT/ROLLBACK callbacks - * - * Use this only for mutli-database commits + * Commit all replica DB transactions so as to flush any REPEATABLE-READ or SSI snapshots * - * @return Exception|null The first exception or null if there were none + * @param string $fname Caller name */ - public function suppressTransactionEndCallbacks(); + public function flushReplicaSnapshots( $fname = __METHOD__ ); /** - * Commit all replica DB transactions so as to flush any REPEATABLE-READ or SSI snapshot + * Commit all master DB transactions so as to flush any REPEATABLE-READ or SSI snapshots + * + * An error will be thrown if a connection has pending writes or callbacks * * @param string $fname Caller name */ - public function flushReplicaSnapshots( $fname = __METHOD__ ); + public function flushMasterSnapshots( $fname = __METHOD__ ); /** * @return bool Whether a master connection is already open @@ -449,7 +466,7 @@ interface ILoadBalancer { public function hasMasterConnection(); /** - * Determine if there are pending changes in a transaction by this thread + * Whether there are pending changes or callbacks in a transaction by this thread * @return bool */ public function hasMasterChanges(); diff --git a/includes/libs/rdbms/loadbalancer/LoadBalancer.php b/includes/libs/rdbms/loadbalancer/LoadBalancer.php index 7c1b9d98dd..360be4256e 100644 --- a/includes/libs/rdbms/loadbalancer/LoadBalancer.php +++ b/includes/libs/rdbms/loadbalancer/LoadBalancer.php @@ -59,8 +59,8 @@ class LoadBalancer implements ILoadBalancer { /** @var ILoadMonitor */ private $loadMonitor; - /** @var ChronologyProtector|null */ - private $chronProt; + /** @var callable|null Callback to run before the first connection attempt */ + private $chronologyCallback; /** @var BagOStuff */ private $srvCache; /** @var WANObjectCache */ @@ -111,13 +111,17 @@ class LoadBalancer implements ILoadBalancer { /** @var callable Exception logger */ private $errorLogger; + /** @var callable Deprecation logger */ + private $deprecationLogger; /** @var bool */ private $disabled = false; - /** @var bool */ - private $chronProtInitialized = false; + /** @var bool Whether any connection has been attempted yet */ + private $connectionAttempted = false; /** @var int */ private $maxLag = self::MAX_LAG_DEFAULT; + /** @var string Stage of the current transaction round in the transaction round life-cycle */ + private $trxRoundStage = self::ROUND_CURSORY; /** @var int Warn when this many connection are held */ const CONN_HELD_WARN_THRESHOLD = 10; @@ -137,6 +141,19 @@ class LoadBalancer implements ILoadBalancer { const KEY_FOREIGN_FREE_NOROUND = 'foreignFreeAutoCommit'; const KEY_FOREIGN_INUSE_NOROUND = 'foreignInUseAutoCommit'; + /** @var string Transaction round, explicit or implicit, has not finished writing */ + const ROUND_CURSORY = 'cursory'; + /** @var string Transaction round writes are complete and ready for pre-commit checks */ + const ROUND_FINALIZED = 'finalized'; + /** @var string Transaction round passed final pre-commit checks */ + const ROUND_APPROVED = 'approved'; + /** @var string Transaction round was committed and post-commit callbacks must be run */ + const ROUND_COMMIT_CALLBACKS = 'commit-callbacks'; + /** @var string Transaction round was rolled back and post-rollback callbacks must be run */ + const ROUND_ROLLBACK_CALLBACKS = 'rollback-callbacks'; + /** @var string Transaction round encountered an error */ + const ROUND_ERROR = 'error'; + public function __construct( array $params ) { if ( !isset( $params['servers'] ) ) { throw new InvalidArgumentException( __CLASS__ . ': missing servers parameter' ); @@ -223,6 +240,11 @@ class LoadBalancer implements ILoadBalancer { : function ( Exception $e ) { trigger_error( get_class( $e ) . ': ' . $e->getMessage(), E_USER_WARNING ); }; + $this->deprecationLogger = isset( $params['deprecationLogger'] ) + ? $params['deprecationLogger'] + : function ( $msg ) { + trigger_error( $msg, E_USER_DEPRECATED ); + }; foreach ( [ 'replLogger', 'connLogger', 'queryLogger', 'perfLogger' ] as $key ) { $this->$key = isset( $params[$key] ) ? $params[$key] : new NullLogger(); @@ -236,8 +258,16 @@ class LoadBalancer implements ILoadBalancer { : ( PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg' ); $this->agent = isset( $params['agent'] ) ? $params['agent'] : ''; - if ( isset( $params['chronologyProtector'] ) ) { - $this->chronProt = $params['chronologyProtector']; + if ( isset( $params['chronologyCallback'] ) ) { + $this->chronologyCallback = $params['chronologyCallback']; + } + + if ( isset( $params['roundStage'] ) ) { + if ( $params['roundStage'] === self::STAGE_POSTCOMMIT_CALLBACKS ) { + $this->trxRoundStage = self::ROUND_COMMIT_CALLBACKS; + } elseif ( $params['roundStage'] === self::STAGE_POSTROLLBACK_CALLBACKS ) { + $this->trxRoundStage = self::ROUND_ROLLBACK_CALLBACKS; + } } } @@ -362,7 +392,7 @@ class LoadBalancer implements ILoadBalancer { // Scale the configured load ratios according to each server's load and state $this->getLoadMonitor()->scaleLoads( $loads, $domain ); - // Pick a server to use, accounting for weights, load, lag, and mWaitForPos + // Pick a server to use, accounting for weights, load, lag, and "waitForPos" list( $i, $laggedReplicaMode ) = $this->pickReaderIndex( $loads, $domain ); if ( $i === false ) { // Replica DB connection unsuccessful @@ -372,7 +402,7 @@ class LoadBalancer implements ILoadBalancer { if ( $this->waitForPos && $i != $this->getWriterIndex() ) { // Before any data queries are run, wait for the server to catch up to the // specified position. This is used to improve session consistency. Note that - // when LoadBalancer::waitFor() sets mWaitForPos, the waiting triggers here, + // when LoadBalancer::waitFor() sets "waitForPos", the waiting triggers here, // so update laggedReplicaMode as needed for consistency. if ( !$this->doWait( $i ) ) { $laggedReplicaMode = true; @@ -417,7 +447,7 @@ class LoadBalancer implements ILoadBalancer { } else { $i = false; if ( $this->waitForPos && $this->waitForPos->asOfTime() ) { - // ChronologyProtecter sets mWaitForPos for session consistency. + // "chronologyCallback" sets "waitForPos" for session consistency. // This triggers doWait() after connect, so it's especially good to // avoid lagged servers so as to avoid excessive delay in that method. $ago = microtime( true ) - $this->waitForPos->asOfTime(); @@ -532,7 +562,7 @@ class LoadBalancer implements ILoadBalancer { if ( $this->loads[$i] > 0 ) { $start = microtime( true ); $ok = $this->doWait( $i, true, $timeout ) && $ok; - $timeout -= ( microtime( true ) - $start ); + $timeout -= intval( microtime( true ) - $start ); if ( $timeout <= 0 ) { break; // timeout reached } @@ -559,17 +589,17 @@ class LoadBalancer implements ILoadBalancer { } } - /** - * @param int $i - * @return IDatabase|bool - */ - public function getAnyOpenConnection( $i ) { + public function getAnyOpenConnection( $i, $flags = 0 ) { + $autocommit = ( ( $flags & self::CONN_TRX_AUTOCOMMIT ) == self::CONN_TRX_AUTOCOMMIT ); foreach ( $this->conns as $connsByServer ) { - if ( !empty( $connsByServer[$i] ) ) { - /** @var IDatabase[] $serverConns */ - $serverConns = $connsByServer[$i]; + if ( !isset( $connsByServer[$i] ) ) { + continue; + } - return reset( $serverConns ); + foreach ( $connsByServer[$i] as $conn ) { + if ( !$autocommit || $conn->getLBInfo( 'autoCommitOnly' ) ) { + return $conn; + } } } @@ -584,7 +614,7 @@ class LoadBalancer implements ILoadBalancer { * @return bool */ protected function doWait( $index, $open = false, $timeout = null ) { - $timeout = max( 1, $timeout ?: $this->waitTimeout ); + $timeout = max( 1, intval( $timeout ?: $this->waitTimeout ) ); // Check if we already know that the DB has reached this point $server = $this->getServerName( $index ); @@ -682,7 +712,7 @@ class LoadBalancer implements ILoadBalancer { $domain = false; // local connection requested } - if ( ( $flags & self::CONN_TRX_AUTO ) === self::CONN_TRX_AUTO ) { + if ( ( $flags & self::CONN_TRX_AUTOCOMMIT ) === self::CONN_TRX_AUTOCOMMIT ) { // Assuming all servers are of the same type (or similar), which is overwhelmingly // the case, use the master server information to get the attributes. The information // for $i cannot be used since it might be DB_REPLICA, which might require connection @@ -693,8 +723,9 @@ class LoadBalancer implements ILoadBalancer { // rows (e.g. FOR UPDATE) or (b) make small commits during a larger transactions // to reduce lock contention. None of these apply for sqlite and using separate // connections just causes self-deadlocks. - $flags &= ~self::CONN_TRX_AUTO; - $this->connLogger->info( __METHOD__ . ': ignoring CONN_TRX_AUTO to avoid deadlocks.' ); + $flags &= ~self::CONN_TRX_AUTOCOMMIT; + $this->connLogger->info( __METHOD__ . + ': ignoring CONN_TRX_AUTOCOMMIT to avoid deadlocks.' ); } } @@ -707,7 +738,7 @@ class LoadBalancer implements ILoadBalancer { if ( $i == self::DB_MASTER ) { $i = $this->getWriterIndex(); - } else { + } elseif ( $i == self::DB_REPLICA ) { # Try to find an available server in any the query groups (in order) foreach ( $groups as $group ) { $groupIndex = $this->getReaderIndex( $group, $domain ); @@ -841,18 +872,18 @@ class LoadBalancer implements ILoadBalancer { $domain = false; // local connection requested } - if ( !$this->chronProtInitialized && $this->chronProt ) { + if ( !$this->connectionAttempted && $this->chronologyCallback ) { $this->connLogger->debug( __METHOD__ . ': calling initLB() before first connection.' ); - // Load CP positions before connecting so that doWait() triggers later if needed - $this->chronProtInitialized = true; - $this->chronProt->initLB( $this ); + // Load any "waitFor" positions before connecting so that doWait() is triggered + $this->connectionAttempted = true; + call_user_func( $this->chronologyCallback, $this ); } // Check if an auto-commit connection is being requested. If so, it will not reuse the // main set of DB connections but rather its own pool since: // a) those are usually set to implicitly use transaction rounds via DBO_TRX // b) those must support the use of explicit transaction rounds via beginMasterChanges() - $autoCommit = ( ( $flags & self::CONN_TRX_AUTO ) == self::CONN_TRX_AUTO ); + $autoCommit = ( ( $flags & self::CONN_TRX_AUTOCOMMIT ) == self::CONN_TRX_AUTOCOMMIT ); if ( $domain !== false ) { // Connection is to a foreign domain @@ -930,7 +961,7 @@ class LoadBalancer implements ILoadBalancer { $domainInstance = DatabaseDomain::newFromId( $domain ); $dbName = $domainInstance->getDatabase(); $prefix = $domainInstance->getTablePrefix(); - $autoCommit = ( ( $flags & self::CONN_TRX_AUTO ) == self::CONN_TRX_AUTO ); + $autoCommit = ( ( $flags & self::CONN_TRX_AUTOCOMMIT ) == self::CONN_TRX_AUTOCOMMIT ); if ( $autoCommit ) { $connFreeKey = self::KEY_FOREIGN_FREE_NOROUND; @@ -1067,6 +1098,7 @@ class LoadBalancer implements ILoadBalancer { $server['connLogger'] = $this->connLogger; $server['queryLogger'] = $this->queryLogger; $server['errorLogger'] = $this->errorLogger; + $server['deprecationLogger'] = $this->deprecationLogger; $server['profiler'] = $this->profiler; $server['trxProfiler'] = $this->trxProfiler; // Use the same agent and PHP mode for all DB handles @@ -1121,8 +1153,7 @@ class LoadBalancer implements ILoadBalancer { $context ); - // throws DBConnectionError - $conn->reportConnectionError( "{$this->lastError} ({$context['db_server']})" ); + throw new DBConnectionError( $conn, "{$this->lastError} ({$context['db_server']})" ); } else { // No last connection, probably due to all servers being too busy $this->connLogger->error( @@ -1131,7 +1162,7 @@ class LoadBalancer implements ILoadBalancer { $context ); - // If all servers were busy, mLastError will contain something sensible + // If all servers were busy, "lastError" will contain something sensible throw new DBConnectionError( null, $this->lastError ); } } @@ -1212,7 +1243,7 @@ class LoadBalancer implements ILoadBalancer { } public function closeConnection( IDatabase $conn ) { - $serverIndex = $conn->getLBInfo( 'serverIndex' ); // second index level of mConns + $serverIndex = $conn->getLBInfo( 'serverIndex' ); foreach ( $this->conns as $type => $connsByServer ) { if ( !isset( $connsByServer[$serverIndex] ) ) { continue; @@ -1234,44 +1265,41 @@ class LoadBalancer implements ILoadBalancer { } public function commitAll( $fname = __METHOD__ ) { - $failures = []; - - $restore = ( $this->trxRoundId !== false ); - $this->trxRoundId = false; - $this->forEachOpenConnection( - function ( IDatabase $conn ) use ( $fname, $restore, &$failures ) { - try { - $conn->commit( $fname, $conn::FLUSHING_ALL_PEERS ); - } catch ( DBError $e ) { - call_user_func( $this->errorLogger, $e ); - $failures[] = "{$conn->getServer()}: {$e->getMessage()}"; - } - if ( $restore && $conn->getLBInfo( 'master' ) ) { - $this->undoTransactionRoundFlags( $conn ); - } - } - ); - - if ( $failures ) { - throw new DBExpectedError( - null, - "Commit failed on server(s) " . implode( "\n", array_unique( $failures ) ) - ); - } + $this->commitMasterChanges( $fname ); + $this->flushMasterSnapshots( $fname ); + $this->flushReplicaSnapshots( $fname ); } public function finalizeMasterChanges() { + $this->assertTransactionRoundStage( [ self::ROUND_CURSORY, self::ROUND_FINALIZED ] ); + + $this->trxRoundStage = self::ROUND_ERROR; // "failed" until proven otherwise + // Loop until callbacks stop adding callbacks on other connections + $total = 0; + do { + $count = 0; // callbacks execution attempts + $this->forEachOpenMasterConnection( function ( Database $conn ) use ( &$count ) { + // Run any pre-commit callbacks while leaving the post-commit ones suppressed. + // Any error should cause all (peer) transactions to be rolled back together. + $count += $conn->runOnTransactionPreCommitCallbacks(); + } ); + $total += $count; + } while ( $count > 0 ); + // Defer post-commit callbacks until after COMMIT/ROLLBACK happens on all handles $this->forEachOpenMasterConnection( function ( Database $conn ) { - // Any error should cause all DB transactions to be rolled back together - $conn->setTrxEndCallbackSuppression( false ); - $conn->runOnTransactionPreCommitCallbacks(); - // Defer post-commit callbacks until COMMIT finishes for all DBs $conn->setTrxEndCallbackSuppression( true ); } ); + $this->trxRoundStage = self::ROUND_FINALIZED; + + return $total; } public function approveMasterChanges( array $options ) { + $this->assertTransactionRoundStage( self::ROUND_FINALIZED ); + $limit = isset( $options['maxWriteDuration'] ) ? $options['maxWriteDuration'] : 0; + + $this->trxRoundStage = self::ROUND_ERROR; // "failed" until proven otherwise $this->forEachOpenMasterConnection( function ( IDatabase $conn ) use ( $limit ) { // If atomic sections or explicit transactions are still open, some caller must have // caught an exception but failed to properly rollback any changes. Detect that and @@ -1301,6 +1329,7 @@ class LoadBalancer implements ILoadBalancer { ); } } ); + $this->trxRoundStage = self::ROUND_APPROVED; } public function beginMasterChanges( $fname = __METHOD__ ) { @@ -1310,32 +1339,26 @@ class LoadBalancer implements ILoadBalancer { "$fname: Transaction round '{$this->trxRoundId}' already started." ); } - $this->trxRoundId = $fname; + $this->assertTransactionRoundStage( self::ROUND_CURSORY ); - $failures = []; - $this->forEachOpenMasterConnection( - function ( Database $conn ) use ( $fname, &$failures ) { - $conn->setTrxEndCallbackSuppression( true ); - try { - $conn->flushSnapshot( $fname ); - } catch ( DBError $e ) { - call_user_func( $this->errorLogger, $e ); - $failures[] = "{$conn->getServer()}: {$e->getMessage()}"; - } - $conn->setTrxEndCallbackSuppression( false ); - $this->applyTransactionRoundFlags( $conn ); - } - ); + // Clear any empty transactions (no writes/callbacks) from the implicit round + $this->flushMasterSnapshots( $fname ); - if ( $failures ) { - throw new DBExpectedError( - null, - "$fname: Flush failed on server(s) " . implode( "\n", array_unique( $failures ) ) - ); - } + $this->trxRoundId = $fname; + $this->trxRoundStage = self::ROUND_ERROR; // "failed" until proven otherwise + // Mark applicable handles as participating in this explicit transaction round. + // For each of these handles, any writes and callbacks will be tied to a single + // transaction. The (peer) handles will reject begin()/commit() calls unless they + // are part of an en masse commit or an en masse rollback. + $this->forEachOpenMasterConnection( function ( Database $conn ) { + $this->applyTransactionRoundFlags( $conn ); + } ); + $this->trxRoundStage = self::ROUND_CURSORY; } public function commitMasterChanges( $fname = __METHOD__ ) { + $this->assertTransactionRoundStage( self::ROUND_APPROVED ); + $failures = []; /** @noinspection PhpUnusedLocalVariableInspection */ @@ -1343,62 +1366,125 @@ class LoadBalancer implements ILoadBalancer { $restore = ( $this->trxRoundId !== false ); $this->trxRoundId = false; + $this->trxRoundStage = self::ROUND_ERROR; // "failed" until proven otherwise + // Commit any writes and clear any snapshots as well (callbacks require AUTOCOMMIT). + // Note that callbacks should already be suppressed due to finalizeMasterChanges(). $this->forEachOpenMasterConnection( - function ( IDatabase $conn ) use ( $fname, $restore, &$failures ) { + function ( IDatabase $conn ) use ( $fname, &$failures ) { try { - if ( $conn->writesOrCallbacksPending() ) { - $conn->commit( $fname, $conn::FLUSHING_ALL_PEERS ); - } elseif ( $restore ) { - $conn->flushSnapshot( $fname ); - } + $conn->commit( $fname, $conn::FLUSHING_ALL_PEERS ); } catch ( DBError $e ) { call_user_func( $this->errorLogger, $e ); $failures[] = "{$conn->getServer()}: {$e->getMessage()}"; } - if ( $restore ) { - $this->undoTransactionRoundFlags( $conn ); - } } ); - if ( $failures ) { - throw new DBExpectedError( + throw new DBTransactionError( null, "$fname: Commit failed on server(s) " . implode( "\n", array_unique( $failures ) ) ); } + if ( $restore ) { + // Unmark handles as participating in this explicit transaction round + $this->forEachOpenMasterConnection( function ( Database $conn ) { + $this->undoTransactionRoundFlags( $conn ); + } ); + } + $this->trxRoundStage = self::ROUND_COMMIT_CALLBACKS; } - public function runMasterPostTrxCallbacks( $type ) { + public function runMasterTransactionIdleCallbacks() { + if ( $this->trxRoundStage === self::ROUND_COMMIT_CALLBACKS ) { + $type = IDatabase::TRIGGER_COMMIT; + } elseif ( $this->trxRoundStage === self::ROUND_ROLLBACK_CALLBACKS ) { + $type = IDatabase::TRIGGER_ROLLBACK; + } else { + throw new DBTransactionError( + null, + "Transaction should be in the callback stage (not '{$this->trxRoundStage}')" + ); + } + + $oldStage = $this->trxRoundStage; + $this->trxRoundStage = self::ROUND_ERROR; // "failed" until proven otherwise + + // Now that the COMMIT/ROLLBACK step is over, enable post-commit callback runs + $this->forEachOpenMasterConnection( function ( Database $conn ) { + $conn->setTrxEndCallbackSuppression( false ); + } ); + $e = null; // first exception + // Loop until callbacks stop adding callbacks on other connections + do { + // Run any pending callbacks for each connection... + $count = 0; // callback execution attempts + $this->forEachOpenMasterConnection( + function ( Database $conn ) use ( $type, &$e, &$count ) { + if ( $conn->trxLevel() ) { + return; // retry in the next iteration, after commit() is called + } + try { + $count += $conn->runOnTransactionIdleCallbacks( $type ); + } catch ( Exception $ex ) { + $e = $e ?: $ex; + } + } + ); + // Clear out any active transactions left over from callbacks... + $this->forEachOpenMasterConnection( function ( Database $conn ) use ( &$e ) { + if ( $conn->writesPending() ) { + // A callback from another handle wrote to this one and DBO_TRX is set + $this->queryLogger->warning( __METHOD__ . ": found writes pending." ); + $fnames = implode( ', ', $conn->pendingWriteAndCallbackCallers() ); + $this->queryLogger->warning( + __METHOD__ . ": found writes pending ($fnames).", + [ + 'db_server' => $conn->getServer(), + 'db_name' => $conn->getDBname() + ] + ); + } elseif ( $conn->trxLevel() ) { + // A callback from another handle read from this one and DBO_TRX is set, + // which can easily happen if there is only one DB (no replicas) + $this->queryLogger->debug( __METHOD__ . ": found empty transaction." ); + } + try { + $conn->commit( __METHOD__, $conn::FLUSHING_ALL_PEERS ); + } catch ( Exception $ex ) { + $e = $e ?: $ex; + } + } ); + } while ( $count > 0 ); + + $this->trxRoundStage = $oldStage; + + return $e; + } + + public function runMasterTransactionListenerCallbacks() { + if ( $this->trxRoundStage === self::ROUND_COMMIT_CALLBACKS ) { + $type = IDatabase::TRIGGER_COMMIT; + } elseif ( $this->trxRoundStage === self::ROUND_ROLLBACK_CALLBACKS ) { + $type = IDatabase::TRIGGER_ROLLBACK; + } else { + throw new DBTransactionError( + null, + "Transaction should be in the callback stage (not '{$this->trxRoundStage}')" + ); + } + + $e = null; + + $this->trxRoundStage = self::ROUND_ERROR; // "failed" until proven otherwise $this->forEachOpenMasterConnection( function ( Database $conn ) use ( $type, &$e ) { - $conn->setTrxEndCallbackSuppression( false ); - // Callbacks run in AUTO-COMMIT mode, so make sure no transactions are pending... - if ( $conn->writesPending() ) { - // This happens if onTransactionIdle() callbacks write to *other* handles - // (which already finished their callbacks). Let any callbacks run in the final - // commitMasterChanges() in LBFactory::shutdown(), when the transaction is gone. - $this->queryLogger->warning( __METHOD__ . ": found writes pending." ); - return; - } elseif ( $conn->trxLevel() ) { - // This happens for single-DB setups where DB_REPLICA uses the master DB, - // thus leaving an implicit read-only transaction open at this point. It - // also happens if onTransactionIdle() callbacks leave implicit transactions - // open on *other* DBs (which is slightly improper). Let these COMMIT on the - // next call to commitMasterChanges(), possibly in LBFactory::shutdown(). - return; - } - try { - $conn->runOnTransactionIdleCallbacks( $type ); - } catch ( Exception $ex ) { - $e = $e ?: $ex; - } try { $conn->runTransactionListenerCallbacks( $type ); } catch ( Exception $ex ) { $e = $e ?: $ex; } } ); + $this->trxRoundStage = self::ROUND_CURSORY; return $e; } @@ -1406,20 +1492,37 @@ class LoadBalancer implements ILoadBalancer { public function rollbackMasterChanges( $fname = __METHOD__ ) { $restore = ( $this->trxRoundId !== false ); $this->trxRoundId = false; - $this->forEachOpenMasterConnection( - function ( IDatabase $conn ) use ( $fname, $restore ) { - $conn->rollback( $fname, $conn::FLUSHING_ALL_PEERS ); - if ( $restore ) { - $this->undoTransactionRoundFlags( $conn ); - } - } - ); + $this->trxRoundStage = self::ROUND_ERROR; // "failed" until proven otherwise + $this->forEachOpenMasterConnection( function ( IDatabase $conn ) use ( $fname ) { + $conn->rollback( $fname, $conn::FLUSHING_ALL_PEERS ); + } ); + if ( $restore ) { + // Unmark handles as participating in this explicit transaction round + $this->forEachOpenMasterConnection( function ( Database $conn ) { + $this->undoTransactionRoundFlags( $conn ); + } ); + } + $this->trxRoundStage = self::ROUND_ROLLBACK_CALLBACKS; } - public function suppressTransactionEndCallbacks() { - $this->forEachOpenMasterConnection( function ( Database $conn ) { - $conn->setTrxEndCallbackSuppression( true ); - } ); + /** + * @param string|string[] $stage + */ + private function assertTransactionRoundStage( $stage ) { + $stages = (array)$stage; + + if ( !in_array( $this->trxRoundStage, $stages, true ) ) { + $stageList = implode( + '/', + array_map( function ( $v ) { + return "'$v'"; + }, $stages ) + ); + throw new DBTransactionError( + null, + "Transaction round stage must be $stageList (not '{$this->trxRoundStage}')" + ); + } } /** @@ -1429,9 +1532,9 @@ class LoadBalancer implements ILoadBalancer { * transaction rounds and remain in auto-commit mode. Such behavior might be desired * when a DB server is used for something like simple key/value storage. * - * @param IDatabase $conn + * @param Database $conn */ - private function applyTransactionRoundFlags( IDatabase $conn ) { + private function applyTransactionRoundFlags( Database $conn ) { if ( $conn->getLBInfo( 'autoCommitOnly' ) ) { return; // transaction rounds do not apply to these connections } @@ -1448,9 +1551,9 @@ class LoadBalancer implements ILoadBalancer { } /** - * @param IDatabase $conn + * @param Database $conn */ - private function undoTransactionRoundFlags( IDatabase $conn ) { + private function undoTransactionRoundFlags( Database $conn ) { if ( $conn->getLBInfo( 'autoCommitOnly' ) ) { return; // transaction rounds do not apply to these connections } @@ -1465,11 +1568,25 @@ class LoadBalancer implements ILoadBalancer { } public function flushReplicaSnapshots( $fname = __METHOD__ ) { - $this->forEachOpenReplicaConnection( function ( IDatabase $conn ) { - $conn->flushSnapshot( __METHOD__ ); + $this->forEachOpenReplicaConnection( function ( IDatabase $conn ) use ( $fname ) { + $conn->flushSnapshot( $fname ); } ); } + public function flushMasterSnapshots( $fname = __METHOD__ ) { + $this->forEachOpenMasterConnection( function ( IDatabase $conn ) use ( $fname ) { + $conn->flushSnapshot( $fname ); + } ); + } + + /** + * @return string + * @since 1.32 + */ + public function getTransactionRoundStage() { + return $this->trxRoundStage; + } + public function hasMasterConnection() { return $this->isOpen( $this->getWriterIndex() ); } @@ -1525,15 +1642,6 @@ class LoadBalancer implements ILoadBalancer { return $this->laggedReplicaMode; } - /** - * @param bool $domain - * @return bool - * @deprecated 1.28; use getLaggedReplicaMode() - */ - public function getLaggedSlaveMode( $domain = false ) { - return $this->getLaggedReplicaMode( $domain ); - } - public function laggedReplicaUsed() { return $this->laggedReplicaMode; } diff --git a/includes/libs/rdbms/loadmonitor/LoadMonitor.php b/includes/libs/rdbms/loadmonitor/LoadMonitor.php index 3372839648..c7f807a0bc 100644 --- a/includes/libs/rdbms/loadmonitor/LoadMonitor.php +++ b/includes/libs/rdbms/loadmonitor/LoadMonitor.php @@ -156,13 +156,14 @@ class LoadMonitor implements ILoadMonitor { continue; } - $conn = $this->parent->getAnyOpenConnection( $i ); - if ( $conn && !$conn->trxLevel() ) { - # Handles with open transactions are avoided since they might be subject - # to REPEATABLE-READ snapshots, which could affect the lag estimate query. + # Handles with open transactions are avoided since they might be subject + # to REPEATABLE-READ snapshots, which could affect the lag estimate query. + $flags = ILoadBalancer::CONN_TRX_AUTOCOMMIT; + $conn = $this->parent->getAnyOpenConnection( $i, $flags ); + if ( $conn ) { $close = false; // already open } else { - $conn = $this->parent->openConnection( $i, '' ); + $conn = $this->parent->openConnection( $i, ILoadBalancer::DOMAIN_ANY, $flags ); $close = true; // new connection } diff --git a/includes/logging/LogEntry.php b/includes/logging/LogEntry.php index c672ef7fc9..31c196af7f 100644 --- a/includes/logging/LogEntry.php +++ b/includes/logging/LogEntry.php @@ -156,7 +156,8 @@ abstract class LogEntryBase implements LogEntry { } /** - * This class wraps around database result row. + * A value class to process existing log entries. In other words, this class caches a log + * entry from the database and provides an immutable object-oriented representation of it. * * @since 1.19 */ @@ -361,6 +362,10 @@ class DatabaseLogEntry extends LogEntryBase { } } +/** + * A subclass of DatabaseLogEntry for objects constructed from entries in the + * recentchanges table (rather than the logging table). + */ class RCDatabaseLogEntry extends DatabaseLogEntry { public function getId() { @@ -425,7 +430,7 @@ class RCDatabaseLogEntry extends DatabaseLogEntry { } /** - * Class for creating log entries manually, to inject them into the database. + * Class for creating new log entries and inserting them into the database. * * @since 1.19 */ @@ -776,19 +781,12 @@ class ManualLogEntry extends LogEntryBase { $tags = []; } $rc->addTags( $tags ); - $rc->save( 'pleasedontudp' ); + $rc->save( $rc::SEND_NONE ); } if ( $to === 'udp' || $to === 'rcandudp' ) { $rc->notifyRCFeeds(); } - - // Log the autopatrol if the log entry is patrollable - if ( $this->getIsPatrollable() && - $rc->getAttribute( 'rc_patrolled' ) === 2 - ) { - PatrolLog::record( $rc, true, $this->getPerformer() ); - } } }, DeferredUpdates::POSTSEND, diff --git a/includes/logging/LogEventsList.php b/includes/logging/LogEventsList.php index 93a81cff8e..9e4a630bd0 100644 --- a/includes/logging/LogEventsList.php +++ b/includes/logging/LogEventsList.php @@ -97,7 +97,7 @@ class LogEventsList extends ContextSource { * @param array|string $types * @param string $user * @param string $page - * @param string $pattern + * @param bool $pattern * @param int|string $year Use 0 to start with no year preselected. * @param int|string $month A month in the 1..12 range. Use 0 to start with no month * preselected. @@ -105,7 +105,7 @@ class LogEventsList extends ContextSource { * @param string $tagFilter Tag to select by default * @param string $action */ - public function showOptions( $types = [], $user = '', $page = '', $pattern = '', $year = 0, + public function showOptions( $types = [], $user = '', $page = '', $pattern = false, $year = 0, $month = 0, $filter = null, $tagFilter = '', $action = null ) { global $wgScript, $wgMiserMode; @@ -289,7 +289,7 @@ class LogEventsList extends ContextSource { } /** - * @param string $pattern + * @param bool $pattern * @return string Checkbox */ private function getTitlePattern( $pattern ) { diff --git a/includes/logging/LogFormatter.php b/includes/logging/LogFormatter.php index bc0491f4cf..0ffe691d65 100644 --- a/includes/logging/LogFormatter.php +++ b/includes/logging/LogFormatter.php @@ -108,6 +108,12 @@ class LogFormatter { */ private $linkRenderer; + /** + * @see LogFormatter::getMessageParameters + * @var array + */ + protected $parsedParameters; + protected function __construct( LogEntry $entry ) { $this->entry = $entry; $this->context = RequestContext::getMain(); diff --git a/includes/logging/LogPager.php b/includes/logging/LogPager.php index 24fdfb0195..84653b1ba1 100644 --- a/includes/logging/LogPager.php +++ b/includes/logging/LogPager.php @@ -36,8 +36,8 @@ class LogPager extends ReverseChronologicalPager { /** @var string|Title Events limited to those about Title when set */ private $title = ''; - /** @var string */ - private $pattern = ''; + /** @var bool */ + private $pattern = false; /** @var string */ private $typeCGI = ''; @@ -59,16 +59,17 @@ class LogPager extends ReverseChronologicalPager { * @param string|array $types Log types to show * @param string $performer The user who made the log entries * @param string|Title $title The page title the log entries are for - * @param string $pattern Do a prefix search rather than an exact title match + * @param bool $pattern Do a prefix search rather than an exact title match * @param array $conds Extra conditions for the query * @param int|bool $year The year to start from. Default: false * @param int|bool $month The month to start from. Default: false * @param string $tagFilter Tag * @param string $action Specific action (subtype) requested + * @param int $logId Log entry ID, to limit to a single log entry. */ public function __construct( $list, $types = [], $performer = '', $title = '', - $pattern = '', $conds = [], $year = false, $month = false, $tagFilter = '', - $action = '' + $pattern = false, $conds = [], $year = false, $month = false, $tagFilter = '', + $action = '', $logId = false ) { parent::__construct( $list->getContext() ); $this->mConds = $conds; @@ -81,6 +82,7 @@ class LogPager extends ReverseChronologicalPager { $this->limitAction( $action ); $this->getDateCond( $year, $month ); $this->mTagFilter = $tagFilter; + $this->limitLogId( $logId ); $this->mDb = wfGetDB( DB_REPLICA, 'logpager' ); } @@ -192,7 +194,7 @@ class LogPager extends ReverseChronologicalPager { * (For the block and rights logs, this is a user page.) * * @param string|Title $page Title name - * @param string $pattern + * @param bool $pattern * @return void */ private function limitTitle( $page, $pattern ) { @@ -278,6 +280,17 @@ class LogPager extends ReverseChronologicalPager { } } + /** + * Limit to the (single) specified log ID. + * @param int $logId The log entry ID. + */ + protected function limitLogId( $logId ) { + if ( !$logId ) { + return; + } + $this->mConds['log_id'] = $logId; + } + /** * Constructs the most part of the query. Extra conditions are sprinkled in * all over this class. @@ -385,6 +398,9 @@ class LogPager extends ReverseChronologicalPager { return $this->title; } + /** + * @return bool + */ public function getPattern() { return $this->pattern; } diff --git a/includes/logging/PatrolLog.php b/includes/logging/PatrolLog.php index d1de2cd3ae..9b2e098298 100644 --- a/includes/logging/PatrolLog.php +++ b/includes/logging/PatrolLog.php @@ -27,6 +27,7 @@ * logs of patrol events */ class PatrolLog { + /** * Record a log event for a change being patrolled * @@ -39,10 +40,8 @@ class PatrolLog { * @return bool */ public static function record( $rc, $auto = false, User $user = null, $tags = null ) { - global $wgLogAutopatrol; - - // do not log autopatrolled edits if setting disables it - if ( $auto && !$wgLogAutopatrol ) { + // Do not log autopatrol actions: T184485 + if ( $auto ) { return false; } diff --git a/includes/mail/MailAddress.php b/includes/mail/MailAddress.php index 1686bbb048..b9d94143d0 100644 --- a/includes/mail/MailAddress.php +++ b/includes/mail/MailAddress.php @@ -88,8 +88,9 @@ class MailAddress { global $wgEnotifUseRealName; $name = ( $wgEnotifUseRealName && $this->realName !== '' ) ? $this->realName : $this->name; $quoted = UserMailer::quotedPrintable( $name ); - if ( strpos( $quoted, '.' ) !== false || strpos( $quoted, ',' ) !== false ) { - $quoted = '"' . $quoted . '"'; + // Must only be quoted if string does not use =? encoding (T191931) + if ( $quoted === $name ) { + $quoted = '"' . addslashes( $quoted ) . '"'; } return "$quoted <{$this->address}>"; } else { diff --git a/includes/mail/UserMailer.php b/includes/mail/UserMailer.php index fb0f2f99f0..7b48ad095a 100644 --- a/includes/mail/UserMailer.php +++ b/includes/mail/UserMailer.php @@ -189,6 +189,46 @@ class UserMailer { return self::sendInternal( $to, $from, $subject, $body, $options ); } + /** + * Whether the PEAR Mail_mime library is usable. This will + * try and load it if it is not already. + * + * @return bool + */ + private static function isMailMimeUsable() { + static $usable = null; + if ( $usable === null ) { + // If the class is not already loaded, and it's in the include path, + // try requiring it. + if ( !class_exists( 'Mail_mime' ) && stream_resolve_include_path( 'Mail/mime.php' ) ) { + require_once 'Mail/mime.php'; + } + $usable = class_exists( 'Mail_mime' ); + } + + return $usable; + } + + /** + * Whether the PEAR Mail library is usable. This will + * try and load it if it is not already. + * + * @return bool + */ + private static function isMailUsable() { + static $usable = null; + if ( $usable === null ) { + // If the class is not already loaded, and it's in the include path, + // try requiring it. + if ( !class_exists( 'Mail' ) && stream_resolve_include_path( 'Mail.php' ) ) { + require_once 'Mail.php'; + } + $usable = class_exists( 'Mail' ); + } + + return $usable; + } + /** * Helper function fo UserMailer::send() which does the actual sending. It expects a $to * list which the UserMailerSplitTo hook would not split further. @@ -296,15 +336,12 @@ class UserMailer { if ( is_array( $body ) ) { // we are sending a multipart message wfDebug( "Assembling multipart mime email\n" ); - if ( !stream_resolve_include_path( 'Mail/mime.php' ) ) { + if ( !self::isMailMimeUsable() ) { wfDebug( "PEAR Mail_Mime package is not installed. Falling back to text email.\n" ); // remove the html body for text email fall back $body = $body['text']; } else { - // Check if pear/mail_mime is already loaded (via composer) - if ( !class_exists( 'Mail_mime' ) ) { - require_once 'Mail/mime.php'; - } + // pear/mail_mime is already loaded by this point if ( wfIsWindows() ) { $body['text'] = str_replace( "\n", "\r\n", $body['text'] ); $body['html'] = str_replace( "\n", "\r\n", $body['html'] ); @@ -352,12 +389,8 @@ class UserMailer { if ( is_array( $wgSMTP ) ) { // Check if pear/mail is already loaded (via composer) - if ( !class_exists( 'Mail' ) ) { - // PEAR MAILER - if ( !stream_resolve_include_path( 'Mail.php' ) ) { - throw new MWException( 'PEAR mail package is not installed' ); - } - require_once 'Mail.php'; + if ( !self::isMailUsable() ) { + throw new MWException( 'PEAR mail package is not installed' ); } Wikimedia\suppressWarnings(); diff --git a/includes/media/BMP.php b/includes/media/BMP.php deleted file mode 100644 index 0229ac11b7..0000000000 --- a/includes/media/BMP.php +++ /dev/null @@ -1,80 +0,0 @@ -getMimeType(); - $interlace = isset( $params['interlace'] ) && $params['interlace'] - && isset( $wgMaxInterlacingAreas[$mimeType] ) - && $this->getImageArea( $image ) <= $wgMaxInterlacingAreas[$mimeType]; - $params['interlace'] = $interlace; - return true; - } - - /** - * Get ImageMagick subsampling factors for the target JPEG pixel format. - * - * @param string $pixelFormat one of 'yuv444', 'yuv422', 'yuv420' - * @return array of string keys - */ - protected function imageMagickSubsampling( $pixelFormat ) { - switch ( $pixelFormat ) { - case 'yuv444': - return [ '1x1', '1x1', '1x1' ]; - case 'yuv422': - return [ '2x1', '1x1', '1x1' ]; - case 'yuv420': - return [ '2x2', '1x1', '1x1' ]; - default: - throw new MWException( 'Invalid pixel format for JPEG output' ); - } - } - - /** - * Transform an image using ImageMagick - * - * @param File $image File associated with this thumbnail - * @param array $params Array with scaler params - * - * @return MediaTransformError|bool Error object if error occurred, false (=no error) otherwise - */ - protected function transformImageMagick( $image, $params ) { - # use ImageMagick - global $wgSharpenReductionThreshold, $wgSharpenParameter, $wgMaxAnimatedGifArea, - $wgImageMagickTempDir, $wgImageMagickConvertCommand, $wgJpegPixelFormat; - - $quality = []; - $sharpen = []; - $scene = false; - $animation_pre = []; - $animation_post = []; - $decoderHint = []; - $subsampling = []; - - if ( $params['mimeType'] == 'image/jpeg' ) { - $qualityVal = isset( $params['quality'] ) ? (string)$params['quality'] : null; - $quality = [ '-quality', $qualityVal ?: '80' ]; // 80% - if ( $params['interlace'] ) { - $animation_post = [ '-interlace', 'JPEG' ]; - } - # Sharpening, see T8193 - if ( ( $params['physicalWidth'] + $params['physicalHeight'] ) - / ( $params['srcWidth'] + $params['srcHeight'] ) - < $wgSharpenReductionThreshold - ) { - $sharpen = [ '-sharpen', $wgSharpenParameter ]; - } - if ( version_compare( $this->getMagickVersion(), "6.5.6" ) >= 0 ) { - // JPEG decoder hint to reduce memory, available since IM 6.5.6-2 - $decoderHint = [ '-define', "jpeg:size={$params['physicalDimensions']}" ]; - } - if ( $wgJpegPixelFormat ) { - $factors = $this->imageMagickSubsampling( $wgJpegPixelFormat ); - $subsampling = [ '-sampling-factor', implode( ',', $factors ) ]; - } - } elseif ( $params['mimeType'] == 'image/png' ) { - $quality = [ '-quality', '95' ]; // zlib 9, adaptive filtering - if ( $params['interlace'] ) { - $animation_post = [ '-interlace', 'PNG' ]; - } - } elseif ( $params['mimeType'] == 'image/webp' ) { - $quality = [ '-quality', '95' ]; // zlib 9, adaptive filtering - } elseif ( $params['mimeType'] == 'image/gif' ) { - if ( $this->getImageArea( $image ) > $wgMaxAnimatedGifArea ) { - // Extract initial frame only; we're so big it'll - // be a total drag. :P - $scene = 0; - } elseif ( $this->isAnimatedImage( $image ) ) { - // Coalesce is needed to scale animated GIFs properly (T3017). - $animation_pre = [ '-coalesce' ]; - // We optimize the output, but -optimize is broken, - // use optimizeTransparency instead (T13822) - if ( version_compare( $this->getMagickVersion(), "6.3.5" ) >= 0 ) { - $animation_post = [ '-fuzz', '5%', '-layers', 'optimizeTransparency' ]; - } - } - if ( $params['interlace'] && version_compare( $this->getMagickVersion(), "6.3.4" ) >= 0 - && !$this->isAnimatedImage( $image ) ) { // interlacing animated GIFs is a bad idea - $animation_post[] = '-interlace'; - $animation_post[] = 'GIF'; - } - } elseif ( $params['mimeType'] == 'image/x-xcf' ) { - // Before merging layers, we need to set the background - // to be transparent to preserve alpha, as -layers merge - // merges all layers on to a canvas filled with the - // background colour. After merging we reset the background - // to be white for the default background colour setting - // in the PNG image (which is used in old IE) - $animation_pre = [ - '-background', 'transparent', - '-layers', 'merge', - '-background', 'white', - ]; - Wikimedia\suppressWarnings(); - $xcfMeta = unserialize( $image->getMetadata() ); - Wikimedia\restoreWarnings(); - if ( $xcfMeta - && isset( $xcfMeta['colorType'] ) - && $xcfMeta['colorType'] === 'greyscale-alpha' - && version_compare( $this->getMagickVersion(), "6.8.9-3" ) < 0 - ) { - // T68323 - Greyscale images not rendered properly. - // So only take the "red" channel. - $channelOnly = [ '-channel', 'R', '-separate' ]; - $animation_pre = array_merge( $animation_pre, $channelOnly ); - } - } - - // Use one thread only, to avoid deadlock bugs on OOM - $env = [ 'OMP_NUM_THREADS' => 1 ]; - if ( strval( $wgImageMagickTempDir ) !== '' ) { - $env['MAGICK_TMPDIR'] = $wgImageMagickTempDir; - } - - $rotation = isset( $params['disableRotation'] ) ? 0 : $this->getRotation( $image ); - list( $width, $height ) = $this->extractPreRotationDimensions( $params, $rotation ); - - $cmd = call_user_func_array( 'wfEscapeShellArg', array_merge( - [ $wgImageMagickConvertCommand ], - $quality, - // Specify white background color, will be used for transparent images - // in Internet Explorer/Windows instead of default black. - [ '-background', 'white' ], - $decoderHint, - [ $this->escapeMagickInput( $params['srcPath'], $scene ) ], - $animation_pre, - // For the -thumbnail option a "!" is needed to force exact size, - // or ImageMagick may decide your ratio is wrong and slice off - // a pixel. - [ '-thumbnail', "{$width}x{$height}!" ], - // Add the source url as a comment to the thumb, but don't add the flag if there's no comment - ( $params['comment'] !== '' - ? [ '-set', 'comment', $this->escapeMagickProperty( $params['comment'] ) ] - : [] ), - // T108616: Avoid exposure of local file path - [ '+set', 'Thumb::URI' ], - [ '-depth', 8 ], - $sharpen, - [ '-rotate', "-$rotation" ], - $subsampling, - $animation_post, - [ $this->escapeMagickOutput( $params['dstPath'] ) ] ) ); - - wfDebug( __METHOD__ . ": running ImageMagick: $cmd\n" ); - $retval = 0; - $err = wfShellExecWithStderr( $cmd, $retval, $env ); - - if ( $retval !== 0 ) { - $this->logErrorForExternalProcess( $retval, $err, $cmd ); - - return $this->getMediaTransformError( $params, "$err\nError code: $retval" ); - } - - return false; # No error - } - - /** - * Transform an image using the Imagick PHP extension - * - * @param File $image File associated with this thumbnail - * @param array $params Array with scaler params - * - * @return MediaTransformError Error|bool object if error occurred, false (=no error) otherwise - */ - protected function transformImageMagickExt( $image, $params ) { - global $wgSharpenReductionThreshold, $wgSharpenParameter, $wgMaxAnimatedGifArea, - $wgJpegPixelFormat; - - try { - $im = new Imagick(); - $im->readImage( $params['srcPath'] ); - - if ( $params['mimeType'] == 'image/jpeg' ) { - // Sharpening, see T8193 - if ( ( $params['physicalWidth'] + $params['physicalHeight'] ) - / ( $params['srcWidth'] + $params['srcHeight'] ) - < $wgSharpenReductionThreshold - ) { - // Hack, since $wgSharpenParameter is written specifically for the command line convert - list( $radius, $sigma ) = explode( 'x', $wgSharpenParameter ); - $im->sharpenImage( $radius, $sigma ); - } - $qualityVal = isset( $params['quality'] ) ? (string)$params['quality'] : null; - $im->setCompressionQuality( $qualityVal ?: 80 ); - if ( $params['interlace'] ) { - $im->setInterlaceScheme( Imagick::INTERLACE_JPEG ); - } - if ( $wgJpegPixelFormat ) { - $factors = $this->imageMagickSubsampling( $wgJpegPixelFormat ); - $im->setSamplingFactors( $factors ); - } - } elseif ( $params['mimeType'] == 'image/png' ) { - $im->setCompressionQuality( 95 ); - if ( $params['interlace'] ) { - $im->setInterlaceScheme( Imagick::INTERLACE_PNG ); - } - } elseif ( $params['mimeType'] == 'image/gif' ) { - if ( $this->getImageArea( $image ) > $wgMaxAnimatedGifArea ) { - // Extract initial frame only; we're so big it'll - // be a total drag. :P - $im->setImageScene( 0 ); - } elseif ( $this->isAnimatedImage( $image ) ) { - // Coalesce is needed to scale animated GIFs properly (T3017). - $im = $im->coalesceImages(); - } - // GIF interlacing is only available since 6.3.4 - $v = Imagick::getVersion(); - preg_match( '/ImageMagick ([0-9]+\.[0-9]+\.[0-9]+)/', $v['versionString'], $v ); - - if ( $params['interlace'] && version_compare( $v[1], '6.3.4' ) >= 0 ) { - $im->setInterlaceScheme( Imagick::INTERLACE_GIF ); - } - } - - $rotation = isset( $params['disableRotation'] ) ? 0 : $this->getRotation( $image ); - list( $width, $height ) = $this->extractPreRotationDimensions( $params, $rotation ); - - $im->setImageBackgroundColor( new ImagickPixel( 'white' ) ); - - // Call Imagick::thumbnailImage on each frame - foreach ( $im as $i => $frame ) { - if ( !$frame->thumbnailImage( $width, $height, /* fit */ false ) ) { - return $this->getMediaTransformError( $params, "Error scaling frame $i" ); - } - } - $im->setImageDepth( 8 ); - - if ( $rotation ) { - if ( !$im->rotateImage( new ImagickPixel( 'white' ), 360 - $rotation ) ) { - return $this->getMediaTransformError( $params, "Error rotating $rotation degrees" ); - } - } - - if ( $this->isAnimatedImage( $image ) ) { - wfDebug( __METHOD__ . ": Writing animated thumbnail\n" ); - // This is broken somehow... can't find out how to fix it - $result = $im->writeImages( $params['dstPath'], true ); - } else { - $result = $im->writeImage( $params['dstPath'] ); - } - if ( !$result ) { - return $this->getMediaTransformError( $params, - "Unable to write thumbnail to {$params['dstPath']}" ); - } - } catch ( ImagickException $e ) { - return $this->getMediaTransformError( $params, $e->getMessage() ); - } - - return false; - } - - /** - * Transform an image using a custom command - * - * @param File $image File associated with this thumbnail - * @param array $params Array with scaler params - * - * @return MediaTransformError Error|bool object if error occurred, false (=no error) otherwise - */ - protected function transformCustom( $image, $params ) { - # Use a custom convert command - global $wgCustomConvertCommand; - - # Variables: %s %d %w %h - $src = wfEscapeShellArg( $params['srcPath'] ); - $dst = wfEscapeShellArg( $params['dstPath'] ); - $cmd = $wgCustomConvertCommand; - $cmd = str_replace( '%s', $src, str_replace( '%d', $dst, $cmd ) ); # Filenames - $cmd = str_replace( '%h', wfEscapeShellArg( $params['physicalHeight'] ), - str_replace( '%w', wfEscapeShellArg( $params['physicalWidth'] ), $cmd ) ); # Size - wfDebug( __METHOD__ . ": Running custom convert command $cmd\n" ); - $retval = 0; - $err = wfShellExecWithStderr( $cmd, $retval ); - - if ( $retval !== 0 ) { - $this->logErrorForExternalProcess( $retval, $err, $cmd ); - - return $this->getMediaTransformError( $params, $err ); - } - - return false; # No error - } - - /** - * Transform an image using the built in GD library - * - * @param File $image File associated with this thumbnail - * @param array $params Array with scaler params - * - * @return MediaTransformError|bool Error object if error occurred, false (=no error) otherwise - */ - protected function transformGd( $image, $params ) { - # Use PHP's builtin GD library functions. - # First find out what kind of file this is, and select the correct - # input routine for this. - - $typemap = [ - 'image/gif' => [ 'imagecreatefromgif', 'palette', false, 'imagegif' ], - 'image/jpeg' => [ 'imagecreatefromjpeg', 'truecolor', true, - [ __CLASS__, 'imageJpegWrapper' ] ], - 'image/png' => [ 'imagecreatefrompng', 'bits', false, 'imagepng' ], - 'image/vnd.wap.wbmp' => [ 'imagecreatefromwbmp', 'palette', false, 'imagewbmp' ], - 'image/xbm' => [ 'imagecreatefromxbm', 'palette', false, 'imagexbm' ], - ]; - - if ( !isset( $typemap[$params['mimeType']] ) ) { - $err = 'Image type not supported'; - wfDebug( "$err\n" ); - $errMsg = wfMessage( 'thumbnail_image-type' )->text(); - - return $this->getMediaTransformError( $params, $errMsg ); - } - list( $loader, $colorStyle, $useQuality, $saveType ) = $typemap[$params['mimeType']]; - - if ( !function_exists( $loader ) ) { - $err = "Incomplete GD library configuration: missing function $loader"; - wfDebug( "$err\n" ); - $errMsg = wfMessage( 'thumbnail_gd-library', $loader )->text(); - - return $this->getMediaTransformError( $params, $errMsg ); - } - - if ( !file_exists( $params['srcPath'] ) ) { - $err = "File seems to be missing: {$params['srcPath']}"; - wfDebug( "$err\n" ); - $errMsg = wfMessage( 'thumbnail_image-missing', $params['srcPath'] )->text(); - - return $this->getMediaTransformError( $params, $errMsg ); - } - - if ( filesize( $params['srcPath'] ) === 0 ) { - $err = "Image file size seems to be zero."; - wfDebug( "$err\n" ); - $errMsg = wfMessage( 'thumbnail_image-size-zero', $params['srcPath'] )->text(); - - return $this->getMediaTransformError( $params, $errMsg ); - } - - $src_image = call_user_func( $loader, $params['srcPath'] ); - - $rotation = function_exists( 'imagerotate' ) && !isset( $params['disableRotation'] ) ? - $this->getRotation( $image ) : - 0; - list( $width, $height ) = $this->extractPreRotationDimensions( $params, $rotation ); - $dst_image = imagecreatetruecolor( $width, $height ); - - // Initialise the destination image to transparent instead of - // the default solid black, to support PNG and GIF transparency nicely - $background = imagecolorallocate( $dst_image, 0, 0, 0 ); - imagecolortransparent( $dst_image, $background ); - imagealphablending( $dst_image, false ); - - if ( $colorStyle == 'palette' ) { - // Don't resample for paletted GIF images. - // It may just uglify them, and completely breaks transparency. - imagecopyresized( $dst_image, $src_image, - 0, 0, 0, 0, - $width, $height, - imagesx( $src_image ), imagesy( $src_image ) ); - } else { - imagecopyresampled( $dst_image, $src_image, - 0, 0, 0, 0, - $width, $height, - imagesx( $src_image ), imagesy( $src_image ) ); - } - - if ( $rotation % 360 != 0 && $rotation % 90 == 0 ) { - $rot_image = imagerotate( $dst_image, $rotation, 0 ); - imagedestroy( $dst_image ); - $dst_image = $rot_image; - } - - imagesavealpha( $dst_image, true ); - - $funcParams = [ $dst_image, $params['dstPath'] ]; - if ( $useQuality && isset( $params['quality'] ) ) { - $funcParams[] = $params['quality']; - } - call_user_func_array( $saveType, $funcParams ); - - imagedestroy( $dst_image ); - imagedestroy( $src_image ); - - return false; # No error - } - - /** - * Callback for transformGd when transforming jpeg images. - */ - // FIXME: transformImageMagick() & transformImageMagickExt() uses JPEG quality 80, here it's 95? - static function imageJpegWrapper( $dst_image, $thumbPath, $quality = 95 ) { - imageinterlace( $dst_image ); - imagejpeg( $dst_image, $thumbPath, $quality ); - } - - /** - * Returns whether the current scaler supports rotation (im and gd do) - * - * @return bool - */ - public function canRotate() { - $scaler = $this->getScalerType( null, false ); - switch ( $scaler ) { - case 'im': - # ImageMagick supports autorotation - return true; - case 'imext': - # Imagick::rotateImage - return true; - case 'gd': - # GD's imagerotate function is used to rotate images, but not - # all precompiled PHP versions have that function - return function_exists( 'imagerotate' ); - default: - # Other scalers don't support rotation - return false; - } - } - - /** - * @see $wgEnableAutoRotation - * @return bool Whether auto rotation is enabled - */ - public function autoRotateEnabled() { - global $wgEnableAutoRotation; - - if ( $wgEnableAutoRotation === null ) { - // Only enable auto-rotation when we actually can - return $this->canRotate(); - } - - return $wgEnableAutoRotation; - } - - /** - * @param File $file - * @param array $params Rotate parameters. - * 'rotation' clockwise rotation in degrees, allowed are multiples of 90 - * @since 1.21 - * @return bool|MediaTransformError - */ - public function rotate( $file, $params ) { - global $wgImageMagickConvertCommand; - - $rotation = ( $params['rotation'] + $this->getRotation( $file ) ) % 360; - $scene = false; - - $scaler = $this->getScalerType( null, false ); - switch ( $scaler ) { - case 'im': - $cmd = wfEscapeShellArg( $wgImageMagickConvertCommand ) . " " . - wfEscapeShellArg( $this->escapeMagickInput( $params['srcPath'], $scene ) ) . - " -rotate " . wfEscapeShellArg( "-$rotation" ) . " " . - wfEscapeShellArg( $this->escapeMagickOutput( $params['dstPath'] ) ); - wfDebug( __METHOD__ . ": running ImageMagick: $cmd\n" ); - $retval = 0; - $err = wfShellExecWithStderr( $cmd, $retval ); - if ( $retval !== 0 ) { - $this->logErrorForExternalProcess( $retval, $err, $cmd ); - - return new MediaTransformError( 'thumbnail_error', 0, 0, $err ); - } - - return false; - case 'imext': - $im = new Imagick(); - $im->readImage( $params['srcPath'] ); - if ( !$im->rotateImage( new ImagickPixel( 'white' ), 360 - $rotation ) ) { - return new MediaTransformError( 'thumbnail_error', 0, 0, - "Error rotating $rotation degrees" ); - } - $result = $im->writeImage( $params['dstPath'] ); - if ( !$result ) { - return new MediaTransformError( 'thumbnail_error', 0, 0, - "Unable to write image to {$params['dstPath']}" ); - } - - return false; - default: - return new MediaTransformError( 'thumbnail_error', 0, 0, - "$scaler rotation not implemented" ); - } - } -} diff --git a/includes/media/BitmapHandler.php b/includes/media/BitmapHandler.php new file mode 100644 index 0000000000..cda037c16d --- /dev/null +++ b/includes/media/BitmapHandler.php @@ -0,0 +1,607 @@ +getMimeType(); + $interlace = isset( $params['interlace'] ) && $params['interlace'] + && isset( $wgMaxInterlacingAreas[$mimeType] ) + && $this->getImageArea( $image ) <= $wgMaxInterlacingAreas[$mimeType]; + $params['interlace'] = $interlace; + return true; + } + + /** + * Get ImageMagick subsampling factors for the target JPEG pixel format. + * + * @param string $pixelFormat one of 'yuv444', 'yuv422', 'yuv420' + * @return array of string keys + */ + protected function imageMagickSubsampling( $pixelFormat ) { + switch ( $pixelFormat ) { + case 'yuv444': + return [ '1x1', '1x1', '1x1' ]; + case 'yuv422': + return [ '2x1', '1x1', '1x1' ]; + case 'yuv420': + return [ '2x2', '1x1', '1x1' ]; + default: + throw new MWException( 'Invalid pixel format for JPEG output' ); + } + } + + /** + * Transform an image using ImageMagick + * + * @param File $image File associated with this thumbnail + * @param array $params Array with scaler params + * + * @return MediaTransformError|bool Error object if error occurred, false (=no error) otherwise + */ + protected function transformImageMagick( $image, $params ) { + # use ImageMagick + global $wgSharpenReductionThreshold, $wgSharpenParameter, $wgMaxAnimatedGifArea, + $wgImageMagickTempDir, $wgImageMagickConvertCommand, $wgJpegPixelFormat, + $wgJpegQuality; + + $quality = []; + $sharpen = []; + $scene = false; + $animation_pre = []; + $animation_post = []; + $decoderHint = []; + $subsampling = []; + + if ( $params['mimeType'] == 'image/jpeg' ) { + $qualityVal = isset( $params['quality'] ) ? (string)$params['quality'] : null; + $quality = [ '-quality', $qualityVal ?: (string)$wgJpegQuality ]; // 80% by default + if ( $params['interlace'] ) { + $animation_post = [ '-interlace', 'JPEG' ]; + } + # Sharpening, see T8193 + if ( ( $params['physicalWidth'] + $params['physicalHeight'] ) + / ( $params['srcWidth'] + $params['srcHeight'] ) + < $wgSharpenReductionThreshold + ) { + $sharpen = [ '-sharpen', $wgSharpenParameter ]; + } + if ( version_compare( $this->getMagickVersion(), "6.5.6" ) >= 0 ) { + // JPEG decoder hint to reduce memory, available since IM 6.5.6-2 + $decoderHint = [ '-define', "jpeg:size={$params['physicalDimensions']}" ]; + } + if ( $wgJpegPixelFormat ) { + $factors = $this->imageMagickSubsampling( $wgJpegPixelFormat ); + $subsampling = [ '-sampling-factor', implode( ',', $factors ) ]; + } + } elseif ( $params['mimeType'] == 'image/png' ) { + $quality = [ '-quality', '95' ]; // zlib 9, adaptive filtering + if ( $params['interlace'] ) { + $animation_post = [ '-interlace', 'PNG' ]; + } + } elseif ( $params['mimeType'] == 'image/webp' ) { + $quality = [ '-quality', '95' ]; // zlib 9, adaptive filtering + } elseif ( $params['mimeType'] == 'image/gif' ) { + if ( $this->getImageArea( $image ) > $wgMaxAnimatedGifArea ) { + // Extract initial frame only; we're so big it'll + // be a total drag. :P + $scene = 0; + } elseif ( $this->isAnimatedImage( $image ) ) { + // Coalesce is needed to scale animated GIFs properly (T3017). + $animation_pre = [ '-coalesce' ]; + // We optimize the output, but -optimize is broken, + // use optimizeTransparency instead (T13822) + if ( version_compare( $this->getMagickVersion(), "6.3.5" ) >= 0 ) { + $animation_post = [ '-fuzz', '5%', '-layers', 'optimizeTransparency' ]; + } + } + if ( $params['interlace'] && version_compare( $this->getMagickVersion(), "6.3.4" ) >= 0 + && !$this->isAnimatedImage( $image ) ) { // interlacing animated GIFs is a bad idea + $animation_post[] = '-interlace'; + $animation_post[] = 'GIF'; + } + } elseif ( $params['mimeType'] == 'image/x-xcf' ) { + // Before merging layers, we need to set the background + // to be transparent to preserve alpha, as -layers merge + // merges all layers on to a canvas filled with the + // background colour. After merging we reset the background + // to be white for the default background colour setting + // in the PNG image (which is used in old IE) + $animation_pre = [ + '-background', 'transparent', + '-layers', 'merge', + '-background', 'white', + ]; + Wikimedia\suppressWarnings(); + $xcfMeta = unserialize( $image->getMetadata() ); + Wikimedia\restoreWarnings(); + if ( $xcfMeta + && isset( $xcfMeta['colorType'] ) + && $xcfMeta['colorType'] === 'greyscale-alpha' + && version_compare( $this->getMagickVersion(), "6.8.9-3" ) < 0 + ) { + // T68323 - Greyscale images not rendered properly. + // So only take the "red" channel. + $channelOnly = [ '-channel', 'R', '-separate' ]; + $animation_pre = array_merge( $animation_pre, $channelOnly ); + } + } + + // Use one thread only, to avoid deadlock bugs on OOM + $env = [ 'OMP_NUM_THREADS' => 1 ]; + if ( strval( $wgImageMagickTempDir ) !== '' ) { + $env['MAGICK_TMPDIR'] = $wgImageMagickTempDir; + } + + $rotation = isset( $params['disableRotation'] ) ? 0 : $this->getRotation( $image ); + list( $width, $height ) = $this->extractPreRotationDimensions( $params, $rotation ); + + $cmd = call_user_func_array( 'wfEscapeShellArg', array_merge( + [ $wgImageMagickConvertCommand ], + $quality, + // Specify white background color, will be used for transparent images + // in Internet Explorer/Windows instead of default black. + [ '-background', 'white' ], + $decoderHint, + [ $this->escapeMagickInput( $params['srcPath'], $scene ) ], + $animation_pre, + // For the -thumbnail option a "!" is needed to force exact size, + // or ImageMagick may decide your ratio is wrong and slice off + // a pixel. + [ '-thumbnail', "{$width}x{$height}!" ], + // Add the source url as a comment to the thumb, but don't add the flag if there's no comment + ( $params['comment'] !== '' + ? [ '-set', 'comment', $this->escapeMagickProperty( $params['comment'] ) ] + : [] ), + // T108616: Avoid exposure of local file path + [ '+set', 'Thumb::URI' ], + [ '-depth', 8 ], + $sharpen, + [ '-rotate', "-$rotation" ], + $subsampling, + $animation_post, + [ $this->escapeMagickOutput( $params['dstPath'] ) ] ) ); + + wfDebug( __METHOD__ . ": running ImageMagick: $cmd\n" ); + $retval = 0; + $err = wfShellExecWithStderr( $cmd, $retval, $env ); + + if ( $retval !== 0 ) { + $this->logErrorForExternalProcess( $retval, $err, $cmd ); + + return $this->getMediaTransformError( $params, "$err\nError code: $retval" ); + } + + return false; # No error + } + + /** + * Transform an image using the Imagick PHP extension + * + * @param File $image File associated with this thumbnail + * @param array $params Array with scaler params + * + * @return MediaTransformError Error|bool object if error occurred, false (=no error) otherwise + */ + protected function transformImageMagickExt( $image, $params ) { + global $wgSharpenReductionThreshold, $wgSharpenParameter, $wgMaxAnimatedGifArea, + $wgJpegPixelFormat, $wgJpegQuality; + + try { + $im = new Imagick(); + $im->readImage( $params['srcPath'] ); + + if ( $params['mimeType'] == 'image/jpeg' ) { + // Sharpening, see T8193 + if ( ( $params['physicalWidth'] + $params['physicalHeight'] ) + / ( $params['srcWidth'] + $params['srcHeight'] ) + < $wgSharpenReductionThreshold + ) { + // Hack, since $wgSharpenParameter is written specifically for the command line convert + list( $radius, $sigma ) = explode( 'x', $wgSharpenParameter ); + $im->sharpenImage( $radius, $sigma ); + } + $qualityVal = isset( $params['quality'] ) ? (string)$params['quality'] : null; + $im->setCompressionQuality( $qualityVal ?: $wgJpegQuality ); + if ( $params['interlace'] ) { + $im->setInterlaceScheme( Imagick::INTERLACE_JPEG ); + } + if ( $wgJpegPixelFormat ) { + $factors = $this->imageMagickSubsampling( $wgJpegPixelFormat ); + $im->setSamplingFactors( $factors ); + } + } elseif ( $params['mimeType'] == 'image/png' ) { + $im->setCompressionQuality( 95 ); + if ( $params['interlace'] ) { + $im->setInterlaceScheme( Imagick::INTERLACE_PNG ); + } + } elseif ( $params['mimeType'] == 'image/gif' ) { + if ( $this->getImageArea( $image ) > $wgMaxAnimatedGifArea ) { + // Extract initial frame only; we're so big it'll + // be a total drag. :P + $im->setImageScene( 0 ); + } elseif ( $this->isAnimatedImage( $image ) ) { + // Coalesce is needed to scale animated GIFs properly (T3017). + $im = $im->coalesceImages(); + } + // GIF interlacing is only available since 6.3.4 + $v = Imagick::getVersion(); + preg_match( '/ImageMagick ([0-9]+\.[0-9]+\.[0-9]+)/', $v['versionString'], $v ); + + if ( $params['interlace'] && version_compare( $v[1], '6.3.4' ) >= 0 ) { + $im->setInterlaceScheme( Imagick::INTERLACE_GIF ); + } + } + + $rotation = isset( $params['disableRotation'] ) ? 0 : $this->getRotation( $image ); + list( $width, $height ) = $this->extractPreRotationDimensions( $params, $rotation ); + + $im->setImageBackgroundColor( new ImagickPixel( 'white' ) ); + + // Call Imagick::thumbnailImage on each frame + foreach ( $im as $i => $frame ) { + if ( !$frame->thumbnailImage( $width, $height, /* fit */ false ) ) { + return $this->getMediaTransformError( $params, "Error scaling frame $i" ); + } + } + $im->setImageDepth( 8 ); + + if ( $rotation ) { + if ( !$im->rotateImage( new ImagickPixel( 'white' ), 360 - $rotation ) ) { + return $this->getMediaTransformError( $params, "Error rotating $rotation degrees" ); + } + } + + if ( $this->isAnimatedImage( $image ) ) { + wfDebug( __METHOD__ . ": Writing animated thumbnail\n" ); + // This is broken somehow... can't find out how to fix it + $result = $im->writeImages( $params['dstPath'], true ); + } else { + $result = $im->writeImage( $params['dstPath'] ); + } + if ( !$result ) { + return $this->getMediaTransformError( $params, + "Unable to write thumbnail to {$params['dstPath']}" ); + } + } catch ( ImagickException $e ) { + return $this->getMediaTransformError( $params, $e->getMessage() ); + } + + return false; + } + + /** + * Transform an image using a custom command + * + * @param File $image File associated with this thumbnail + * @param array $params Array with scaler params + * + * @return MediaTransformError Error|bool object if error occurred, false (=no error) otherwise + */ + protected function transformCustom( $image, $params ) { + # Use a custom convert command + global $wgCustomConvertCommand; + + # Variables: %s %d %w %h + $src = wfEscapeShellArg( $params['srcPath'] ); + $dst = wfEscapeShellArg( $params['dstPath'] ); + $cmd = $wgCustomConvertCommand; + $cmd = str_replace( '%s', $src, str_replace( '%d', $dst, $cmd ) ); # Filenames + $cmd = str_replace( '%h', wfEscapeShellArg( $params['physicalHeight'] ), + str_replace( '%w', wfEscapeShellArg( $params['physicalWidth'] ), $cmd ) ); # Size + wfDebug( __METHOD__ . ": Running custom convert command $cmd\n" ); + $retval = 0; + $err = wfShellExecWithStderr( $cmd, $retval ); + + if ( $retval !== 0 ) { + $this->logErrorForExternalProcess( $retval, $err, $cmd ); + + return $this->getMediaTransformError( $params, $err ); + } + + return false; # No error + } + + /** + * Transform an image using the built in GD library + * + * @param File $image File associated with this thumbnail + * @param array $params Array with scaler params + * + * @return MediaTransformError|bool Error object if error occurred, false (=no error) otherwise + */ + protected function transformGd( $image, $params ) { + # Use PHP's builtin GD library functions. + # First find out what kind of file this is, and select the correct + # input routine for this. + + $typemap = [ + 'image/gif' => [ 'imagecreatefromgif', 'palette', false, 'imagegif' ], + 'image/jpeg' => [ 'imagecreatefromjpeg', 'truecolor', true, + [ __CLASS__, 'imageJpegWrapper' ] ], + 'image/png' => [ 'imagecreatefrompng', 'bits', false, 'imagepng' ], + 'image/vnd.wap.wbmp' => [ 'imagecreatefromwbmp', 'palette', false, 'imagewbmp' ], + 'image/xbm' => [ 'imagecreatefromxbm', 'palette', false, 'imagexbm' ], + ]; + + if ( !isset( $typemap[$params['mimeType']] ) ) { + $err = 'Image type not supported'; + wfDebug( "$err\n" ); + $errMsg = wfMessage( 'thumbnail_image-type' )->text(); + + return $this->getMediaTransformError( $params, $errMsg ); + } + list( $loader, $colorStyle, $useQuality, $saveType ) = $typemap[$params['mimeType']]; + + if ( !function_exists( $loader ) ) { + $err = "Incomplete GD library configuration: missing function $loader"; + wfDebug( "$err\n" ); + $errMsg = wfMessage( 'thumbnail_gd-library', $loader )->text(); + + return $this->getMediaTransformError( $params, $errMsg ); + } + + if ( !file_exists( $params['srcPath'] ) ) { + $err = "File seems to be missing: {$params['srcPath']}"; + wfDebug( "$err\n" ); + $errMsg = wfMessage( 'thumbnail_image-missing', $params['srcPath'] )->text(); + + return $this->getMediaTransformError( $params, $errMsg ); + } + + if ( filesize( $params['srcPath'] ) === 0 ) { + $err = "Image file size seems to be zero."; + wfDebug( "$err\n" ); + $errMsg = wfMessage( 'thumbnail_image-size-zero', $params['srcPath'] )->text(); + + return $this->getMediaTransformError( $params, $errMsg ); + } + + $src_image = call_user_func( $loader, $params['srcPath'] ); + + $rotation = function_exists( 'imagerotate' ) && !isset( $params['disableRotation'] ) ? + $this->getRotation( $image ) : + 0; + list( $width, $height ) = $this->extractPreRotationDimensions( $params, $rotation ); + $dst_image = imagecreatetruecolor( $width, $height ); + + // Initialise the destination image to transparent instead of + // the default solid black, to support PNG and GIF transparency nicely + $background = imagecolorallocate( $dst_image, 0, 0, 0 ); + imagecolortransparent( $dst_image, $background ); + imagealphablending( $dst_image, false ); + + if ( $colorStyle == 'palette' ) { + // Don't resample for paletted GIF images. + // It may just uglify them, and completely breaks transparency. + imagecopyresized( $dst_image, $src_image, + 0, 0, 0, 0, + $width, $height, + imagesx( $src_image ), imagesy( $src_image ) ); + } else { + imagecopyresampled( $dst_image, $src_image, + 0, 0, 0, 0, + $width, $height, + imagesx( $src_image ), imagesy( $src_image ) ); + } + + if ( $rotation % 360 != 0 && $rotation % 90 == 0 ) { + $rot_image = imagerotate( $dst_image, $rotation, 0 ); + imagedestroy( $dst_image ); + $dst_image = $rot_image; + } + + imagesavealpha( $dst_image, true ); + + $funcParams = [ $dst_image, $params['dstPath'] ]; + if ( $useQuality && isset( $params['quality'] ) ) { + $funcParams[] = $params['quality']; + } + call_user_func_array( $saveType, $funcParams ); + + imagedestroy( $dst_image ); + imagedestroy( $src_image ); + + return false; # No error + } + + /** + * Callback for transformGd when transforming jpeg images. + * + * @param resource $dst_image Image resource of the original image + * @param string $thumbPath File path to write the thumbnail image to + * @param int|null $quality Quality of the thumbnail from 1-100, + * or null to use default quality. + */ + static function imageJpegWrapper( $dst_image, $thumbPath, $quality = null ) { + global $wgJpegQuality; + + if ( $quality === null ) { + $quality = $wgJpegQuality; + } + + imageinterlace( $dst_image ); + imagejpeg( $dst_image, $thumbPath, $quality ); + } + + /** + * Returns whether the current scaler supports rotation (im and gd do) + * + * @return bool + */ + public function canRotate() { + $scaler = $this->getScalerType( null, false ); + switch ( $scaler ) { + case 'im': + # ImageMagick supports autorotation + return true; + case 'imext': + # Imagick::rotateImage + return true; + case 'gd': + # GD's imagerotate function is used to rotate images, but not + # all precompiled PHP versions have that function + return function_exists( 'imagerotate' ); + default: + # Other scalers don't support rotation + return false; + } + } + + /** + * @see $wgEnableAutoRotation + * @return bool Whether auto rotation is enabled + */ + public function autoRotateEnabled() { + global $wgEnableAutoRotation; + + if ( $wgEnableAutoRotation === null ) { + // Only enable auto-rotation when we actually can + return $this->canRotate(); + } + + return $wgEnableAutoRotation; + } + + /** + * @param File $file + * @param array $params Rotate parameters. + * 'rotation' clockwise rotation in degrees, allowed are multiples of 90 + * @since 1.21 + * @return bool|MediaTransformError + */ + public function rotate( $file, $params ) { + global $wgImageMagickConvertCommand; + + $rotation = ( $params['rotation'] + $this->getRotation( $file ) ) % 360; + $scene = false; + + $scaler = $this->getScalerType( null, false ); + switch ( $scaler ) { + case 'im': + $cmd = wfEscapeShellArg( $wgImageMagickConvertCommand ) . " " . + wfEscapeShellArg( $this->escapeMagickInput( $params['srcPath'], $scene ) ) . + " -rotate " . wfEscapeShellArg( "-$rotation" ) . " " . + wfEscapeShellArg( $this->escapeMagickOutput( $params['dstPath'] ) ); + wfDebug( __METHOD__ . ": running ImageMagick: $cmd\n" ); + $retval = 0; + $err = wfShellExecWithStderr( $cmd, $retval ); + if ( $retval !== 0 ) { + $this->logErrorForExternalProcess( $retval, $err, $cmd ); + + return new MediaTransformError( 'thumbnail_error', 0, 0, $err ); + } + + return false; + case 'imext': + $im = new Imagick(); + $im->readImage( $params['srcPath'] ); + if ( !$im->rotateImage( new ImagickPixel( 'white' ), 360 - $rotation ) ) { + return new MediaTransformError( 'thumbnail_error', 0, 0, + "Error rotating $rotation degrees" ); + } + $result = $im->writeImage( $params['dstPath'] ); + if ( !$result ) { + return new MediaTransformError( 'thumbnail_error', 0, 0, + "Unable to write image to {$params['dstPath']}" ); + } + + return false; + default: + return new MediaTransformError( 'thumbnail_error', 0, 0, + "$scaler rotation not implemented" ); + } + } +} diff --git a/includes/media/BitmapHandler_ClientOnly.php b/includes/media/BitmapHandler_ClientOnly.php new file mode 100644 index 0000000000..fa5b0a61c6 --- /dev/null +++ b/includes/media/BitmapHandler_ClientOnly.php @@ -0,0 +1,59 @@ +normaliseParams( $image, $params ) ) { + return new TransformParameterError( $params ); + } + + return new ThumbnailImage( $image, $image->getUrl(), $image->getLocalRefPath(), $params ); + } +} diff --git a/includes/media/Bitmap_ClientOnly.php b/includes/media/Bitmap_ClientOnly.php deleted file mode 100644 index fa5b0a61c6..0000000000 --- a/includes/media/Bitmap_ClientOnly.php +++ /dev/null @@ -1,59 +0,0 @@ -normaliseParams( $image, $params ) ) { - return new TransformParameterError( $params ); - } - - return new ThumbnailImage( $image, $image->getUrl(), $image->getLocalRefPath(), $params ); - } -} diff --git a/includes/media/BmpHandler.php b/includes/media/BmpHandler.php new file mode 100644 index 0000000000..0229ac11b7 --- /dev/null +++ b/includes/media/BmpHandler.php @@ -0,0 +1,80 @@ +getSize() > static::EXPENSIVE_SIZE_LIMIT; - } - - /** - * @param File $file - * @return bool - */ - public function isMultiPage( $file ) { - return true; - } - - /** - * @return array - */ - public function getParamMap() { - return [ - 'img_width' => 'width', - 'img_page' => 'page', - ]; - } - - /** - * @param string $name - * @param mixed $value - * @return bool - */ - public function validateParam( $name, $value ) { - if ( $name === 'page' && trim( $value ) !== (string)intval( $value ) ) { - // Extra junk on the end of page, probably actually a caption - // e.g. [[File:Foo.djvu|thumb|Page 3 of the document shows foo]] - return false; - } - if ( in_array( $name, [ 'width', 'height', 'page' ] ) ) { - if ( $value <= 0 ) { - return false; - } else { - return true; - } - } else { - return false; - } - } - - /** - * @param array $params - * @return bool|string - */ - public function makeParamString( $params ) { - $page = isset( $params['page'] ) ? $params['page'] : 1; - if ( !isset( $params['width'] ) ) { - return false; - } - - return "page{$page}-{$params['width']}px"; - } - - /** - * @param string $str - * @return array|bool - */ - public function parseParamString( $str ) { - $m = false; - if ( preg_match( '/^page(\d+)-(\d+)px$/', $str, $m ) ) { - return [ 'width' => $m[2], 'page' => $m[1] ]; - } else { - return false; - } - } - - /** - * @param array $params - * @return array - */ - function getScriptParams( $params ) { - return [ - 'width' => $params['width'], - 'page' => $params['page'], - ]; - } - - /** - * @param File $image - * @param string $dstPath - * @param string $dstUrl - * @param array $params - * @param int $flags - * @return MediaTransformError|ThumbnailImage|TransformParameterError - */ - function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) { - global $wgDjvuRenderer, $wgDjvuPostProcessor; - - if ( !$this->normaliseParams( $image, $params ) ) { - return new TransformParameterError( $params ); - } - $width = $params['width']; - $height = $params['height']; - $page = $params['page']; - - if ( $flags & self::TRANSFORM_LATER ) { - $params = [ - 'width' => $width, - 'height' => $height, - 'page' => $page - ]; - - return new ThumbnailImage( $image, $dstUrl, $dstPath, $params ); - } - - if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) { - return new MediaTransformError( - 'thumbnail_error', - $width, - $height, - wfMessage( 'thumbnail_dest_directory' ) - ); - } - - // Get local copy source for shell scripts - // Thumbnail extraction is very inefficient for large files. - // Provide a way to pool count limit the number of downloaders. - if ( $image->getSize() >= 1e7 ) { // 10MB - $work = new PoolCounterWorkViaCallback( 'GetLocalFileCopy', sha1( $image->getName() ), - [ - 'doWork' => function () use ( $image ) { - return $image->getLocalRefPath(); - } - ] - ); - $srcPath = $work->execute(); - } else { - $srcPath = $image->getLocalRefPath(); - } - - if ( $srcPath === false ) { // Failed to get local copy - wfDebugLog( 'thumbnail', - sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"', - wfHostname(), $image->getName() ) ); - - return new MediaTransformError( 'thumbnail_error', - $params['width'], $params['height'], - wfMessage( 'filemissing' ) - ); - } - - # Use a subshell (brackets) to aggregate stderr from both pipeline commands - # before redirecting it to the overall stdout. This works in both Linux and Windows XP. - $cmd = '(' . wfEscapeShellArg( - $wgDjvuRenderer, - "-format=ppm", - "-page={$page}", - "-size={$params['physicalWidth']}x{$params['physicalHeight']}", - $srcPath ); - if ( $wgDjvuPostProcessor ) { - $cmd .= " | {$wgDjvuPostProcessor}"; - } - $cmd .= ' > ' . wfEscapeShellArg( $dstPath ) . ') 2>&1'; - wfDebug( __METHOD__ . ": $cmd\n" ); - $retval = ''; - $err = wfShellExec( $cmd, $retval ); - - $removed = $this->removeBadFile( $dstPath, $retval ); - if ( $retval != 0 || $removed ) { - $this->logErrorForExternalProcess( $retval, $err, $cmd ); - return new MediaTransformError( 'thumbnail_error', $width, $height, $err ); - } else { - $params = [ - 'width' => $width, - 'height' => $height, - 'page' => $page - ]; - - return new ThumbnailImage( $image, $dstUrl, $dstPath, $params ); - } - } - - /** - * Cache an instance of DjVuImage in an Image object, return that instance - * - * @param File|FSFile $image - * @param string $path - * @return DjVuImage - */ - function getDjVuImage( $image, $path ) { - if ( !$image ) { - $deja = new DjVuImage( $path ); - } elseif ( !isset( $image->dejaImage ) ) { - $deja = $image->dejaImage = new DjVuImage( $path ); - } else { - $deja = $image->dejaImage; - } - - return $deja; - } - - /** - * Get metadata, unserializing it if neccessary. - * - * @param File $file The DjVu file in question - * @return string XML metadata as a string. - * @throws MWException - */ - private function getUnserializedMetadata( File $file ) { - $metadata = $file->getMetadata(); - if ( substr( $metadata, 0, 3 ) === 'djvuTextTree ) ) { - return $image->djvuTextTree; - } - if ( !$gettext && isset( $image->dejaMetaTree ) ) { - return $image->dejaMetaTree; - } - - $metadata = $this->getUnserializedMetadata( $image ); - if ( !$this->isMetadataValid( $image, $metadata ) ) { - wfDebug( "DjVu XML metadata is invalid or missing, should have been fixed in upgradeRow\n" ); - - return false; - } - - $trees = $this->extractTreesFromMetadata( $metadata ); - $image->djvuTextTree = $trees['TextTree']; - $image->dejaMetaTree = $trees['MetaTree']; - - if ( $gettext ) { - return $image->djvuTextTree; - } else { - return $image->dejaMetaTree; - } - } - - /** - * Extracts metadata and text trees from metadata XML in string form - * @param string $metadata XML metadata as a string - * @return array - */ - protected function extractTreesFromMetadata( $metadata ) { - Wikimedia\suppressWarnings(); - try { - // Set to false rather than null to avoid further attempts - $metaTree = false; - $textTree = false; - $tree = new SimpleXMLElement( $metadata, LIBXML_PARSEHUGE ); - if ( $tree->getName() == 'mw-djvu' ) { - /** @var SimpleXMLElement $b */ - foreach ( $tree->children() as $b ) { - if ( $b->getName() == 'DjVuTxt' ) { - // @todo File::djvuTextTree and File::dejaMetaTree are declared - // dynamically. Add a public File::$data to facilitate this? - $textTree = $b; - } elseif ( $b->getName() == 'DjVuXML' ) { - $metaTree = $b; - } - } - } else { - $metaTree = $tree; - } - } catch ( Exception $e ) { - wfDebug( "Bogus multipage XML metadata\n" ); - } - Wikimedia\restoreWarnings(); - - return [ 'MetaTree' => $metaTree, 'TextTree' => $textTree ]; - } - - function getImageSize( $image, $path ) { - return $this->getDjVuImage( $image, $path )->getImageSize(); - } - - function getThumbType( $ext, $mime, $params = null ) { - global $wgDjvuOutputExtension; - static $mime; - if ( !isset( $mime ) ) { - $magic = MediaWiki\MediaWikiServices::getInstance()->getMimeAnalyzer(); - $mime = $magic->guessTypesForExtension( $wgDjvuOutputExtension ); - } - - return [ $wgDjvuOutputExtension, $mime ]; - } - - function getMetadata( $image, $path ) { - wfDebug( "Getting DjVu metadata for $path\n" ); - - $xml = $this->getDjVuImage( $image, $path )->retrieveMetaData(); - if ( $xml === false ) { - // Special value so that we don't repetitively try and decode a broken file. - return serialize( [ 'error' => 'Error extracting metadata' ] ); - } else { - return serialize( [ 'xml' => $xml ] ); - } - } - - function getMetadataType( $image ) { - return 'djvuxml'; - } - - function isMetadataValid( $image, $metadata ) { - return !empty( $metadata ) && $metadata != serialize( [] ); - } - - function pageCount( File $image ) { - $info = $this->getDimensionInfo( $image ); - - return $info ? $info['pageCount'] : false; - } - - function getPageDimensions( File $image, $page ) { - $index = $page - 1; // MW starts pages at 1 - - $info = $this->getDimensionInfo( $image ); - if ( $info && isset( $info['dimensionsByPage'][$index] ) ) { - return $info['dimensionsByPage'][$index]; - } - - return false; - } - - protected function getDimensionInfo( File $file ) { - $cache = ObjectCache::getMainWANInstance(); - return $cache->getWithSetCallback( - $cache->makeKey( 'file-djvu', 'dimensions', $file->getSha1() ), - $cache::TTL_INDEFINITE, - function () use ( $file ) { - $tree = $this->getMetaTree( $file ); - return $this->getDimensionInfoFromMetaTree( $tree ); - }, - [ 'pcTTL' => $cache::TTL_INDEFINITE ] - ); - } - - /** - * Given an XML metadata tree, returns dimension information about the document - * @param bool|SimpleXMLElement $metatree The file's XML metadata tree - * @return bool|array - */ - protected function getDimensionInfoFromMetaTree( $metatree ) { - if ( !$metatree ) { - return false; - } - - $dimsByPage = []; - $count = count( $metatree->xpath( '//OBJECT' ) ); - for ( $i = 0; $i < $count; $i++ ) { - $o = $metatree->BODY[0]->OBJECT[$i]; - if ( $o ) { - $dimsByPage[$i] = [ - 'width' => (int)$o['width'], - 'height' => (int)$o['height'], - ]; - } else { - $dimsByPage[$i] = false; - } - } - - return [ 'pageCount' => $count, 'dimensionsByPage' => $dimsByPage ]; - } - - /** - * @param File $image - * @param int $page Page number to get information for - * @return bool|string Page text or false when no text found. - */ - function getPageText( File $image, $page ) { - $tree = $this->getMetaTree( $image, true ); - if ( !$tree ) { - return false; - } - - $o = $tree->BODY[0]->PAGE[$page - 1]; - if ( $o ) { - $txt = $o['value']; - - return $txt; - } else { - return false; - } - } -} diff --git a/includes/media/DjVuHandler.php b/includes/media/DjVuHandler.php new file mode 100644 index 0000000000..2541e35bc5 --- /dev/null +++ b/includes/media/DjVuHandler.php @@ -0,0 +1,464 @@ +getSize() > static::EXPENSIVE_SIZE_LIMIT; + } + + /** + * @param File $file + * @return bool + */ + public function isMultiPage( $file ) { + return true; + } + + /** + * @return array + */ + public function getParamMap() { + return [ + 'img_width' => 'width', + 'img_page' => 'page', + ]; + } + + /** + * @param string $name + * @param mixed $value + * @return bool + */ + public function validateParam( $name, $value ) { + if ( $name === 'page' && trim( $value ) !== (string)intval( $value ) ) { + // Extra junk on the end of page, probably actually a caption + // e.g. [[File:Foo.djvu|thumb|Page 3 of the document shows foo]] + return false; + } + if ( in_array( $name, [ 'width', 'height', 'page' ] ) ) { + if ( $value <= 0 ) { + return false; + } else { + return true; + } + } else { + return false; + } + } + + /** + * @param array $params + * @return bool|string + */ + public function makeParamString( $params ) { + $page = isset( $params['page'] ) ? $params['page'] : 1; + if ( !isset( $params['width'] ) ) { + return false; + } + + return "page{$page}-{$params['width']}px"; + } + + /** + * @param string $str + * @return array|bool + */ + public function parseParamString( $str ) { + $m = false; + if ( preg_match( '/^page(\d+)-(\d+)px$/', $str, $m ) ) { + return [ 'width' => $m[2], 'page' => $m[1] ]; + } else { + return false; + } + } + + /** + * @param array $params + * @return array + */ + function getScriptParams( $params ) { + return [ + 'width' => $params['width'], + 'page' => $params['page'], + ]; + } + + /** + * @param File $image + * @param string $dstPath + * @param string $dstUrl + * @param array $params + * @param int $flags + * @return MediaTransformError|ThumbnailImage|TransformParameterError + */ + function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) { + global $wgDjvuRenderer, $wgDjvuPostProcessor; + + if ( !$this->normaliseParams( $image, $params ) ) { + return new TransformParameterError( $params ); + } + $width = $params['width']; + $height = $params['height']; + $page = $params['page']; + + if ( $flags & self::TRANSFORM_LATER ) { + $params = [ + 'width' => $width, + 'height' => $height, + 'page' => $page + ]; + + return new ThumbnailImage( $image, $dstUrl, $dstPath, $params ); + } + + if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) { + return new MediaTransformError( + 'thumbnail_error', + $width, + $height, + wfMessage( 'thumbnail_dest_directory' ) + ); + } + + // Get local copy source for shell scripts + // Thumbnail extraction is very inefficient for large files. + // Provide a way to pool count limit the number of downloaders. + if ( $image->getSize() >= 1e7 ) { // 10MB + $work = new PoolCounterWorkViaCallback( 'GetLocalFileCopy', sha1( $image->getName() ), + [ + 'doWork' => function () use ( $image ) { + return $image->getLocalRefPath(); + } + ] + ); + $srcPath = $work->execute(); + } else { + $srcPath = $image->getLocalRefPath(); + } + + if ( $srcPath === false ) { // Failed to get local copy + wfDebugLog( 'thumbnail', + sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"', + wfHostname(), $image->getName() ) ); + + return new MediaTransformError( 'thumbnail_error', + $params['width'], $params['height'], + wfMessage( 'filemissing' ) + ); + } + + # Use a subshell (brackets) to aggregate stderr from both pipeline commands + # before redirecting it to the overall stdout. This works in both Linux and Windows XP. + $cmd = '(' . wfEscapeShellArg( + $wgDjvuRenderer, + "-format=ppm", + "-page={$page}", + "-size={$params['physicalWidth']}x{$params['physicalHeight']}", + $srcPath ); + if ( $wgDjvuPostProcessor ) { + $cmd .= " | {$wgDjvuPostProcessor}"; + } + $cmd .= ' > ' . wfEscapeShellArg( $dstPath ) . ') 2>&1'; + wfDebug( __METHOD__ . ": $cmd\n" ); + $retval = ''; + $err = wfShellExec( $cmd, $retval ); + + $removed = $this->removeBadFile( $dstPath, $retval ); + if ( $retval != 0 || $removed ) { + $this->logErrorForExternalProcess( $retval, $err, $cmd ); + return new MediaTransformError( 'thumbnail_error', $width, $height, $err ); + } else { + $params = [ + 'width' => $width, + 'height' => $height, + 'page' => $page + ]; + + return new ThumbnailImage( $image, $dstUrl, $dstPath, $params ); + } + } + + /** + * Cache an instance of DjVuImage in an Image object, return that instance + * + * @param File|FSFile $image + * @param string $path + * @return DjVuImage + */ + function getDjVuImage( $image, $path ) { + if ( !$image ) { + $deja = new DjVuImage( $path ); + } elseif ( !isset( $image->dejaImage ) ) { + $deja = $image->dejaImage = new DjVuImage( $path ); + } else { + $deja = $image->dejaImage; + } + + return $deja; + } + + /** + * Get metadata, unserializing it if neccessary. + * + * @param File $file The DjVu file in question + * @return string XML metadata as a string. + * @throws MWException + */ + private function getUnserializedMetadata( File $file ) { + $metadata = $file->getMetadata(); + if ( substr( $metadata, 0, 3 ) === 'djvuTextTree ) ) { + return $image->djvuTextTree; + } + if ( !$gettext && isset( $image->dejaMetaTree ) ) { + return $image->dejaMetaTree; + } + + $metadata = $this->getUnserializedMetadata( $image ); + if ( !$this->isMetadataValid( $image, $metadata ) ) { + wfDebug( "DjVu XML metadata is invalid or missing, should have been fixed in upgradeRow\n" ); + + return false; + } + + $trees = $this->extractTreesFromMetadata( $metadata ); + $image->djvuTextTree = $trees['TextTree']; + $image->dejaMetaTree = $trees['MetaTree']; + + if ( $gettext ) { + return $image->djvuTextTree; + } else { + return $image->dejaMetaTree; + } + } + + /** + * Extracts metadata and text trees from metadata XML in string form + * @param string $metadata XML metadata as a string + * @return array + */ + protected function extractTreesFromMetadata( $metadata ) { + Wikimedia\suppressWarnings(); + try { + // Set to false rather than null to avoid further attempts + $metaTree = false; + $textTree = false; + $tree = new SimpleXMLElement( $metadata, LIBXML_PARSEHUGE ); + if ( $tree->getName() == 'mw-djvu' ) { + /** @var SimpleXMLElement $b */ + foreach ( $tree->children() as $b ) { + if ( $b->getName() == 'DjVuTxt' ) { + // @todo File::djvuTextTree and File::dejaMetaTree are declared + // dynamically. Add a public File::$data to facilitate this? + $textTree = $b; + } elseif ( $b->getName() == 'DjVuXML' ) { + $metaTree = $b; + } + } + } else { + $metaTree = $tree; + } + } catch ( Exception $e ) { + wfDebug( "Bogus multipage XML metadata\n" ); + } + Wikimedia\restoreWarnings(); + + return [ 'MetaTree' => $metaTree, 'TextTree' => $textTree ]; + } + + function getImageSize( $image, $path ) { + return $this->getDjVuImage( $image, $path )->getImageSize(); + } + + function getThumbType( $ext, $mime, $params = null ) { + global $wgDjvuOutputExtension; + static $mime; + if ( !isset( $mime ) ) { + $magic = MediaWiki\MediaWikiServices::getInstance()->getMimeAnalyzer(); + $mime = $magic->guessTypesForExtension( $wgDjvuOutputExtension ); + } + + return [ $wgDjvuOutputExtension, $mime ]; + } + + function getMetadata( $image, $path ) { + wfDebug( "Getting DjVu metadata for $path\n" ); + + $xml = $this->getDjVuImage( $image, $path )->retrieveMetaData(); + if ( $xml === false ) { + // Special value so that we don't repetitively try and decode a broken file. + return serialize( [ 'error' => 'Error extracting metadata' ] ); + } else { + return serialize( [ 'xml' => $xml ] ); + } + } + + function getMetadataType( $image ) { + return 'djvuxml'; + } + + function isMetadataValid( $image, $metadata ) { + return !empty( $metadata ) && $metadata != serialize( [] ); + } + + function pageCount( File $image ) { + $info = $this->getDimensionInfo( $image ); + + return $info ? $info['pageCount'] : false; + } + + function getPageDimensions( File $image, $page ) { + $index = $page - 1; // MW starts pages at 1 + + $info = $this->getDimensionInfo( $image ); + if ( $info && isset( $info['dimensionsByPage'][$index] ) ) { + return $info['dimensionsByPage'][$index]; + } + + return false; + } + + protected function getDimensionInfo( File $file ) { + $cache = ObjectCache::getMainWANInstance(); + return $cache->getWithSetCallback( + $cache->makeKey( 'file-djvu', 'dimensions', $file->getSha1() ), + $cache::TTL_INDEFINITE, + function () use ( $file ) { + $tree = $this->getMetaTree( $file ); + return $this->getDimensionInfoFromMetaTree( $tree ); + }, + [ 'pcTTL' => $cache::TTL_INDEFINITE ] + ); + } + + /** + * Given an XML metadata tree, returns dimension information about the document + * @param bool|SimpleXMLElement $metatree The file's XML metadata tree + * @return bool|array + */ + protected function getDimensionInfoFromMetaTree( $metatree ) { + if ( !$metatree ) { + return false; + } + + $dimsByPage = []; + $count = count( $metatree->xpath( '//OBJECT' ) ); + for ( $i = 0; $i < $count; $i++ ) { + $o = $metatree->BODY[0]->OBJECT[$i]; + if ( $o ) { + $dimsByPage[$i] = [ + 'width' => (int)$o['width'], + 'height' => (int)$o['height'], + ]; + } else { + $dimsByPage[$i] = false; + } + } + + return [ 'pageCount' => $count, 'dimensionsByPage' => $dimsByPage ]; + } + + /** + * @param File $image + * @param int $page Page number to get information for + * @return bool|string Page text or false when no text found. + */ + function getPageText( File $image, $page ) { + $tree = $this->getMetaTree( $image, true ); + if ( !$tree ) { + return false; + } + + $o = $tree->BODY[0]->PAGE[$page - 1]; + if ( $o ) { + $txt = $o['value']; + + return $txt; + } else { + return false; + } + } +} diff --git a/includes/media/ExifBitmap.php b/includes/media/ExifBitmap.php deleted file mode 100644 index 426721095c..0000000000 --- a/includes/media/ExifBitmap.php +++ /dev/null @@ -1,245 +0,0 @@ -= 2 ) { - return $metadata; - } - - $avoidHtml = true; - - if ( !is_array( $metadata ) ) { - $metadata = unserialize( $metadata ); - } - if ( !isset( $metadata['MEDIAWIKI_EXIF_VERSION'] ) || $metadata['MEDIAWIKI_EXIF_VERSION'] != 2 ) { - return $metadata; - } - - // Treat Software as a special case because in can contain - // an array of (SoftwareName, Version). - if ( isset( $metadata['Software'] ) - && is_array( $metadata['Software'] ) - && is_array( $metadata['Software'][0] ) - && isset( $metadata['Software'][0][0] ) - && isset( $metadata['Software'][0][1] ) - ) { - $metadata['Software'] = $metadata['Software'][0][0] . ' (Version ' - . $metadata['Software'][0][1] . ')'; - } - - $formatter = new FormatMetadata; - - // ContactInfo also has to be dealt with specially - if ( isset( $metadata['Contact'] ) ) { - $metadata['Contact'] = - $formatter->collapseContactInfo( - $metadata['Contact'] ); - } - - foreach ( $metadata as &$val ) { - if ( is_array( $val ) ) { - $val = $formatter->flattenArrayReal( $val, 'ul', $avoidHtml ); - } - } - $metadata['MEDIAWIKI_EXIF_VERSION'] = 1; - - return $metadata; - } - - /** - * @param File $image - * @param array $metadata - * @return bool|int - */ - function isMetadataValid( $image, $metadata ) { - global $wgShowEXIF; - if ( !$wgShowEXIF ) { - # Metadata disabled and so an empty field is expected - return self::METADATA_GOOD; - } - if ( $metadata === self::OLD_BROKEN_FILE ) { - # Old special value indicating that there is no Exif data in the file. - # or that there was an error well extracting the metadata. - wfDebug( __METHOD__ . ": back-compat version\n" ); - - return self::METADATA_COMPATIBLE; - } - if ( $metadata === self::BROKEN_FILE ) { - return self::METADATA_GOOD; - } - Wikimedia\suppressWarnings(); - $exif = unserialize( $metadata ); - Wikimedia\restoreWarnings(); - if ( !isset( $exif['MEDIAWIKI_EXIF_VERSION'] ) - || $exif['MEDIAWIKI_EXIF_VERSION'] != Exif::version() - ) { - if ( isset( $exif['MEDIAWIKI_EXIF_VERSION'] ) - && $exif['MEDIAWIKI_EXIF_VERSION'] == 1 - ) { - // back-compatible but old - wfDebug( __METHOD__ . ": back-compat version\n" ); - - return self::METADATA_COMPATIBLE; - } - # Wrong (non-compatible) version - wfDebug( __METHOD__ . ": wrong version\n" ); - - return self::METADATA_BAD; - } - - return self::METADATA_GOOD; - } - - /** - * @param File $image - * @param bool|IContextSource $context Context to use (optional) - * @return array|bool - */ - function formatMetadata( $image, $context = false ) { - $meta = $this->getCommonMetaArray( $image ); - if ( count( $meta ) === 0 ) { - return false; - } - - return $this->formatMetadataHelper( $meta, $context ); - } - - public function getCommonMetaArray( File $file ) { - $metadata = $file->getMetadata(); - if ( $metadata === self::OLD_BROKEN_FILE - || $metadata === self::BROKEN_FILE - || $this->isMetadataValid( $file, $metadata ) === self::METADATA_BAD - ) { - // So we don't try and display metadata from PagedTiffHandler - // for example when using InstantCommons. - return []; - } - - $exif = unserialize( $metadata ); - if ( !$exif ) { - return []; - } - unset( $exif['MEDIAWIKI_EXIF_VERSION'] ); - - return $exif; - } - - function getMetadataType( $image ) { - return 'exif'; - } - - /** - * Wrapper for base classes ImageHandler::getImageSize() that checks for - * rotation reported from metadata and swaps the sizes to match. - * - * @param File|FSFile $image - * @param string $path - * @return array - */ - function getImageSize( $image, $path ) { - $gis = parent::getImageSize( $image, $path ); - - // Don't just call $image->getMetadata(); FSFile::getPropsFromPath() calls us with a bogus object. - // This may mean we read EXIF data twice on initial upload. - if ( $this->autoRotateEnabled() ) { - $meta = $this->getMetadata( $image, $path ); - $rotation = $this->getRotationForExif( $meta ); - } else { - $rotation = 0; - } - - if ( $rotation == 90 || $rotation == 270 ) { - $width = $gis[0]; - $gis[0] = $gis[1]; - $gis[1] = $width; - } - - return $gis; - } - - /** - * On supporting image formats, try to read out the low-level orientation - * of the file and return the angle that the file needs to be rotated to - * be viewed. - * - * This information is only useful when manipulating the original file; - * the width and height we normally work with is logical, and will match - * any produced output views. - * - * @param File $file - * @return int 0, 90, 180 or 270 - */ - public function getRotation( $file ) { - if ( !$this->autoRotateEnabled() ) { - return 0; - } - - $data = $file->getMetadata(); - - return $this->getRotationForExif( $data ); - } - - /** - * Given a chunk of serialized Exif metadata, return the orientation as - * degrees of rotation. - * - * @param string $data - * @return int 0, 90, 180 or 270 - * @todo FIXME: Orientation can include flipping as well; see if this is an issue! - */ - protected function getRotationForExif( $data ) { - if ( !$data ) { - return 0; - } - Wikimedia\suppressWarnings(); - $data = unserialize( $data ); - Wikimedia\restoreWarnings(); - if ( isset( $data['Orientation'] ) ) { - # See http://sylvana.net/jpegcrop/exif_orientation.html - switch ( $data['Orientation'] ) { - case 8: - return 90; - case 3: - return 180; - case 6: - return 270; - default: - return 0; - } - } - - return 0; - } -} diff --git a/includes/media/ExifBitmapHandler.php b/includes/media/ExifBitmapHandler.php new file mode 100644 index 0000000000..426721095c --- /dev/null +++ b/includes/media/ExifBitmapHandler.php @@ -0,0 +1,245 @@ += 2 ) { + return $metadata; + } + + $avoidHtml = true; + + if ( !is_array( $metadata ) ) { + $metadata = unserialize( $metadata ); + } + if ( !isset( $metadata['MEDIAWIKI_EXIF_VERSION'] ) || $metadata['MEDIAWIKI_EXIF_VERSION'] != 2 ) { + return $metadata; + } + + // Treat Software as a special case because in can contain + // an array of (SoftwareName, Version). + if ( isset( $metadata['Software'] ) + && is_array( $metadata['Software'] ) + && is_array( $metadata['Software'][0] ) + && isset( $metadata['Software'][0][0] ) + && isset( $metadata['Software'][0][1] ) + ) { + $metadata['Software'] = $metadata['Software'][0][0] . ' (Version ' + . $metadata['Software'][0][1] . ')'; + } + + $formatter = new FormatMetadata; + + // ContactInfo also has to be dealt with specially + if ( isset( $metadata['Contact'] ) ) { + $metadata['Contact'] = + $formatter->collapseContactInfo( + $metadata['Contact'] ); + } + + foreach ( $metadata as &$val ) { + if ( is_array( $val ) ) { + $val = $formatter->flattenArrayReal( $val, 'ul', $avoidHtml ); + } + } + $metadata['MEDIAWIKI_EXIF_VERSION'] = 1; + + return $metadata; + } + + /** + * @param File $image + * @param array $metadata + * @return bool|int + */ + function isMetadataValid( $image, $metadata ) { + global $wgShowEXIF; + if ( !$wgShowEXIF ) { + # Metadata disabled and so an empty field is expected + return self::METADATA_GOOD; + } + if ( $metadata === self::OLD_BROKEN_FILE ) { + # Old special value indicating that there is no Exif data in the file. + # or that there was an error well extracting the metadata. + wfDebug( __METHOD__ . ": back-compat version\n" ); + + return self::METADATA_COMPATIBLE; + } + if ( $metadata === self::BROKEN_FILE ) { + return self::METADATA_GOOD; + } + Wikimedia\suppressWarnings(); + $exif = unserialize( $metadata ); + Wikimedia\restoreWarnings(); + if ( !isset( $exif['MEDIAWIKI_EXIF_VERSION'] ) + || $exif['MEDIAWIKI_EXIF_VERSION'] != Exif::version() + ) { + if ( isset( $exif['MEDIAWIKI_EXIF_VERSION'] ) + && $exif['MEDIAWIKI_EXIF_VERSION'] == 1 + ) { + // back-compatible but old + wfDebug( __METHOD__ . ": back-compat version\n" ); + + return self::METADATA_COMPATIBLE; + } + # Wrong (non-compatible) version + wfDebug( __METHOD__ . ": wrong version\n" ); + + return self::METADATA_BAD; + } + + return self::METADATA_GOOD; + } + + /** + * @param File $image + * @param bool|IContextSource $context Context to use (optional) + * @return array|bool + */ + function formatMetadata( $image, $context = false ) { + $meta = $this->getCommonMetaArray( $image ); + if ( count( $meta ) === 0 ) { + return false; + } + + return $this->formatMetadataHelper( $meta, $context ); + } + + public function getCommonMetaArray( File $file ) { + $metadata = $file->getMetadata(); + if ( $metadata === self::OLD_BROKEN_FILE + || $metadata === self::BROKEN_FILE + || $this->isMetadataValid( $file, $metadata ) === self::METADATA_BAD + ) { + // So we don't try and display metadata from PagedTiffHandler + // for example when using InstantCommons. + return []; + } + + $exif = unserialize( $metadata ); + if ( !$exif ) { + return []; + } + unset( $exif['MEDIAWIKI_EXIF_VERSION'] ); + + return $exif; + } + + function getMetadataType( $image ) { + return 'exif'; + } + + /** + * Wrapper for base classes ImageHandler::getImageSize() that checks for + * rotation reported from metadata and swaps the sizes to match. + * + * @param File|FSFile $image + * @param string $path + * @return array + */ + function getImageSize( $image, $path ) { + $gis = parent::getImageSize( $image, $path ); + + // Don't just call $image->getMetadata(); FSFile::getPropsFromPath() calls us with a bogus object. + // This may mean we read EXIF data twice on initial upload. + if ( $this->autoRotateEnabled() ) { + $meta = $this->getMetadata( $image, $path ); + $rotation = $this->getRotationForExif( $meta ); + } else { + $rotation = 0; + } + + if ( $rotation == 90 || $rotation == 270 ) { + $width = $gis[0]; + $gis[0] = $gis[1]; + $gis[1] = $width; + } + + return $gis; + } + + /** + * On supporting image formats, try to read out the low-level orientation + * of the file and return the angle that the file needs to be rotated to + * be viewed. + * + * This information is only useful when manipulating the original file; + * the width and height we normally work with is logical, and will match + * any produced output views. + * + * @param File $file + * @return int 0, 90, 180 or 270 + */ + public function getRotation( $file ) { + if ( !$this->autoRotateEnabled() ) { + return 0; + } + + $data = $file->getMetadata(); + + return $this->getRotationForExif( $data ); + } + + /** + * Given a chunk of serialized Exif metadata, return the orientation as + * degrees of rotation. + * + * @param string $data + * @return int 0, 90, 180 or 270 + * @todo FIXME: Orientation can include flipping as well; see if this is an issue! + */ + protected function getRotationForExif( $data ) { + if ( !$data ) { + return 0; + } + Wikimedia\suppressWarnings(); + $data = unserialize( $data ); + Wikimedia\restoreWarnings(); + if ( isset( $data['Orientation'] ) ) { + # See http://sylvana.net/jpegcrop/exif_orientation.html + switch ( $data['Orientation'] ) { + case 8: + return 90; + case 3: + return 180; + case 6: + return 270; + default: + return 0; + } + } + + return 0; + } +} diff --git a/includes/media/GIF.php b/includes/media/GIF.php deleted file mode 100644 index d65f872634..0000000000 --- a/includes/media/GIF.php +++ /dev/null @@ -1,211 +0,0 @@ -getMessage() . "\n" ); - - return self::BROKEN_FILE; - } - - return serialize( $parsedGIFMetadata ); - } - - /** - * @param File $image - * @param bool|IContextSource $context Context to use (optional) - * @return array|bool - */ - function formatMetadata( $image, $context = false ) { - $meta = $this->getCommonMetaArray( $image ); - if ( count( $meta ) === 0 ) { - return false; - } - - return $this->formatMetadataHelper( $meta, $context ); - } - - /** - * Return the standard metadata elements for #filemetadata parser func. - * @param File $image - * @return array|bool - */ - public function getCommonMetaArray( File $image ) { - $meta = $image->getMetadata(); - - if ( !$meta ) { - return []; - } - $meta = unserialize( $meta ); - if ( !isset( $meta['metadata'] ) ) { - return []; - } - unset( $meta['metadata']['_MW_GIF_VERSION'] ); - - return $meta['metadata']; - } - - /** - * @todo Add unit tests - * - * @param File $image - * @return bool - */ - function getImageArea( $image ) { - $ser = $image->getMetadata(); - if ( $ser ) { - $metadata = unserialize( $ser ); - - return $image->getWidth() * $image->getHeight() * $metadata['frameCount']; - } else { - return $image->getWidth() * $image->getHeight(); - } - } - - /** - * @param File $image - * @return bool - */ - function isAnimatedImage( $image ) { - $ser = $image->getMetadata(); - if ( $ser ) { - $metadata = unserialize( $ser ); - if ( $metadata['frameCount'] > 1 ) { - return true; - } - } - - return false; - } - - /** - * We cannot animate thumbnails that are bigger than a particular size - * @param File $file - * @return bool - */ - function canAnimateThumbnail( $file ) { - global $wgMaxAnimatedGifArea; - $answer = $this->getImageArea( $file ) <= $wgMaxAnimatedGifArea; - - return $answer; - } - - function getMetadataType( $image ) { - return 'parsed-gif'; - } - - function isMetadataValid( $image, $metadata ) { - if ( $metadata === self::BROKEN_FILE ) { - // Do not repetitivly regenerate metadata on broken file. - return self::METADATA_GOOD; - } - - Wikimedia\suppressWarnings(); - $data = unserialize( $metadata ); - Wikimedia\restoreWarnings(); - - if ( !$data || !is_array( $data ) ) { - wfDebug( __METHOD__ . " invalid GIF metadata\n" ); - - return self::METADATA_BAD; - } - - if ( !isset( $data['metadata']['_MW_GIF_VERSION'] ) - || $data['metadata']['_MW_GIF_VERSION'] != GIFMetadataExtractor::VERSION - ) { - wfDebug( __METHOD__ . " old but compatible GIF metadata\n" ); - - return self::METADATA_COMPATIBLE; - } - - return self::METADATA_GOOD; - } - - /** - * @param File $image - * @return string - */ - function getLongDesc( $image ) { - global $wgLang; - - $original = parent::getLongDesc( $image ); - - Wikimedia\suppressWarnings(); - $metadata = unserialize( $image->getMetadata() ); - Wikimedia\restoreWarnings(); - - if ( !$metadata || $metadata['frameCount'] <= 1 ) { - return $original; - } - - /* Preserve original image info string, but strip the last char ')' so we can add even more */ - $info = []; - $info[] = $original; - - if ( $metadata['looped'] ) { - $info[] = wfMessage( 'file-info-gif-looped' )->parse(); - } - - if ( $metadata['frameCount'] > 1 ) { - $info[] = wfMessage( 'file-info-gif-frames' )->numParams( $metadata['frameCount'] )->parse(); - } - - if ( $metadata['duration'] ) { - $info[] = $wgLang->formatTimePeriod( $metadata['duration'] ); - } - - return $wgLang->commaList( $info ); - } - - /** - * Return the duration of the GIF file. - * - * Shown in the &query=imageinfo&iiprop=size api query. - * - * @param File $file - * @return float The duration of the file. - */ - public function getLength( $file ) { - $serMeta = $file->getMetadata(); - Wikimedia\suppressWarnings(); - $metadata = unserialize( $serMeta ); - Wikimedia\restoreWarnings(); - - if ( !$metadata || !isset( $metadata['duration'] ) || !$metadata['duration'] ) { - return 0.0; - } else { - return (float)$metadata['duration']; - } - } -} diff --git a/includes/media/GIFHandler.php b/includes/media/GIFHandler.php new file mode 100644 index 0000000000..d65f872634 --- /dev/null +++ b/includes/media/GIFHandler.php @@ -0,0 +1,211 @@ +getMessage() . "\n" ); + + return self::BROKEN_FILE; + } + + return serialize( $parsedGIFMetadata ); + } + + /** + * @param File $image + * @param bool|IContextSource $context Context to use (optional) + * @return array|bool + */ + function formatMetadata( $image, $context = false ) { + $meta = $this->getCommonMetaArray( $image ); + if ( count( $meta ) === 0 ) { + return false; + } + + return $this->formatMetadataHelper( $meta, $context ); + } + + /** + * Return the standard metadata elements for #filemetadata parser func. + * @param File $image + * @return array|bool + */ + public function getCommonMetaArray( File $image ) { + $meta = $image->getMetadata(); + + if ( !$meta ) { + return []; + } + $meta = unserialize( $meta ); + if ( !isset( $meta['metadata'] ) ) { + return []; + } + unset( $meta['metadata']['_MW_GIF_VERSION'] ); + + return $meta['metadata']; + } + + /** + * @todo Add unit tests + * + * @param File $image + * @return bool + */ + function getImageArea( $image ) { + $ser = $image->getMetadata(); + if ( $ser ) { + $metadata = unserialize( $ser ); + + return $image->getWidth() * $image->getHeight() * $metadata['frameCount']; + } else { + return $image->getWidth() * $image->getHeight(); + } + } + + /** + * @param File $image + * @return bool + */ + function isAnimatedImage( $image ) { + $ser = $image->getMetadata(); + if ( $ser ) { + $metadata = unserialize( $ser ); + if ( $metadata['frameCount'] > 1 ) { + return true; + } + } + + return false; + } + + /** + * We cannot animate thumbnails that are bigger than a particular size + * @param File $file + * @return bool + */ + function canAnimateThumbnail( $file ) { + global $wgMaxAnimatedGifArea; + $answer = $this->getImageArea( $file ) <= $wgMaxAnimatedGifArea; + + return $answer; + } + + function getMetadataType( $image ) { + return 'parsed-gif'; + } + + function isMetadataValid( $image, $metadata ) { + if ( $metadata === self::BROKEN_FILE ) { + // Do not repetitivly regenerate metadata on broken file. + return self::METADATA_GOOD; + } + + Wikimedia\suppressWarnings(); + $data = unserialize( $metadata ); + Wikimedia\restoreWarnings(); + + if ( !$data || !is_array( $data ) ) { + wfDebug( __METHOD__ . " invalid GIF metadata\n" ); + + return self::METADATA_BAD; + } + + if ( !isset( $data['metadata']['_MW_GIF_VERSION'] ) + || $data['metadata']['_MW_GIF_VERSION'] != GIFMetadataExtractor::VERSION + ) { + wfDebug( __METHOD__ . " old but compatible GIF metadata\n" ); + + return self::METADATA_COMPATIBLE; + } + + return self::METADATA_GOOD; + } + + /** + * @param File $image + * @return string + */ + function getLongDesc( $image ) { + global $wgLang; + + $original = parent::getLongDesc( $image ); + + Wikimedia\suppressWarnings(); + $metadata = unserialize( $image->getMetadata() ); + Wikimedia\restoreWarnings(); + + if ( !$metadata || $metadata['frameCount'] <= 1 ) { + return $original; + } + + /* Preserve original image info string, but strip the last char ')' so we can add even more */ + $info = []; + $info[] = $original; + + if ( $metadata['looped'] ) { + $info[] = wfMessage( 'file-info-gif-looped' )->parse(); + } + + if ( $metadata['frameCount'] > 1 ) { + $info[] = wfMessage( 'file-info-gif-frames' )->numParams( $metadata['frameCount'] )->parse(); + } + + if ( $metadata['duration'] ) { + $info[] = $wgLang->formatTimePeriod( $metadata['duration'] ); + } + + return $wgLang->commaList( $info ); + } + + /** + * Return the duration of the GIF file. + * + * Shown in the &query=imageinfo&iiprop=size api query. + * + * @param File $file + * @return float The duration of the file. + */ + public function getLength( $file ) { + $serMeta = $file->getMetadata(); + Wikimedia\suppressWarnings(); + $metadata = unserialize( $serMeta ); + Wikimedia\restoreWarnings(); + + if ( !$metadata || !isset( $metadata['duration'] ) || !$metadata['duration'] ) { + return 0.0; + } else { + return (float)$metadata['duration']; + } + } +} diff --git a/includes/media/Jpeg.php b/includes/media/Jpeg.php deleted file mode 100644 index 287c198c57..0000000000 --- a/includes/media/Jpeg.php +++ /dev/null @@ -1,290 +0,0 @@ -getMessage() . "\n" ); - - /* This used to use 0 (ExifBitmapHandler::OLD_BROKEN_FILE) for the cases - * * No metadata in the file - * * Something is broken in the file. - * However, if the metadata support gets expanded then you can't tell if the 0 is from - * a broken file, or just no props found. A broken file is likely to stay broken, but - * a file which had no props could have props once the metadata support is improved. - * Thus switch to using -1 to denote only a broken file, and use an array with only - * MEDIAWIKI_EXIF_VERSION to denote no props. - */ - - return ExifBitmapHandler::BROKEN_FILE; - } - } - - /** - * @param File $file - * @param array $params Rotate parameters. - * 'rotation' clockwise rotation in degrees, allowed are multiples of 90 - * @since 1.21 - * @return bool|MediaTransformError - */ - public function rotate( $file, $params ) { - global $wgJpegTran; - - $rotation = ( $params['rotation'] + $this->getRotation( $file ) ) % 360; - - if ( $wgJpegTran && is_executable( $wgJpegTran ) ) { - $cmd = wfEscapeShellArg( $wgJpegTran ) . - " -rotate " . wfEscapeShellArg( $rotation ) . - " -outfile " . wfEscapeShellArg( $params['dstPath'] ) . - " " . wfEscapeShellArg( $params['srcPath'] ); - wfDebug( __METHOD__ . ": running jpgtran: $cmd\n" ); - $retval = 0; - $err = wfShellExecWithStderr( $cmd, $retval ); - if ( $retval !== 0 ) { - $this->logErrorForExternalProcess( $retval, $err, $cmd ); - - return new MediaTransformError( 'thumbnail_error', 0, 0, $err ); - } - - return false; - } else { - return parent::rotate( $file, $params ); - } - } - - public function supportsBucketing() { - return true; - } - - public function sanitizeParamsForBucketing( $params ) { - $params = parent::sanitizeParamsForBucketing( $params ); - - // Quality needs to be cleared for bucketing. Buckets need to be default quality - if ( isset( $params['quality'] ) ) { - unset( $params['quality'] ); - } - - return $params; - } - - /** - * @inheritDoc - */ - protected function transformImageMagick( $image, $params ) { - global $wgUseTinyRGBForJPGThumbnails; - - $ret = parent::transformImageMagick( $image, $params ); - - if ( $ret ) { - return $ret; - } - - if ( $wgUseTinyRGBForJPGThumbnails ) { - // T100976 If the profile embedded in the JPG is sRGB, swap it for the smaller - // (and free) TinyRGB - - /** - * We'll want to replace the color profile for JPGs: - * * in the sRGB color space, or with the sRGB profile - * (other profiles will be left untouched) - * * without color space or profile, in which case browsers - * should assume sRGB, but don't always do (e.g. on wide-gamut - * monitors (unless it's meant for low bandwith) - * @see https://phabricator.wikimedia.org/T134498 - */ - $colorSpaces = [ self::SRGB_EXIF_COLOR_SPACE, '-' ]; - $profiles = [ self::SRGB_ICC_PROFILE_DESCRIPTION ]; - - // we'll also add TinyRGB profile to images lacking a profile, but - // only if they're not low quality (which are meant to save bandwith - // and we don't want to increase the filesize by adding a profile) - if ( isset( $params['quality'] ) && $params['quality'] > 30 ) { - $profiles[] = '-'; - } - - $this->swapICCProfile( - $params['dstPath'], - $colorSpaces, - $profiles, - realpath( __DIR__ ) . '/tinyrgb.icc' - ); - } - - return false; - } - - /** - * Swaps an embedded ICC profile for another, if found. - * Depends on exiftool, no-op if not installed. - * @param string $filepath File to be manipulated (will be overwritten) - * @param array $colorSpaces Only process files with this/these Color Space(s) - * @param array $oldProfileStrings Exact name(s) of color profile to look for - * (the one that will be replaced) - * @param string $profileFilepath ICC profile file to apply to the file - * @since 1.26 - * @return bool - */ - public function swapICCProfile( $filepath, array $colorSpaces, - array $oldProfileStrings, $profileFilepath - ) { - global $wgExiftool; - - if ( !$wgExiftool || !is_executable( $wgExiftool ) ) { - return false; - } - - $cmd = wfEscapeShellArg( $wgExiftool, - '-EXIF:ColorSpace', - '-ICC_Profile:ProfileDescription', - '-S', - '-T', - $filepath - ); - - $output = wfShellExecWithStderr( $cmd, $retval ); - - // Explode EXIF data into an array with [0 => Color Space, 1 => Device Model Desc] - $data = explode( "\t", trim( $output ) ); - - if ( $retval !== 0 ) { - return false; - } - - // Make a regex out of the source data to match it to an array of color - // spaces in a case-insensitive way - $colorSpaceRegex = '/'.preg_quote( $data[0], '/' ).'/i'; - if ( empty( preg_grep( $colorSpaceRegex, $colorSpaces ) ) ) { - // We can't establish that this file matches the color space, don't process it - return false; - } - - $profileRegex = '/'.preg_quote( $data[1], '/' ).'/i'; - if ( empty( preg_grep( $profileRegex, $oldProfileStrings ) ) ) { - // We can't establish that this file has the expected ICC profile, don't process it - return false; - } - - $cmd = wfEscapeShellArg( $wgExiftool, - '-overwrite_original', - '-icc_profile<=' . $profileFilepath, - $filepath - ); - - $output = wfShellExecWithStderr( $cmd, $retval ); - - if ( $retval !== 0 ) { - $this->logErrorForExternalProcess( $retval, $output, $cmd ); - - return false; - } - - return true; - } -} diff --git a/includes/media/JpegHandler.php b/includes/media/JpegHandler.php new file mode 100644 index 0000000000..287c198c57 --- /dev/null +++ b/includes/media/JpegHandler.php @@ -0,0 +1,290 @@ +getMessage() . "\n" ); + + /* This used to use 0 (ExifBitmapHandler::OLD_BROKEN_FILE) for the cases + * * No metadata in the file + * * Something is broken in the file. + * However, if the metadata support gets expanded then you can't tell if the 0 is from + * a broken file, or just no props found. A broken file is likely to stay broken, but + * a file which had no props could have props once the metadata support is improved. + * Thus switch to using -1 to denote only a broken file, and use an array with only + * MEDIAWIKI_EXIF_VERSION to denote no props. + */ + + return ExifBitmapHandler::BROKEN_FILE; + } + } + + /** + * @param File $file + * @param array $params Rotate parameters. + * 'rotation' clockwise rotation in degrees, allowed are multiples of 90 + * @since 1.21 + * @return bool|MediaTransformError + */ + public function rotate( $file, $params ) { + global $wgJpegTran; + + $rotation = ( $params['rotation'] + $this->getRotation( $file ) ) % 360; + + if ( $wgJpegTran && is_executable( $wgJpegTran ) ) { + $cmd = wfEscapeShellArg( $wgJpegTran ) . + " -rotate " . wfEscapeShellArg( $rotation ) . + " -outfile " . wfEscapeShellArg( $params['dstPath'] ) . + " " . wfEscapeShellArg( $params['srcPath'] ); + wfDebug( __METHOD__ . ": running jpgtran: $cmd\n" ); + $retval = 0; + $err = wfShellExecWithStderr( $cmd, $retval ); + if ( $retval !== 0 ) { + $this->logErrorForExternalProcess( $retval, $err, $cmd ); + + return new MediaTransformError( 'thumbnail_error', 0, 0, $err ); + } + + return false; + } else { + return parent::rotate( $file, $params ); + } + } + + public function supportsBucketing() { + return true; + } + + public function sanitizeParamsForBucketing( $params ) { + $params = parent::sanitizeParamsForBucketing( $params ); + + // Quality needs to be cleared for bucketing. Buckets need to be default quality + if ( isset( $params['quality'] ) ) { + unset( $params['quality'] ); + } + + return $params; + } + + /** + * @inheritDoc + */ + protected function transformImageMagick( $image, $params ) { + global $wgUseTinyRGBForJPGThumbnails; + + $ret = parent::transformImageMagick( $image, $params ); + + if ( $ret ) { + return $ret; + } + + if ( $wgUseTinyRGBForJPGThumbnails ) { + // T100976 If the profile embedded in the JPG is sRGB, swap it for the smaller + // (and free) TinyRGB + + /** + * We'll want to replace the color profile for JPGs: + * * in the sRGB color space, or with the sRGB profile + * (other profiles will be left untouched) + * * without color space or profile, in which case browsers + * should assume sRGB, but don't always do (e.g. on wide-gamut + * monitors (unless it's meant for low bandwith) + * @see https://phabricator.wikimedia.org/T134498 + */ + $colorSpaces = [ self::SRGB_EXIF_COLOR_SPACE, '-' ]; + $profiles = [ self::SRGB_ICC_PROFILE_DESCRIPTION ]; + + // we'll also add TinyRGB profile to images lacking a profile, but + // only if they're not low quality (which are meant to save bandwith + // and we don't want to increase the filesize by adding a profile) + if ( isset( $params['quality'] ) && $params['quality'] > 30 ) { + $profiles[] = '-'; + } + + $this->swapICCProfile( + $params['dstPath'], + $colorSpaces, + $profiles, + realpath( __DIR__ ) . '/tinyrgb.icc' + ); + } + + return false; + } + + /** + * Swaps an embedded ICC profile for another, if found. + * Depends on exiftool, no-op if not installed. + * @param string $filepath File to be manipulated (will be overwritten) + * @param array $colorSpaces Only process files with this/these Color Space(s) + * @param array $oldProfileStrings Exact name(s) of color profile to look for + * (the one that will be replaced) + * @param string $profileFilepath ICC profile file to apply to the file + * @since 1.26 + * @return bool + */ + public function swapICCProfile( $filepath, array $colorSpaces, + array $oldProfileStrings, $profileFilepath + ) { + global $wgExiftool; + + if ( !$wgExiftool || !is_executable( $wgExiftool ) ) { + return false; + } + + $cmd = wfEscapeShellArg( $wgExiftool, + '-EXIF:ColorSpace', + '-ICC_Profile:ProfileDescription', + '-S', + '-T', + $filepath + ); + + $output = wfShellExecWithStderr( $cmd, $retval ); + + // Explode EXIF data into an array with [0 => Color Space, 1 => Device Model Desc] + $data = explode( "\t", trim( $output ) ); + + if ( $retval !== 0 ) { + return false; + } + + // Make a regex out of the source data to match it to an array of color + // spaces in a case-insensitive way + $colorSpaceRegex = '/'.preg_quote( $data[0], '/' ).'/i'; + if ( empty( preg_grep( $colorSpaceRegex, $colorSpaces ) ) ) { + // We can't establish that this file matches the color space, don't process it + return false; + } + + $profileRegex = '/'.preg_quote( $data[1], '/' ).'/i'; + if ( empty( preg_grep( $profileRegex, $oldProfileStrings ) ) ) { + // We can't establish that this file has the expected ICC profile, don't process it + return false; + } + + $cmd = wfEscapeShellArg( $wgExiftool, + '-overwrite_original', + '-icc_profile<=' . $profileFilepath, + $filepath + ); + + $output = wfShellExecWithStderr( $cmd, $retval ); + + if ( $retval !== 0 ) { + $this->logErrorForExternalProcess( $retval, $output, $cmd ); + + return false; + } + + return true; + } +} diff --git a/includes/media/PNG.php b/includes/media/PNG.php deleted file mode 100644 index 6748b26b09..0000000000 --- a/includes/media/PNG.php +++ /dev/null @@ -1,203 +0,0 @@ -getMessage() . "\n" ); - - return self::BROKEN_FILE; - } - - return serialize( $metadata ); - } - - /** - * @param File $image - * @param bool|IContextSource $context Context to use (optional) - * @return array|bool - */ - function formatMetadata( $image, $context = false ) { - $meta = $this->getCommonMetaArray( $image ); - if ( count( $meta ) === 0 ) { - return false; - } - - return $this->formatMetadataHelper( $meta, $context ); - } - - /** - * Get a file type independent array of metadata. - * - * @param File $image - * @return array The metadata array - */ - public function getCommonMetaArray( File $image ) { - $meta = $image->getMetadata(); - - if ( !$meta ) { - return []; - } - $meta = unserialize( $meta ); - if ( !isset( $meta['metadata'] ) ) { - return []; - } - unset( $meta['metadata']['_MW_PNG_VERSION'] ); - - return $meta['metadata']; - } - - /** - * @param File $image - * @return bool - */ - function isAnimatedImage( $image ) { - $ser = $image->getMetadata(); - if ( $ser ) { - $metadata = unserialize( $ser ); - if ( $metadata['frameCount'] > 1 ) { - return true; - } - } - - return false; - } - - /** - * We do not support making APNG thumbnails, so always false - * @param File $image - * @return bool False - */ - function canAnimateThumbnail( $image ) { - return false; - } - - function getMetadataType( $image ) { - return 'parsed-png'; - } - - function isMetadataValid( $image, $metadata ) { - if ( $metadata === self::BROKEN_FILE ) { - // Do not repetitivly regenerate metadata on broken file. - return self::METADATA_GOOD; - } - - Wikimedia\suppressWarnings(); - $data = unserialize( $metadata ); - Wikimedia\restoreWarnings(); - - if ( !$data || !is_array( $data ) ) { - wfDebug( __METHOD__ . " invalid png metadata\n" ); - - return self::METADATA_BAD; - } - - if ( !isset( $data['metadata']['_MW_PNG_VERSION'] ) - || $data['metadata']['_MW_PNG_VERSION'] != PNGMetadataExtractor::VERSION - ) { - wfDebug( __METHOD__ . " old but compatible png metadata\n" ); - - return self::METADATA_COMPATIBLE; - } - - return self::METADATA_GOOD; - } - - /** - * @param File $image - * @return string - */ - function getLongDesc( $image ) { - global $wgLang; - $original = parent::getLongDesc( $image ); - - Wikimedia\suppressWarnings(); - $metadata = unserialize( $image->getMetadata() ); - Wikimedia\restoreWarnings(); - - if ( !$metadata || $metadata['frameCount'] <= 0 ) { - return $original; - } - - $info = []; - $info[] = $original; - - if ( $metadata['loopCount'] == 0 ) { - $info[] = wfMessage( 'file-info-png-looped' )->parse(); - } elseif ( $metadata['loopCount'] > 1 ) { - $info[] = wfMessage( 'file-info-png-repeat' )->numParams( $metadata['loopCount'] )->parse(); - } - - if ( $metadata['frameCount'] > 0 ) { - $info[] = wfMessage( 'file-info-png-frames' )->numParams( $metadata['frameCount'] )->parse(); - } - - if ( $metadata['duration'] ) { - $info[] = $wgLang->formatTimePeriod( $metadata['duration'] ); - } - - return $wgLang->commaList( $info ); - } - - /** - * Return the duration of an APNG file. - * - * Shown in the &query=imageinfo&iiprop=size api query. - * - * @param File $file - * @return float The duration of the file. - */ - public function getLength( $file ) { - $serMeta = $file->getMetadata(); - Wikimedia\suppressWarnings(); - $metadata = unserialize( $serMeta ); - Wikimedia\restoreWarnings(); - - if ( !$metadata || !isset( $metadata['duration'] ) || !$metadata['duration'] ) { - return 0.0; - } else { - return (float)$metadata['duration']; - } - } - - // PNGs should be easy to support, but it will need some sharpening applied - // and another user test to check if the perceived quality change is noticeable - public function supportsBucketing() { - return false; - } -} diff --git a/includes/media/PNGHandler.php b/includes/media/PNGHandler.php new file mode 100644 index 0000000000..6748b26b09 --- /dev/null +++ b/includes/media/PNGHandler.php @@ -0,0 +1,203 @@ +getMessage() . "\n" ); + + return self::BROKEN_FILE; + } + + return serialize( $metadata ); + } + + /** + * @param File $image + * @param bool|IContextSource $context Context to use (optional) + * @return array|bool + */ + function formatMetadata( $image, $context = false ) { + $meta = $this->getCommonMetaArray( $image ); + if ( count( $meta ) === 0 ) { + return false; + } + + return $this->formatMetadataHelper( $meta, $context ); + } + + /** + * Get a file type independent array of metadata. + * + * @param File $image + * @return array The metadata array + */ + public function getCommonMetaArray( File $image ) { + $meta = $image->getMetadata(); + + if ( !$meta ) { + return []; + } + $meta = unserialize( $meta ); + if ( !isset( $meta['metadata'] ) ) { + return []; + } + unset( $meta['metadata']['_MW_PNG_VERSION'] ); + + return $meta['metadata']; + } + + /** + * @param File $image + * @return bool + */ + function isAnimatedImage( $image ) { + $ser = $image->getMetadata(); + if ( $ser ) { + $metadata = unserialize( $ser ); + if ( $metadata['frameCount'] > 1 ) { + return true; + } + } + + return false; + } + + /** + * We do not support making APNG thumbnails, so always false + * @param File $image + * @return bool False + */ + function canAnimateThumbnail( $image ) { + return false; + } + + function getMetadataType( $image ) { + return 'parsed-png'; + } + + function isMetadataValid( $image, $metadata ) { + if ( $metadata === self::BROKEN_FILE ) { + // Do not repetitivly regenerate metadata on broken file. + return self::METADATA_GOOD; + } + + Wikimedia\suppressWarnings(); + $data = unserialize( $metadata ); + Wikimedia\restoreWarnings(); + + if ( !$data || !is_array( $data ) ) { + wfDebug( __METHOD__ . " invalid png metadata\n" ); + + return self::METADATA_BAD; + } + + if ( !isset( $data['metadata']['_MW_PNG_VERSION'] ) + || $data['metadata']['_MW_PNG_VERSION'] != PNGMetadataExtractor::VERSION + ) { + wfDebug( __METHOD__ . " old but compatible png metadata\n" ); + + return self::METADATA_COMPATIBLE; + } + + return self::METADATA_GOOD; + } + + /** + * @param File $image + * @return string + */ + function getLongDesc( $image ) { + global $wgLang; + $original = parent::getLongDesc( $image ); + + Wikimedia\suppressWarnings(); + $metadata = unserialize( $image->getMetadata() ); + Wikimedia\restoreWarnings(); + + if ( !$metadata || $metadata['frameCount'] <= 0 ) { + return $original; + } + + $info = []; + $info[] = $original; + + if ( $metadata['loopCount'] == 0 ) { + $info[] = wfMessage( 'file-info-png-looped' )->parse(); + } elseif ( $metadata['loopCount'] > 1 ) { + $info[] = wfMessage( 'file-info-png-repeat' )->numParams( $metadata['loopCount'] )->parse(); + } + + if ( $metadata['frameCount'] > 0 ) { + $info[] = wfMessage( 'file-info-png-frames' )->numParams( $metadata['frameCount'] )->parse(); + } + + if ( $metadata['duration'] ) { + $info[] = $wgLang->formatTimePeriod( $metadata['duration'] ); + } + + return $wgLang->commaList( $info ); + } + + /** + * Return the duration of an APNG file. + * + * Shown in the &query=imageinfo&iiprop=size api query. + * + * @param File $file + * @return float The duration of the file. + */ + public function getLength( $file ) { + $serMeta = $file->getMetadata(); + Wikimedia\suppressWarnings(); + $metadata = unserialize( $serMeta ); + Wikimedia\restoreWarnings(); + + if ( !$metadata || !isset( $metadata['duration'] ) || !$metadata['duration'] ) { + return 0.0; + } else { + return (float)$metadata['duration']; + } + } + + // PNGs should be easy to support, but it will need some sharpening applied + // and another user test to check if the perceived quality change is noticeable + public function supportsBucketing() { + return false; + } +} diff --git a/includes/media/SVG.php b/includes/media/SVG.php deleted file mode 100644 index 9085421af8..0000000000 --- a/includes/media/SVG.php +++ /dev/null @@ -1,593 +0,0 @@ - 'ImageWidth', - 'originalheight' => 'ImageLength', - 'description' => 'ImageDescription', - 'title' => 'ObjectName', - ]; - - function isEnabled() { - global $wgSVGConverters, $wgSVGConverter; - if ( !isset( $wgSVGConverters[$wgSVGConverter] ) ) { - wfDebug( "\$wgSVGConverter is invalid, disabling SVG rendering.\n" ); - - return false; - } else { - return true; - } - } - - public function mustRender( $file ) { - return true; - } - - function isVectorized( $file ) { - return true; - } - - /** - * @param File $file - * @return bool - */ - function isAnimatedImage( $file ) { - # @todo Detect animated SVGs - $metadata = $file->getMetadata(); - if ( $metadata ) { - $metadata = $this->unpackMetadata( $metadata ); - if ( isset( $metadata['animated'] ) ) { - return $metadata['animated']; - } - } - - return false; - } - - /** - * Which languages (systemLanguage attribute) is supported. - * - * @note This list is not guaranteed to be exhaustive. - * To avoid OOM errors, we only look at first bit of a file. - * Thus all languages on this list are present in the file, - * but its possible for the file to have a language not on - * this list. - * - * @param File $file - * @return array Array of language codes, or empty if no language switching supported. - */ - public function getAvailableLanguages( File $file ) { - $metadata = $file->getMetadata(); - $langList = []; - if ( $metadata ) { - $metadata = $this->unpackMetadata( $metadata ); - if ( isset( $metadata['translations'] ) ) { - foreach ( $metadata['translations'] as $lang => $langType ) { - if ( $langType === SVGReader::LANG_FULL_MATCH ) { - $langList[] = strtolower( $lang ); - } - } - } - } - return array_unique( $langList ); - } - - /** - * SVG's systemLanguage matching rules state: - * 'The `systemLanguage` attribute ... [e]valuates to "true" if one of the languages indicated - * by user preferences exactly equals one of the languages given in the value of this parameter, - * or if one of the languages indicated by user preferences exactly equals a prefix of one of - * the languages given in the value of this parameter such that the first tag character - * following the prefix is "-".' - * - * Return the first element of $svgLanguages that matches $userPreferredLanguage - * - * @see https://www.w3.org/TR/SVG/struct.html#SystemLanguageAttribute - * @param string $userPreferredLanguage - * @param array $svgLanguages - * @return string|null - */ - public function getMatchedLanguage( $userPreferredLanguage, array $svgLanguages ) { - foreach ( $svgLanguages as $svgLang ) { - if ( strcasecmp( $svgLang, $userPreferredLanguage ) === 0 ) { - return $svgLang; - } - $trimmedSvgLang = $svgLang; - while ( strpos( $trimmedSvgLang, '-' ) !== false ) { - $trimmedSvgLang = substr( $trimmedSvgLang, 0, strrpos( $trimmedSvgLang, '-' ) ); - if ( strcasecmp( $trimmedSvgLang, $userPreferredLanguage ) === 0 ) { - return $svgLang; - } - } - } - return null; - } - - /** - * What language to render file in if none selected - * - * @param File $file Language code - * @return string - */ - public function getDefaultRenderLanguage( File $file ) { - return 'en'; - } - - /** - * We do not support making animated svg thumbnails - * @param File $file - * @return bool - */ - function canAnimateThumbnail( $file ) { - return false; - } - - /** - * @param File $image - * @param array &$params - * @return bool - */ - function normaliseParams( $image, &$params ) { - global $wgSVGMaxSize; - if ( !parent::normaliseParams( $image, $params ) ) { - return false; - } - # Don't make an image bigger than wgMaxSVGSize on the smaller side - if ( $params['physicalWidth'] <= $params['physicalHeight'] ) { - if ( $params['physicalWidth'] > $wgSVGMaxSize ) { - $srcWidth = $image->getWidth( $params['page'] ); - $srcHeight = $image->getHeight( $params['page'] ); - $params['physicalWidth'] = $wgSVGMaxSize; - $params['physicalHeight'] = File::scaleHeight( $srcWidth, $srcHeight, $wgSVGMaxSize ); - } - } else { - if ( $params['physicalHeight'] > $wgSVGMaxSize ) { - $srcWidth = $image->getWidth( $params['page'] ); - $srcHeight = $image->getHeight( $params['page'] ); - $params['physicalWidth'] = File::scaleHeight( $srcHeight, $srcWidth, $wgSVGMaxSize ); - $params['physicalHeight'] = $wgSVGMaxSize; - } - } - - return true; - } - - /** - * @param File $image - * @param string $dstPath - * @param string $dstUrl - * @param array $params - * @param int $flags - * @return bool|MediaTransformError|ThumbnailImage|TransformParameterError - */ - function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) { - if ( !$this->normaliseParams( $image, $params ) ) { - return new TransformParameterError( $params ); - } - $clientWidth = $params['width']; - $clientHeight = $params['height']; - $physicalWidth = $params['physicalWidth']; - $physicalHeight = $params['physicalHeight']; - $lang = isset( $params['lang'] ) ? $params['lang'] : $this->getDefaultRenderLanguage( $image ); - - if ( $flags & self::TRANSFORM_LATER ) { - return new ThumbnailImage( $image, $dstUrl, $dstPath, $params ); - } - - $metadata = $this->unpackMetadata( $image->getMetadata() ); - if ( isset( $metadata['error'] ) ) { // sanity check - $err = wfMessage( 'svg-long-error', $metadata['error']['message'] ); - - return new MediaTransformError( 'thumbnail_error', $clientWidth, $clientHeight, $err ); - } - - if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) { - return new MediaTransformError( 'thumbnail_error', $clientWidth, $clientHeight, - wfMessage( 'thumbnail_dest_directory' ) ); - } - - $srcPath = $image->getLocalRefPath(); - if ( $srcPath === false ) { // Failed to get local copy - wfDebugLog( 'thumbnail', - sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"', - wfHostname(), $image->getName() ) ); - - return new MediaTransformError( 'thumbnail_error', - $params['width'], $params['height'], - wfMessage( 'filemissing' ) - ); - } - - // Make a temp dir with a symlink to the local copy in it. - // This plays well with rsvg-convert policy for external entities. - // https://git.gnome.org/browse/librsvg/commit/?id=f01aded72c38f0e18bc7ff67dee800e380251c8e - $tmpDir = wfTempDir() . '/svg_' . wfRandomString( 24 ); - $lnPath = "$tmpDir/" . basename( $srcPath ); - $ok = mkdir( $tmpDir, 0771 ); - if ( !$ok ) { - wfDebugLog( 'thumbnail', - sprintf( 'Thumbnail failed on %s: could not create temporary directory %s', - wfHostname(), $tmpDir ) ); - return new MediaTransformError( 'thumbnail_error', - $params['width'], $params['height'], - wfMessage( 'thumbnail-temp-create' )->text() - ); - } - $ok = symlink( $srcPath, $lnPath ); - /** @noinspection PhpUnusedLocalVariableInspection */ - $cleaner = new ScopedCallback( function () use ( $tmpDir, $lnPath ) { - Wikimedia\suppressWarnings(); - unlink( $lnPath ); - rmdir( $tmpDir ); - Wikimedia\restoreWarnings(); - } ); - if ( !$ok ) { - wfDebugLog( 'thumbnail', - sprintf( 'Thumbnail failed on %s: could not link %s to %s', - wfHostname(), $lnPath, $srcPath ) ); - return new MediaTransformError( 'thumbnail_error', - $params['width'], $params['height'], - wfMessage( 'thumbnail-temp-create' ) - ); - } - - $status = $this->rasterize( $lnPath, $dstPath, $physicalWidth, $physicalHeight, $lang ); - if ( $status === true ) { - return new ThumbnailImage( $image, $dstUrl, $dstPath, $params ); - } else { - return $status; // MediaTransformError - } - } - - /** - * Transform an SVG file to PNG - * This function can be called outside of thumbnail contexts - * @param string $srcPath - * @param string $dstPath - * @param string $width - * @param string $height - * @param bool|string $lang Language code of the language to render the SVG in - * @throws MWException - * @return bool|MediaTransformError - */ - public function rasterize( $srcPath, $dstPath, $width, $height, $lang = false ) { - global $wgSVGConverters, $wgSVGConverter, $wgSVGConverterPath; - $err = false; - $retval = ''; - if ( isset( $wgSVGConverters[$wgSVGConverter] ) ) { - if ( is_array( $wgSVGConverters[$wgSVGConverter] ) ) { - // This is a PHP callable - $func = $wgSVGConverters[$wgSVGConverter][0]; - $args = array_merge( [ $srcPath, $dstPath, $width, $height, $lang ], - array_slice( $wgSVGConverters[$wgSVGConverter], 1 ) ); - if ( !is_callable( $func ) ) { - throw new MWException( "$func is not callable" ); - } - $err = call_user_func_array( $func, $args ); - $retval = (bool)$err; - } else { - // External command - $cmd = str_replace( - [ '$path/', '$width', '$height', '$input', '$output' ], - [ $wgSVGConverterPath ? wfEscapeShellArg( "$wgSVGConverterPath/" ) : "", - intval( $width ), - intval( $height ), - wfEscapeShellArg( $srcPath ), - wfEscapeShellArg( $dstPath ) ], - $wgSVGConverters[$wgSVGConverter] - ); - - $env = []; - if ( $lang !== false ) { - $env['LANG'] = $lang; - } - - wfDebug( __METHOD__ . ": $cmd\n" ); - $err = wfShellExecWithStderr( $cmd, $retval, $env ); - } - } - $removed = $this->removeBadFile( $dstPath, $retval ); - if ( $retval != 0 || $removed ) { - $this->logErrorForExternalProcess( $retval, $err, $cmd ); - return new MediaTransformError( 'thumbnail_error', $width, $height, $err ); - } - - return true; - } - - public static function rasterizeImagickExt( $srcPath, $dstPath, $width, $height ) { - $im = new Imagick( $srcPath ); - $im->setImageFormat( 'png' ); - $im->setBackgroundColor( 'transparent' ); - $im->setImageDepth( 8 ); - - if ( !$im->thumbnailImage( intval( $width ), intval( $height ), /* fit */ false ) ) { - return 'Could not resize image'; - } - if ( !$im->writeImage( $dstPath ) ) { - return "Could not write to $dstPath"; - } - } - - /** - * @param File|FSFile $file - * @param string $path Unused - * @param bool|array $metadata - * @return array - */ - function getImageSize( $file, $path, $metadata = false ) { - if ( $metadata === false && $file instanceof File ) { - $metadata = $file->getMetadata(); - } - $metadata = $this->unpackMetadata( $metadata ); - - if ( isset( $metadata['width'] ) && isset( $metadata['height'] ) ) { - return [ $metadata['width'], $metadata['height'], 'SVG', - "width=\"{$metadata['width']}\" height=\"{$metadata['height']}\"" ]; - } else { // error - return [ 0, 0, 'SVG', "width=\"0\" height=\"0\"" ]; - } - } - - function getThumbType( $ext, $mime, $params = null ) { - return [ 'png', 'image/png' ]; - } - - /** - * Subtitle for the image. Different from the base - * class so it can be denoted that SVG's have - * a "nominal" resolution, and not a fixed one, - * as well as so animation can be denoted. - * - * @param File $file - * @return string - */ - function getLongDesc( $file ) { - global $wgLang; - - $metadata = $this->unpackMetadata( $file->getMetadata() ); - if ( isset( $metadata['error'] ) ) { - return wfMessage( 'svg-long-error', $metadata['error']['message'] )->text(); - } - - $size = $wgLang->formatSize( $file->getSize() ); - - if ( $this->isAnimatedImage( $file ) ) { - $msg = wfMessage( 'svg-long-desc-animated' ); - } else { - $msg = wfMessage( 'svg-long-desc' ); - } - - $msg->numParams( $file->getWidth(), $file->getHeight() )->params( $size ); - - return $msg->parse(); - } - - /** - * @param File|FSFile $file - * @param string $filename - * @return string Serialised metadata - */ - function getMetadata( $file, $filename ) { - $metadata = [ 'version' => self::SVG_METADATA_VERSION ]; - try { - $metadata += SVGMetadataExtractor::getMetadata( $filename ); - } catch ( Exception $e ) { // @todo SVG specific exceptions - // File not found, broken, etc. - $metadata['error'] = [ - 'message' => $e->getMessage(), - 'code' => $e->getCode() - ]; - wfDebug( __METHOD__ . ': ' . $e->getMessage() . "\n" ); - } - - return serialize( $metadata ); - } - - function unpackMetadata( $metadata ) { - Wikimedia\suppressWarnings(); - $unser = unserialize( $metadata ); - Wikimedia\restoreWarnings(); - if ( isset( $unser['version'] ) && $unser['version'] == self::SVG_METADATA_VERSION ) { - return $unser; - } else { - return false; - } - } - - function getMetadataType( $image ) { - return 'parsed-svg'; - } - - function isMetadataValid( $image, $metadata ) { - $meta = $this->unpackMetadata( $metadata ); - if ( $meta === false ) { - return self::METADATA_BAD; - } - if ( !isset( $meta['originalWidth'] ) ) { - // Old but compatible - return self::METADATA_COMPATIBLE; - } - - return self::METADATA_GOOD; - } - - protected function visibleMetadataFields() { - $fields = [ 'objectname', 'imagedescription' ]; - - return $fields; - } - - /** - * @param File $file - * @param bool|IContextSource $context Context to use (optional) - * @return array|bool - */ - function formatMetadata( $file, $context = false ) { - $result = [ - 'visible' => [], - 'collapsed' => [] - ]; - $metadata = $file->getMetadata(); - if ( !$metadata ) { - return false; - } - $metadata = $this->unpackMetadata( $metadata ); - if ( !$metadata || isset( $metadata['error'] ) ) { - return false; - } - - /* @todo Add a formatter - $format = new FormatSVG( $metadata ); - $formatted = $format->getFormattedData(); - */ - - // Sort fields into visible and collapsed - $visibleFields = $this->visibleMetadataFields(); - - $showMeta = false; - foreach ( $metadata as $name => $value ) { - $tag = strtolower( $name ); - if ( isset( self::$metaConversion[$tag] ) ) { - $tag = strtolower( self::$metaConversion[$tag] ); - } else { - // Do not output other metadata not in list - continue; - } - $showMeta = true; - self::addMeta( $result, - in_array( $tag, $visibleFields ) ? 'visible' : 'collapsed', - 'exif', - $tag, - $value - ); - } - - return $showMeta ? $result : false; - } - - /** - * @param string $name Parameter name - * @param mixed $value Parameter value - * @return bool Validity - */ - public function validateParam( $name, $value ) { - if ( in_array( $name, [ 'width', 'height' ] ) ) { - // Reject negative heights, widths - return ( $value > 0 ); - } elseif ( $name == 'lang' ) { - // Validate $code - if ( $value === '' || !Language::isValidCode( $value ) ) { - return false; - } - - return true; - } - - // Only lang, width and height are acceptable keys - return false; - } - - /** - * @param array $params Name=>value pairs of parameters - * @return string Filename to use - */ - public function makeParamString( $params ) { - $lang = ''; - if ( isset( $params['lang'] ) && $params['lang'] !== 'en' ) { - $lang = 'lang' . strtolower( $params['lang'] ) . '-'; - } - if ( !isset( $params['width'] ) ) { - return false; - } - - return "$lang{$params['width']}px"; - } - - public function parseParamString( $str ) { - $m = false; - if ( preg_match( '/^lang([a-z]+(?:-[a-z]+)*)-(\d+)px$/i', $str, $m ) ) { - return [ 'width' => array_pop( $m ), 'lang' => $m[1] ]; - } elseif ( preg_match( '/^(\d+)px$/', $str, $m ) ) { - return [ 'width' => $m[1], 'lang' => 'en' ]; - } else { - return false; - } - } - - public function getParamMap() { - return [ 'img_lang' => 'lang', 'img_width' => 'width' ]; - } - - /** - * @param array $params - * @return array - */ - function getScriptParams( $params ) { - $scriptParams = [ 'width' => $params['width'] ]; - if ( isset( $params['lang'] ) ) { - $scriptParams['lang'] = $params['lang']; - } - - return $scriptParams; - } - - public function getCommonMetaArray( File $file ) { - $metadata = $file->getMetadata(); - if ( !$metadata ) { - return []; - } - $metadata = $this->unpackMetadata( $metadata ); - if ( !$metadata || isset( $metadata['error'] ) ) { - return []; - } - $stdMetadata = []; - foreach ( $metadata as $name => $value ) { - $tag = strtolower( $name ); - if ( $tag === 'originalwidth' || $tag === 'originalheight' ) { - // Skip these. In the exif metadata stuff, it is assumed these - // are measured in px, which is not the case here. - continue; - } - if ( isset( self::$metaConversion[$tag] ) ) { - $tag = self::$metaConversion[$tag]; - $stdMetadata[$tag] = $value; - } - } - - return $stdMetadata; - } -} diff --git a/includes/media/SvgHandler.php b/includes/media/SvgHandler.php new file mode 100644 index 0000000000..9085421af8 --- /dev/null +++ b/includes/media/SvgHandler.php @@ -0,0 +1,593 @@ + 'ImageWidth', + 'originalheight' => 'ImageLength', + 'description' => 'ImageDescription', + 'title' => 'ObjectName', + ]; + + function isEnabled() { + global $wgSVGConverters, $wgSVGConverter; + if ( !isset( $wgSVGConverters[$wgSVGConverter] ) ) { + wfDebug( "\$wgSVGConverter is invalid, disabling SVG rendering.\n" ); + + return false; + } else { + return true; + } + } + + public function mustRender( $file ) { + return true; + } + + function isVectorized( $file ) { + return true; + } + + /** + * @param File $file + * @return bool + */ + function isAnimatedImage( $file ) { + # @todo Detect animated SVGs + $metadata = $file->getMetadata(); + if ( $metadata ) { + $metadata = $this->unpackMetadata( $metadata ); + if ( isset( $metadata['animated'] ) ) { + return $metadata['animated']; + } + } + + return false; + } + + /** + * Which languages (systemLanguage attribute) is supported. + * + * @note This list is not guaranteed to be exhaustive. + * To avoid OOM errors, we only look at first bit of a file. + * Thus all languages on this list are present in the file, + * but its possible for the file to have a language not on + * this list. + * + * @param File $file + * @return array Array of language codes, or empty if no language switching supported. + */ + public function getAvailableLanguages( File $file ) { + $metadata = $file->getMetadata(); + $langList = []; + if ( $metadata ) { + $metadata = $this->unpackMetadata( $metadata ); + if ( isset( $metadata['translations'] ) ) { + foreach ( $metadata['translations'] as $lang => $langType ) { + if ( $langType === SVGReader::LANG_FULL_MATCH ) { + $langList[] = strtolower( $lang ); + } + } + } + } + return array_unique( $langList ); + } + + /** + * SVG's systemLanguage matching rules state: + * 'The `systemLanguage` attribute ... [e]valuates to "true" if one of the languages indicated + * by user preferences exactly equals one of the languages given in the value of this parameter, + * or if one of the languages indicated by user preferences exactly equals a prefix of one of + * the languages given in the value of this parameter such that the first tag character + * following the prefix is "-".' + * + * Return the first element of $svgLanguages that matches $userPreferredLanguage + * + * @see https://www.w3.org/TR/SVG/struct.html#SystemLanguageAttribute + * @param string $userPreferredLanguage + * @param array $svgLanguages + * @return string|null + */ + public function getMatchedLanguage( $userPreferredLanguage, array $svgLanguages ) { + foreach ( $svgLanguages as $svgLang ) { + if ( strcasecmp( $svgLang, $userPreferredLanguage ) === 0 ) { + return $svgLang; + } + $trimmedSvgLang = $svgLang; + while ( strpos( $trimmedSvgLang, '-' ) !== false ) { + $trimmedSvgLang = substr( $trimmedSvgLang, 0, strrpos( $trimmedSvgLang, '-' ) ); + if ( strcasecmp( $trimmedSvgLang, $userPreferredLanguage ) === 0 ) { + return $svgLang; + } + } + } + return null; + } + + /** + * What language to render file in if none selected + * + * @param File $file Language code + * @return string + */ + public function getDefaultRenderLanguage( File $file ) { + return 'en'; + } + + /** + * We do not support making animated svg thumbnails + * @param File $file + * @return bool + */ + function canAnimateThumbnail( $file ) { + return false; + } + + /** + * @param File $image + * @param array &$params + * @return bool + */ + function normaliseParams( $image, &$params ) { + global $wgSVGMaxSize; + if ( !parent::normaliseParams( $image, $params ) ) { + return false; + } + # Don't make an image bigger than wgMaxSVGSize on the smaller side + if ( $params['physicalWidth'] <= $params['physicalHeight'] ) { + if ( $params['physicalWidth'] > $wgSVGMaxSize ) { + $srcWidth = $image->getWidth( $params['page'] ); + $srcHeight = $image->getHeight( $params['page'] ); + $params['physicalWidth'] = $wgSVGMaxSize; + $params['physicalHeight'] = File::scaleHeight( $srcWidth, $srcHeight, $wgSVGMaxSize ); + } + } else { + if ( $params['physicalHeight'] > $wgSVGMaxSize ) { + $srcWidth = $image->getWidth( $params['page'] ); + $srcHeight = $image->getHeight( $params['page'] ); + $params['physicalWidth'] = File::scaleHeight( $srcHeight, $srcWidth, $wgSVGMaxSize ); + $params['physicalHeight'] = $wgSVGMaxSize; + } + } + + return true; + } + + /** + * @param File $image + * @param string $dstPath + * @param string $dstUrl + * @param array $params + * @param int $flags + * @return bool|MediaTransformError|ThumbnailImage|TransformParameterError + */ + function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) { + if ( !$this->normaliseParams( $image, $params ) ) { + return new TransformParameterError( $params ); + } + $clientWidth = $params['width']; + $clientHeight = $params['height']; + $physicalWidth = $params['physicalWidth']; + $physicalHeight = $params['physicalHeight']; + $lang = isset( $params['lang'] ) ? $params['lang'] : $this->getDefaultRenderLanguage( $image ); + + if ( $flags & self::TRANSFORM_LATER ) { + return new ThumbnailImage( $image, $dstUrl, $dstPath, $params ); + } + + $metadata = $this->unpackMetadata( $image->getMetadata() ); + if ( isset( $metadata['error'] ) ) { // sanity check + $err = wfMessage( 'svg-long-error', $metadata['error']['message'] ); + + return new MediaTransformError( 'thumbnail_error', $clientWidth, $clientHeight, $err ); + } + + if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) { + return new MediaTransformError( 'thumbnail_error', $clientWidth, $clientHeight, + wfMessage( 'thumbnail_dest_directory' ) ); + } + + $srcPath = $image->getLocalRefPath(); + if ( $srcPath === false ) { // Failed to get local copy + wfDebugLog( 'thumbnail', + sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"', + wfHostname(), $image->getName() ) ); + + return new MediaTransformError( 'thumbnail_error', + $params['width'], $params['height'], + wfMessage( 'filemissing' ) + ); + } + + // Make a temp dir with a symlink to the local copy in it. + // This plays well with rsvg-convert policy for external entities. + // https://git.gnome.org/browse/librsvg/commit/?id=f01aded72c38f0e18bc7ff67dee800e380251c8e + $tmpDir = wfTempDir() . '/svg_' . wfRandomString( 24 ); + $lnPath = "$tmpDir/" . basename( $srcPath ); + $ok = mkdir( $tmpDir, 0771 ); + if ( !$ok ) { + wfDebugLog( 'thumbnail', + sprintf( 'Thumbnail failed on %s: could not create temporary directory %s', + wfHostname(), $tmpDir ) ); + return new MediaTransformError( 'thumbnail_error', + $params['width'], $params['height'], + wfMessage( 'thumbnail-temp-create' )->text() + ); + } + $ok = symlink( $srcPath, $lnPath ); + /** @noinspection PhpUnusedLocalVariableInspection */ + $cleaner = new ScopedCallback( function () use ( $tmpDir, $lnPath ) { + Wikimedia\suppressWarnings(); + unlink( $lnPath ); + rmdir( $tmpDir ); + Wikimedia\restoreWarnings(); + } ); + if ( !$ok ) { + wfDebugLog( 'thumbnail', + sprintf( 'Thumbnail failed on %s: could not link %s to %s', + wfHostname(), $lnPath, $srcPath ) ); + return new MediaTransformError( 'thumbnail_error', + $params['width'], $params['height'], + wfMessage( 'thumbnail-temp-create' ) + ); + } + + $status = $this->rasterize( $lnPath, $dstPath, $physicalWidth, $physicalHeight, $lang ); + if ( $status === true ) { + return new ThumbnailImage( $image, $dstUrl, $dstPath, $params ); + } else { + return $status; // MediaTransformError + } + } + + /** + * Transform an SVG file to PNG + * This function can be called outside of thumbnail contexts + * @param string $srcPath + * @param string $dstPath + * @param string $width + * @param string $height + * @param bool|string $lang Language code of the language to render the SVG in + * @throws MWException + * @return bool|MediaTransformError + */ + public function rasterize( $srcPath, $dstPath, $width, $height, $lang = false ) { + global $wgSVGConverters, $wgSVGConverter, $wgSVGConverterPath; + $err = false; + $retval = ''; + if ( isset( $wgSVGConverters[$wgSVGConverter] ) ) { + if ( is_array( $wgSVGConverters[$wgSVGConverter] ) ) { + // This is a PHP callable + $func = $wgSVGConverters[$wgSVGConverter][0]; + $args = array_merge( [ $srcPath, $dstPath, $width, $height, $lang ], + array_slice( $wgSVGConverters[$wgSVGConverter], 1 ) ); + if ( !is_callable( $func ) ) { + throw new MWException( "$func is not callable" ); + } + $err = call_user_func_array( $func, $args ); + $retval = (bool)$err; + } else { + // External command + $cmd = str_replace( + [ '$path/', '$width', '$height', '$input', '$output' ], + [ $wgSVGConverterPath ? wfEscapeShellArg( "$wgSVGConverterPath/" ) : "", + intval( $width ), + intval( $height ), + wfEscapeShellArg( $srcPath ), + wfEscapeShellArg( $dstPath ) ], + $wgSVGConverters[$wgSVGConverter] + ); + + $env = []; + if ( $lang !== false ) { + $env['LANG'] = $lang; + } + + wfDebug( __METHOD__ . ": $cmd\n" ); + $err = wfShellExecWithStderr( $cmd, $retval, $env ); + } + } + $removed = $this->removeBadFile( $dstPath, $retval ); + if ( $retval != 0 || $removed ) { + $this->logErrorForExternalProcess( $retval, $err, $cmd ); + return new MediaTransformError( 'thumbnail_error', $width, $height, $err ); + } + + return true; + } + + public static function rasterizeImagickExt( $srcPath, $dstPath, $width, $height ) { + $im = new Imagick( $srcPath ); + $im->setImageFormat( 'png' ); + $im->setBackgroundColor( 'transparent' ); + $im->setImageDepth( 8 ); + + if ( !$im->thumbnailImage( intval( $width ), intval( $height ), /* fit */ false ) ) { + return 'Could not resize image'; + } + if ( !$im->writeImage( $dstPath ) ) { + return "Could not write to $dstPath"; + } + } + + /** + * @param File|FSFile $file + * @param string $path Unused + * @param bool|array $metadata + * @return array + */ + function getImageSize( $file, $path, $metadata = false ) { + if ( $metadata === false && $file instanceof File ) { + $metadata = $file->getMetadata(); + } + $metadata = $this->unpackMetadata( $metadata ); + + if ( isset( $metadata['width'] ) && isset( $metadata['height'] ) ) { + return [ $metadata['width'], $metadata['height'], 'SVG', + "width=\"{$metadata['width']}\" height=\"{$metadata['height']}\"" ]; + } else { // error + return [ 0, 0, 'SVG', "width=\"0\" height=\"0\"" ]; + } + } + + function getThumbType( $ext, $mime, $params = null ) { + return [ 'png', 'image/png' ]; + } + + /** + * Subtitle for the image. Different from the base + * class so it can be denoted that SVG's have + * a "nominal" resolution, and not a fixed one, + * as well as so animation can be denoted. + * + * @param File $file + * @return string + */ + function getLongDesc( $file ) { + global $wgLang; + + $metadata = $this->unpackMetadata( $file->getMetadata() ); + if ( isset( $metadata['error'] ) ) { + return wfMessage( 'svg-long-error', $metadata['error']['message'] )->text(); + } + + $size = $wgLang->formatSize( $file->getSize() ); + + if ( $this->isAnimatedImage( $file ) ) { + $msg = wfMessage( 'svg-long-desc-animated' ); + } else { + $msg = wfMessage( 'svg-long-desc' ); + } + + $msg->numParams( $file->getWidth(), $file->getHeight() )->params( $size ); + + return $msg->parse(); + } + + /** + * @param File|FSFile $file + * @param string $filename + * @return string Serialised metadata + */ + function getMetadata( $file, $filename ) { + $metadata = [ 'version' => self::SVG_METADATA_VERSION ]; + try { + $metadata += SVGMetadataExtractor::getMetadata( $filename ); + } catch ( Exception $e ) { // @todo SVG specific exceptions + // File not found, broken, etc. + $metadata['error'] = [ + 'message' => $e->getMessage(), + 'code' => $e->getCode() + ]; + wfDebug( __METHOD__ . ': ' . $e->getMessage() . "\n" ); + } + + return serialize( $metadata ); + } + + function unpackMetadata( $metadata ) { + Wikimedia\suppressWarnings(); + $unser = unserialize( $metadata ); + Wikimedia\restoreWarnings(); + if ( isset( $unser['version'] ) && $unser['version'] == self::SVG_METADATA_VERSION ) { + return $unser; + } else { + return false; + } + } + + function getMetadataType( $image ) { + return 'parsed-svg'; + } + + function isMetadataValid( $image, $metadata ) { + $meta = $this->unpackMetadata( $metadata ); + if ( $meta === false ) { + return self::METADATA_BAD; + } + if ( !isset( $meta['originalWidth'] ) ) { + // Old but compatible + return self::METADATA_COMPATIBLE; + } + + return self::METADATA_GOOD; + } + + protected function visibleMetadataFields() { + $fields = [ 'objectname', 'imagedescription' ]; + + return $fields; + } + + /** + * @param File $file + * @param bool|IContextSource $context Context to use (optional) + * @return array|bool + */ + function formatMetadata( $file, $context = false ) { + $result = [ + 'visible' => [], + 'collapsed' => [] + ]; + $metadata = $file->getMetadata(); + if ( !$metadata ) { + return false; + } + $metadata = $this->unpackMetadata( $metadata ); + if ( !$metadata || isset( $metadata['error'] ) ) { + return false; + } + + /* @todo Add a formatter + $format = new FormatSVG( $metadata ); + $formatted = $format->getFormattedData(); + */ + + // Sort fields into visible and collapsed + $visibleFields = $this->visibleMetadataFields(); + + $showMeta = false; + foreach ( $metadata as $name => $value ) { + $tag = strtolower( $name ); + if ( isset( self::$metaConversion[$tag] ) ) { + $tag = strtolower( self::$metaConversion[$tag] ); + } else { + // Do not output other metadata not in list + continue; + } + $showMeta = true; + self::addMeta( $result, + in_array( $tag, $visibleFields ) ? 'visible' : 'collapsed', + 'exif', + $tag, + $value + ); + } + + return $showMeta ? $result : false; + } + + /** + * @param string $name Parameter name + * @param mixed $value Parameter value + * @return bool Validity + */ + public function validateParam( $name, $value ) { + if ( in_array( $name, [ 'width', 'height' ] ) ) { + // Reject negative heights, widths + return ( $value > 0 ); + } elseif ( $name == 'lang' ) { + // Validate $code + if ( $value === '' || !Language::isValidCode( $value ) ) { + return false; + } + + return true; + } + + // Only lang, width and height are acceptable keys + return false; + } + + /** + * @param array $params Name=>value pairs of parameters + * @return string Filename to use + */ + public function makeParamString( $params ) { + $lang = ''; + if ( isset( $params['lang'] ) && $params['lang'] !== 'en' ) { + $lang = 'lang' . strtolower( $params['lang'] ) . '-'; + } + if ( !isset( $params['width'] ) ) { + return false; + } + + return "$lang{$params['width']}px"; + } + + public function parseParamString( $str ) { + $m = false; + if ( preg_match( '/^lang([a-z]+(?:-[a-z]+)*)-(\d+)px$/i', $str, $m ) ) { + return [ 'width' => array_pop( $m ), 'lang' => $m[1] ]; + } elseif ( preg_match( '/^(\d+)px$/', $str, $m ) ) { + return [ 'width' => $m[1], 'lang' => 'en' ]; + } else { + return false; + } + } + + public function getParamMap() { + return [ 'img_lang' => 'lang', 'img_width' => 'width' ]; + } + + /** + * @param array $params + * @return array + */ + function getScriptParams( $params ) { + $scriptParams = [ 'width' => $params['width'] ]; + if ( isset( $params['lang'] ) ) { + $scriptParams['lang'] = $params['lang']; + } + + return $scriptParams; + } + + public function getCommonMetaArray( File $file ) { + $metadata = $file->getMetadata(); + if ( !$metadata ) { + return []; + } + $metadata = $this->unpackMetadata( $metadata ); + if ( !$metadata || isset( $metadata['error'] ) ) { + return []; + } + $stdMetadata = []; + foreach ( $metadata as $name => $value ) { + $tag = strtolower( $name ); + if ( $tag === 'originalwidth' || $tag === 'originalheight' ) { + // Skip these. In the exif metadata stuff, it is assumed these + // are measured in px, which is not the case here. + continue; + } + if ( isset( self::$metaConversion[$tag] ) ) { + $tag = self::$metaConversion[$tag]; + $stdMetadata[$tag] = $value; + } + } + + return $stdMetadata; + } +} diff --git a/includes/media/Tiff.php b/includes/media/Tiff.php deleted file mode 100644 index f0f4cdad6c..0000000000 --- a/includes/media/Tiff.php +++ /dev/null @@ -1,107 +0,0 @@ -getRepo() instanceof ForeignAPIRepo; - } - - /** - * Browsers don't support TIFF inline generally... - * For inline display, we need to convert to PNG. - * - * @param File $file - * @return bool - */ - public function mustRender( $file ) { - return true; - } - - /** - * @param string $ext - * @param string $mime - * @param array $params - * @return bool - */ - function getThumbType( $ext, $mime, $params = null ) { - global $wgTiffThumbnailType; - - return $wgTiffThumbnailType; - } - - /** - * @param File|FSFile $image - * @param string $filename - * @throws MWException - * @return string - */ - function getMetadata( $image, $filename ) { - global $wgShowEXIF; - - if ( $wgShowEXIF ) { - try { - $meta = BitmapMetadataHandler::Tiff( $filename ); - if ( !is_array( $meta ) ) { - // This should never happen, but doesn't hurt to be paranoid. - throw new MWException( 'Metadata array is not an array' ); - } - $meta['MEDIAWIKI_EXIF_VERSION'] = Exif::version(); - - return serialize( $meta ); - } catch ( Exception $e ) { - // BitmapMetadataHandler throws an exception in certain exceptional - // cases like if file does not exist. - wfDebug( __METHOD__ . ': ' . $e->getMessage() . "\n" ); - - return ExifBitmapHandler::BROKEN_FILE; - } - } else { - return ''; - } - } - - public function isExpensiveToThumbnail( $file ) { - return $file->getSize() > static::EXPENSIVE_SIZE_LIMIT; - } -} diff --git a/includes/media/TiffHandler.php b/includes/media/TiffHandler.php new file mode 100644 index 0000000000..f0f4cdad6c --- /dev/null +++ b/includes/media/TiffHandler.php @@ -0,0 +1,107 @@ +getRepo() instanceof ForeignAPIRepo; + } + + /** + * Browsers don't support TIFF inline generally... + * For inline display, we need to convert to PNG. + * + * @param File $file + * @return bool + */ + public function mustRender( $file ) { + return true; + } + + /** + * @param string $ext + * @param string $mime + * @param array $params + * @return bool + */ + function getThumbType( $ext, $mime, $params = null ) { + global $wgTiffThumbnailType; + + return $wgTiffThumbnailType; + } + + /** + * @param File|FSFile $image + * @param string $filename + * @throws MWException + * @return string + */ + function getMetadata( $image, $filename ) { + global $wgShowEXIF; + + if ( $wgShowEXIF ) { + try { + $meta = BitmapMetadataHandler::Tiff( $filename ); + if ( !is_array( $meta ) ) { + // This should never happen, but doesn't hurt to be paranoid. + throw new MWException( 'Metadata array is not an array' ); + } + $meta['MEDIAWIKI_EXIF_VERSION'] = Exif::version(); + + return serialize( $meta ); + } catch ( Exception $e ) { + // BitmapMetadataHandler throws an exception in certain exceptional + // cases like if file does not exist. + wfDebug( __METHOD__ . ': ' . $e->getMessage() . "\n" ); + + return ExifBitmapHandler::BROKEN_FILE; + } + } else { + return ''; + } + } + + public function isExpensiveToThumbnail( $file ) { + return $file->getSize() > static::EXPENSIVE_SIZE_LIMIT; + } +} diff --git a/includes/media/WebP.php b/includes/media/WebP.php deleted file mode 100644 index 295a9785b5..0000000000 --- a/includes/media/WebP.php +++ /dev/null @@ -1,309 +0,0 @@ - - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 2 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, write to the Free Software Foundation, Inc., - * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - * http://www.gnu.org/copyleft/gpl.html - * - * @file - * @ingroup Media - */ - -/** - * Handler for Google's WebP format - * - * @ingroup Media - */ -class WebPHandler extends BitmapHandler { - const BROKEN_FILE = '0'; // value to store in img_metadata if error extracting metadata. - /** - * @var int Minimum chunk header size to be able to read all header types - */ - const MINIMUM_CHUNK_HEADER_LENGTH = 18; - /** - * @var int version of the metadata stored in db records - */ - const _MW_WEBP_VERSION = 1; - - const VP8X_ICC = 32; - const VP8X_ALPHA = 16; - const VP8X_EXIF = 8; - const VP8X_XMP = 4; - const VP8X_ANIM = 2; - - public function getMetadata( $image, $filename ) { - $parsedWebPData = self::extractMetadata( $filename ); - if ( !$parsedWebPData ) { - return self::BROKEN_FILE; - } - - $parsedWebPData['metadata']['_MW_WEBP_VERSION'] = self::_MW_WEBP_VERSION; - return serialize( $parsedWebPData ); - } - - public function getMetadataType( $image ) { - return 'parsed-webp'; - } - - public function isMetadataValid( $image, $metadata ) { - if ( $metadata === self::BROKEN_FILE ) { - // Do not repetitivly regenerate metadata on broken file. - return self::METADATA_GOOD; - } - - Wikimedia\suppressWarnings(); - $data = unserialize( $metadata ); - Wikimedia\restoreWarnings(); - - if ( !$data || !is_array( $data ) ) { - wfDebug( __METHOD__ . " invalid WebP metadata\n" ); - - return self::METADATA_BAD; - } - - if ( !isset( $data['metadata']['_MW_WEBP_VERSION'] ) - || $data['metadata']['_MW_WEBP_VERSION'] != self::_MW_WEBP_VERSION - ) { - wfDebug( __METHOD__ . " old but compatible WebP metadata\n" ); - - return self::METADATA_COMPATIBLE; - } - return self::METADATA_GOOD; - } - - /** - * Extracts the image size and WebP type from a file - * - * @param string $filename - * @return array|bool Header data array with entries 'compression', 'width' and 'height', - * where 'compression' can be 'lossy', 'lossless', 'animated' or 'unknown'. False if - * file is not a valid WebP file. - */ - public static function extractMetadata( $filename ) { - wfDebugLog( 'WebP', __METHOD__ . ": Extracting metadata from $filename\n" ); - - $info = RiffExtractor::findChunksFromFile( $filename, 100 ); - if ( $info === false ) { - wfDebugLog( 'WebP', __METHOD__ . ": Not a valid RIFF file\n" ); - return false; - } - - if ( $info['fourCC'] != 'WEBP' ) { - wfDebugLog( 'WebP', __METHOD__ . ': FourCC was not WEBP: ' . - bin2hex( $info['fourCC'] ) . " \n" ); - return false; - } - - $metadata = self::extractMetadataFromChunks( $info['chunks'], $filename ); - if ( !$metadata ) { - wfDebugLog( 'WebP', __METHOD__ . ": No VP8 chunks found\n" ); - return false; - } - - return $metadata; - } - - /** - * Extracts the image size and WebP type from a file based on the chunk list - * @param array $chunks Chunks as extracted by RiffExtractor - * @param string $filename - * @return array Header data array with entries 'compression', 'width' and 'height', where - * 'compression' can be 'lossy', 'lossless', 'animated' or 'unknown' - */ - public static function extractMetadataFromChunks( $chunks, $filename ) { - $vp8Info = []; - - foreach ( $chunks as $chunk ) { - if ( !in_array( $chunk['fourCC'], [ 'VP8 ', 'VP8L', 'VP8X' ] ) ) { - // Not a chunk containing interesting metadata - continue; - } - - $chunkHeader = file_get_contents( $filename, false, null, - $chunk['start'], self::MINIMUM_CHUNK_HEADER_LENGTH ); - wfDebugLog( 'WebP', __METHOD__ . ": {$chunk['fourCC']}\n" ); - - switch ( $chunk['fourCC'] ) { - case 'VP8 ': - return array_merge( $vp8Info, - self::decodeLossyChunkHeader( $chunkHeader ) ); - case 'VP8L': - return array_merge( $vp8Info, - self::decodeLosslessChunkHeader( $chunkHeader ) ); - case 'VP8X': - $vp8Info = array_merge( $vp8Info, - self::decodeExtendedChunkHeader( $chunkHeader ) ); - // Continue looking for other chunks to improve the metadata - break; - } - } - return $vp8Info; - } - - /** - * Decodes a lossy chunk header - * @param string $header First few bytes of the header, expected to be at least 18 bytes long - * @return bool|array See WebPHandler::decodeHeader - */ - protected static function decodeLossyChunkHeader( $header ) { - // Bytes 0-3 are 'VP8 ' - // Bytes 4-7 are the VP8 stream size - // Bytes 8-10 are the frame tag - // Bytes 11-13 are 0x9D 0x01 0x2A called the sync code - $syncCode = substr( $header, 11, 3 ); - if ( $syncCode != "\x9D\x01\x2A" ) { - wfDebugLog( 'WebP', __METHOD__ . ': Invalid sync code: ' . - bin2hex( $syncCode ) . "\n" ); - return []; - } - // Bytes 14-17 are image size - $imageSize = unpack( 'v2', substr( $header, 14, 4 ) ); - // Image sizes are 14 bit, 2 MSB are scaling parameters which are ignored here - return [ - 'compression' => 'lossy', - 'width' => $imageSize[1] & 0x3FFF, - 'height' => $imageSize[2] & 0x3FFF - ]; - } - - /** - * Decodes a lossless chunk header - * @param string $header First few bytes of the header, expected to be at least 13 bytes long - * @return bool|array See WebPHandler::decodeHeader - */ - public static function decodeLosslessChunkHeader( $header ) { - // Bytes 0-3 are 'VP8L' - // Bytes 4-7 are chunk stream size - // Byte 8 is 0x2F called the signature - if ( $header{8} != "\x2F" ) { - wfDebugLog( 'WebP', __METHOD__ . ': Invalid signature: ' . - bin2hex( $header{8} ) . "\n" ); - return []; - } - // Bytes 9-12 contain the image size - // Bits 0-13 are width-1; bits 15-27 are height-1 - $imageSize = unpack( 'C4', substr( $header, 9, 4 ) ); - return [ - 'compression' => 'lossless', - 'width' => ( $imageSize[1] | ( ( $imageSize[2] & 0x3F ) << 8 ) ) + 1, - 'height' => ( ( ( $imageSize[2] & 0xC0 ) >> 6 ) | - ( $imageSize[3] << 2 ) | ( ( $imageSize[4] & 0x03 ) << 10 ) ) + 1 - ]; - } - - /** - * Decodes an extended chunk header - * @param string $header First few bytes of the header, expected to be at least 18 bytes long - * @return bool|array See WebPHandler::decodeHeader - */ - public static function decodeExtendedChunkHeader( $header ) { - // Bytes 0-3 are 'VP8X' - // Byte 4-7 are chunk length - // Byte 8-11 are a flag bytes - $flags = unpack( 'c', substr( $header, 8, 1 ) ); - - // Byte 12-17 are image size (24 bits) - $width = unpack( 'V', substr( $header, 12, 3 ) . "\x00" ); - $height = unpack( 'V', substr( $header, 15, 3 ) . "\x00" ); - - return [ - 'compression' => 'unknown', - 'animated' => ( $flags[1] & self::VP8X_ANIM ) == self::VP8X_ANIM, - 'transparency' => ( $flags[1] & self::VP8X_ALPHA ) == self::VP8X_ALPHA, - 'width' => ( $width[1] & 0xFFFFFF ) + 1, - 'height' => ( $height[1] & 0xFFFFFF ) + 1 - ]; - } - - public function getImageSize( $file, $path, $metadata = false ) { - if ( $file === null ) { - $metadata = self::getMetadata( $file, $path ); - } - if ( $metadata === false && $file instanceof File ) { - $metadata = $file->getMetadata(); - } - - Wikimedia\suppressWarnings(); - $metadata = unserialize( $metadata ); - Wikimedia\restoreWarnings(); - - if ( $metadata == false ) { - return false; - } - return [ $metadata['width'], $metadata['height'] ]; - } - - /** - * @param File $file - * @return bool True, not all browsers support WebP - */ - public function mustRender( $file ) { - return true; - } - - /** - * @param File $file - * @return bool False if we are unable to render this image - */ - public function canRender( $file ) { - if ( self::isAnimatedImage( $file ) ) { - return false; - } - return true; - } - - /** - * @param File $image - * @return bool - */ - public function isAnimatedImage( $image ) { - $ser = $image->getMetadata(); - if ( $ser ) { - $metadata = unserialize( $ser ); - if ( isset( $metadata['animated'] ) && $metadata['animated'] === true ) { - return true; - } - } - - return false; - } - - public function canAnimateThumbnail( $file ) { - return false; - } - - /** - * Render files as PNG - * - * @param string $ext - * @param string $mime - * @param array|null $params - * @return array - */ - public function getThumbType( $ext, $mime, $params = null ) { - return [ 'png', 'image/png' ]; - } - - /** - * Must use "im" for XCF - * - * @param string $dstPath - * @param bool $checkDstPath - * @return string - */ - protected function getScalerType( $dstPath, $checkDstPath = true ) { - return 'im'; - } -} diff --git a/includes/media/WebPHandler.php b/includes/media/WebPHandler.php new file mode 100644 index 0000000000..295a9785b5 --- /dev/null +++ b/includes/media/WebPHandler.php @@ -0,0 +1,309 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + * @ingroup Media + */ + +/** + * Handler for Google's WebP format + * + * @ingroup Media + */ +class WebPHandler extends BitmapHandler { + const BROKEN_FILE = '0'; // value to store in img_metadata if error extracting metadata. + /** + * @var int Minimum chunk header size to be able to read all header types + */ + const MINIMUM_CHUNK_HEADER_LENGTH = 18; + /** + * @var int version of the metadata stored in db records + */ + const _MW_WEBP_VERSION = 1; + + const VP8X_ICC = 32; + const VP8X_ALPHA = 16; + const VP8X_EXIF = 8; + const VP8X_XMP = 4; + const VP8X_ANIM = 2; + + public function getMetadata( $image, $filename ) { + $parsedWebPData = self::extractMetadata( $filename ); + if ( !$parsedWebPData ) { + return self::BROKEN_FILE; + } + + $parsedWebPData['metadata']['_MW_WEBP_VERSION'] = self::_MW_WEBP_VERSION; + return serialize( $parsedWebPData ); + } + + public function getMetadataType( $image ) { + return 'parsed-webp'; + } + + public function isMetadataValid( $image, $metadata ) { + if ( $metadata === self::BROKEN_FILE ) { + // Do not repetitivly regenerate metadata on broken file. + return self::METADATA_GOOD; + } + + Wikimedia\suppressWarnings(); + $data = unserialize( $metadata ); + Wikimedia\restoreWarnings(); + + if ( !$data || !is_array( $data ) ) { + wfDebug( __METHOD__ . " invalid WebP metadata\n" ); + + return self::METADATA_BAD; + } + + if ( !isset( $data['metadata']['_MW_WEBP_VERSION'] ) + || $data['metadata']['_MW_WEBP_VERSION'] != self::_MW_WEBP_VERSION + ) { + wfDebug( __METHOD__ . " old but compatible WebP metadata\n" ); + + return self::METADATA_COMPATIBLE; + } + return self::METADATA_GOOD; + } + + /** + * Extracts the image size and WebP type from a file + * + * @param string $filename + * @return array|bool Header data array with entries 'compression', 'width' and 'height', + * where 'compression' can be 'lossy', 'lossless', 'animated' or 'unknown'. False if + * file is not a valid WebP file. + */ + public static function extractMetadata( $filename ) { + wfDebugLog( 'WebP', __METHOD__ . ": Extracting metadata from $filename\n" ); + + $info = RiffExtractor::findChunksFromFile( $filename, 100 ); + if ( $info === false ) { + wfDebugLog( 'WebP', __METHOD__ . ": Not a valid RIFF file\n" ); + return false; + } + + if ( $info['fourCC'] != 'WEBP' ) { + wfDebugLog( 'WebP', __METHOD__ . ': FourCC was not WEBP: ' . + bin2hex( $info['fourCC'] ) . " \n" ); + return false; + } + + $metadata = self::extractMetadataFromChunks( $info['chunks'], $filename ); + if ( !$metadata ) { + wfDebugLog( 'WebP', __METHOD__ . ": No VP8 chunks found\n" ); + return false; + } + + return $metadata; + } + + /** + * Extracts the image size and WebP type from a file based on the chunk list + * @param array $chunks Chunks as extracted by RiffExtractor + * @param string $filename + * @return array Header data array with entries 'compression', 'width' and 'height', where + * 'compression' can be 'lossy', 'lossless', 'animated' or 'unknown' + */ + public static function extractMetadataFromChunks( $chunks, $filename ) { + $vp8Info = []; + + foreach ( $chunks as $chunk ) { + if ( !in_array( $chunk['fourCC'], [ 'VP8 ', 'VP8L', 'VP8X' ] ) ) { + // Not a chunk containing interesting metadata + continue; + } + + $chunkHeader = file_get_contents( $filename, false, null, + $chunk['start'], self::MINIMUM_CHUNK_HEADER_LENGTH ); + wfDebugLog( 'WebP', __METHOD__ . ": {$chunk['fourCC']}\n" ); + + switch ( $chunk['fourCC'] ) { + case 'VP8 ': + return array_merge( $vp8Info, + self::decodeLossyChunkHeader( $chunkHeader ) ); + case 'VP8L': + return array_merge( $vp8Info, + self::decodeLosslessChunkHeader( $chunkHeader ) ); + case 'VP8X': + $vp8Info = array_merge( $vp8Info, + self::decodeExtendedChunkHeader( $chunkHeader ) ); + // Continue looking for other chunks to improve the metadata + break; + } + } + return $vp8Info; + } + + /** + * Decodes a lossy chunk header + * @param string $header First few bytes of the header, expected to be at least 18 bytes long + * @return bool|array See WebPHandler::decodeHeader + */ + protected static function decodeLossyChunkHeader( $header ) { + // Bytes 0-3 are 'VP8 ' + // Bytes 4-7 are the VP8 stream size + // Bytes 8-10 are the frame tag + // Bytes 11-13 are 0x9D 0x01 0x2A called the sync code + $syncCode = substr( $header, 11, 3 ); + if ( $syncCode != "\x9D\x01\x2A" ) { + wfDebugLog( 'WebP', __METHOD__ . ': Invalid sync code: ' . + bin2hex( $syncCode ) . "\n" ); + return []; + } + // Bytes 14-17 are image size + $imageSize = unpack( 'v2', substr( $header, 14, 4 ) ); + // Image sizes are 14 bit, 2 MSB are scaling parameters which are ignored here + return [ + 'compression' => 'lossy', + 'width' => $imageSize[1] & 0x3FFF, + 'height' => $imageSize[2] & 0x3FFF + ]; + } + + /** + * Decodes a lossless chunk header + * @param string $header First few bytes of the header, expected to be at least 13 bytes long + * @return bool|array See WebPHandler::decodeHeader + */ + public static function decodeLosslessChunkHeader( $header ) { + // Bytes 0-3 are 'VP8L' + // Bytes 4-7 are chunk stream size + // Byte 8 is 0x2F called the signature + if ( $header{8} != "\x2F" ) { + wfDebugLog( 'WebP', __METHOD__ . ': Invalid signature: ' . + bin2hex( $header{8} ) . "\n" ); + return []; + } + // Bytes 9-12 contain the image size + // Bits 0-13 are width-1; bits 15-27 are height-1 + $imageSize = unpack( 'C4', substr( $header, 9, 4 ) ); + return [ + 'compression' => 'lossless', + 'width' => ( $imageSize[1] | ( ( $imageSize[2] & 0x3F ) << 8 ) ) + 1, + 'height' => ( ( ( $imageSize[2] & 0xC0 ) >> 6 ) | + ( $imageSize[3] << 2 ) | ( ( $imageSize[4] & 0x03 ) << 10 ) ) + 1 + ]; + } + + /** + * Decodes an extended chunk header + * @param string $header First few bytes of the header, expected to be at least 18 bytes long + * @return bool|array See WebPHandler::decodeHeader + */ + public static function decodeExtendedChunkHeader( $header ) { + // Bytes 0-3 are 'VP8X' + // Byte 4-7 are chunk length + // Byte 8-11 are a flag bytes + $flags = unpack( 'c', substr( $header, 8, 1 ) ); + + // Byte 12-17 are image size (24 bits) + $width = unpack( 'V', substr( $header, 12, 3 ) . "\x00" ); + $height = unpack( 'V', substr( $header, 15, 3 ) . "\x00" ); + + return [ + 'compression' => 'unknown', + 'animated' => ( $flags[1] & self::VP8X_ANIM ) == self::VP8X_ANIM, + 'transparency' => ( $flags[1] & self::VP8X_ALPHA ) == self::VP8X_ALPHA, + 'width' => ( $width[1] & 0xFFFFFF ) + 1, + 'height' => ( $height[1] & 0xFFFFFF ) + 1 + ]; + } + + public function getImageSize( $file, $path, $metadata = false ) { + if ( $file === null ) { + $metadata = self::getMetadata( $file, $path ); + } + if ( $metadata === false && $file instanceof File ) { + $metadata = $file->getMetadata(); + } + + Wikimedia\suppressWarnings(); + $metadata = unserialize( $metadata ); + Wikimedia\restoreWarnings(); + + if ( $metadata == false ) { + return false; + } + return [ $metadata['width'], $metadata['height'] ]; + } + + /** + * @param File $file + * @return bool True, not all browsers support WebP + */ + public function mustRender( $file ) { + return true; + } + + /** + * @param File $file + * @return bool False if we are unable to render this image + */ + public function canRender( $file ) { + if ( self::isAnimatedImage( $file ) ) { + return false; + } + return true; + } + + /** + * @param File $image + * @return bool + */ + public function isAnimatedImage( $image ) { + $ser = $image->getMetadata(); + if ( $ser ) { + $metadata = unserialize( $ser ); + if ( isset( $metadata['animated'] ) && $metadata['animated'] === true ) { + return true; + } + } + + return false; + } + + public function canAnimateThumbnail( $file ) { + return false; + } + + /** + * Render files as PNG + * + * @param string $ext + * @param string $mime + * @param array|null $params + * @return array + */ + public function getThumbType( $ext, $mime, $params = null ) { + return [ 'png', 'image/png' ]; + } + + /** + * Must use "im" for XCF + * + * @param string $dstPath + * @param bool $checkDstPath + * @return string + */ + protected function getScalerType( $dstPath, $checkDstPath = true ) { + return 'im'; + } +} diff --git a/includes/objectcache/ObjectCache.php b/includes/objectcache/ObjectCache.php index 67d2346013..c384032220 100644 --- a/includes/objectcache/ObjectCache.php +++ b/includes/objectcache/ObjectCache.php @@ -337,9 +337,11 @@ class ObjectCache { $services = MediaWikiServices::getInstance(); $erGroup = $services->getEventRelayerGroup(); - foreach ( $params['channels'] as $action => $channel ) { - $params['relayers'][$action] = $erGroup->getRelayer( $channel ); - $params['channels'][$action] = $channel; + if ( isset( $params['channels'] ) ) { + foreach ( $params['channels'] as $action => $channel ) { + $params['relayers'][$action] = $erGroup->getRelayer( $channel ); + $params['channels'][$action] = $channel; + } } $params['cache'] = self::newFromParams( $params['store'] ); if ( isset( $params['loggroup'] ) ) { @@ -411,4 +413,21 @@ class ObjectCache { self::$instances = []; self::$wanInstances = []; } + + /** + * Detects which local server cache library is present and returns a configuration for it + * @since 1.32 + * + * @return int|string Index to cache in $wgObjectCaches + */ + public static function detectLocalServerCache() { + if ( function_exists( 'apc_fetch' ) ) { + return 'apc'; + } elseif ( function_exists( 'apcu_fetch' ) ) { + return 'apcu'; + } elseif ( function_exists( 'wincache_ucache_get' ) ) { + return 'wincache'; + } + return CACHE_NONE; + } } diff --git a/includes/objectcache/SqlBagOStuff.php b/includes/objectcache/SqlBagOStuff.php index 6d35658151..8ff14ed788 100644 --- a/includes/objectcache/SqlBagOStuff.php +++ b/includes/objectcache/SqlBagOStuff.php @@ -181,7 +181,7 @@ class SqlBagOStuff extends BagOStuff { $index = $this->replicaOnly ? DB_REPLICA : DB_MASTER; if ( $lb->getServerType( $lb->getWriterIndex() ) !== 'sqlite' ) { // Keep a separate connection to avoid contention and deadlocks - $db = $lb->getConnection( $index, [], false, $lb::CONN_TRX_AUTO ); + $db = $lb->getConnection( $index, [], false, $lb::CONN_TRX_AUTOCOMMIT ); // @TODO: Use a blank trx profiler to ignore expections as this is a cache } else { // However, SQLite has the opposite behavior due to DB-level locking. diff --git a/includes/page/Article.php b/includes/page/Article.php index 3cbeacffa7..c865d4ed53 100644 --- a/includes/page/Article.php +++ b/includes/page/Article.php @@ -577,7 +577,16 @@ class Article implements Page { # Preload timestamp to avoid a DB hit $outputPage->setRevisionTimestamp( $this->mPage->getTimestamp() ); - if ( !Hooks::run( 'ArticleContentViewCustom', + # Pages containing custom CSS or JavaScript get special treatment + if ( $this->getTitle()->isSiteConfigPage() || $this->getTitle()->isUserConfigPage() ) { + $dir = $this->getContext()->getLanguage()->getDir(); + $lang = $this->getContext()->getLanguage()->getHtmlCode(); + + $outputPage->wrapWikiMsg( + "
    \n$1\n
    ", + 'clearyourcache' + ); + } elseif ( !Hooks::run( 'ArticleContentViewCustom', [ $this->fetchContentObject(), $this->getTitle(), $outputPage ] ) ) { # Allow extensions do their own custom view for certain pages @@ -965,7 +974,7 @@ class Article implements Page { * @return bool */ public function showPatrolFooter() { - global $wgUseNPPatrol, $wgUseRCPatrol, $wgUseFilePatrol, $wgEnableAPI, $wgEnableWriteAPI; + global $wgUseNPPatrol, $wgUseRCPatrol, $wgUseFilePatrol; $outputPage = $this->getContext()->getOutput(); $user = $this->getContext()->getUser(); @@ -1056,8 +1065,7 @@ class Article implements Page { 'rc_namespace' => NS_FILE, 'rc_cur_id' => $title->getArticleID() ], - __METHOD__, - [ 'USE INDEX' => 'rc_timestamp' ] + __METHOD__ ); if ( $rc ) { // Use patrol message specific to files @@ -1100,7 +1108,7 @@ class Article implements Page { } $outputPage->preventClickjacking(); - if ( $wgEnableAPI && $wgEnableWriteAPI && $user->isAllowed( 'writeapi' ) ) { + if ( $user->isAllowed( 'writeapi' ) ) { $outputPage->addModules( 'mediawiki.page.patrol.ajax' ); } diff --git a/includes/page/PageArchive.php b/includes/page/PageArchive.php index 05247cafeb..8b42020af2 100644 --- a/includes/page/PageArchive.php +++ b/includes/page/PageArchive.php @@ -315,19 +315,13 @@ class PageArchive { } /** - * Get the text from an archive row containing ar_text, ar_flags and ar_text_id + * Get the text from an archive row containing ar_text_id * + * @deprecated since 1.31 * @param object $row Database row * @return string */ public function getTextFromRow( $row ) { - if ( is_null( $row->ar_text_id ) ) { - // An old row from MediaWiki 1.4 or previous. - // Text is embedded in this row in classic compression format. - return Revision::getRevisionText( $row, 'ar_' ); - } - - // New-style: keyed to the text storage backend. $dbr = wfGetDB( DB_REPLICA ); $text = $dbr->selectRow( 'text', [ 'old_text', 'old_flags' ], @@ -347,15 +341,18 @@ class PageArchive { */ public function getLastRevisionText() { $dbr = wfGetDB( DB_REPLICA ); - $row = $dbr->selectRow( 'archive', - [ 'ar_text', 'ar_flags', 'ar_text_id' ], + $row = $dbr->selectRow( + [ 'archive', 'text' ], + [ 'old_text', 'old_flags' ], [ 'ar_namespace' => $this->title->getNamespace(), 'ar_title' => $this->title->getDBkey() ], __METHOD__, - [ 'ORDER BY' => 'ar_timestamp DESC' ] ); + [ 'ORDER BY' => 'ar_timestamp DESC, ar_id DESC' ], + [ 'text' => [ 'JOIN', 'old_id = ar_text_id' ] ] + ); if ( $row ) { - return $this->getTextFromRow( $row ); + return Revision::getRevisionText( $row ); } return null; diff --git a/includes/page/WikiPage.php b/includes/page/WikiPage.php index f45036c1db..cbce884dfc 100644 --- a/includes/page/WikiPage.php +++ b/includes/page/WikiPage.php @@ -2117,7 +2117,12 @@ class WikiPage implements Page, IDBAccessObject { : DB_REPLICA; // T154554 $edit->popts->setSpeculativeRevIdCallback( function () use ( $dbIndex ) { - return 1 + (int)wfGetDB( $dbIndex )->selectField( + $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)', [], @@ -2871,13 +2876,32 @@ class WikiPage implements Page, IDBAccessObject { // In the future, we may keep revisions and mark them with // the rev_deleted field, which is reserved for this purpose. + // Lock rows in `revision` and its temp tables, but not any others. + // 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. + $res = $dbw->select( + array_intersect( + $revQuery['tables'], + [ 'revision', 'revision_comment_temp', 'revision_actor_temp' ] + ), + '1', + [ 'rev_page' => $id ], + __METHOD__, + 'FOR UPDATE', + $revQuery['joins'] + ); + foreach ( $res as $row ) { + // Fetch all rows in case the DB needs that to properly lock them. + } + // Get all of the page revisions $res = $dbw->select( $revQuery['tables'], $revQuery['fields'], [ 'rev_page' => $id ], __METHOD__, - 'FOR UPDATE', + [], $revQuery['joins'] ); @@ -2899,8 +2923,6 @@ class WikiPage implements Page, IDBAccessObject { 'ar_rev_id' => $row->rev_id, 'ar_parent_id' => $row->rev_parent_id, 'ar_text_id' => $row->rev_text_id, - 'ar_text' => '', - 'ar_flags' => '', 'ar_len' => $row->rev_len, 'ar_page_id' => $id, 'ar_deleted' => $suppress ? $bitfield : $row->rev_deleted, @@ -2956,7 +2978,7 @@ class WikiPage implements Page, IDBAccessObject { $logid = $logEntry->insert(); $dbw->onTransactionPreCommitOrIdle( - function () use ( $dbw, $logEntry, $logid ) { + function () use ( $logEntry, $logid ) { // T58776: avoid deadlocks (especially from FileDeleteForm) $logEntry->publish( $logid ); }, @@ -3269,7 +3291,7 @@ class WikiPage implements Page, IDBAccessObject { if ( $wgUseRCPatrol ) { // Mark all reverted edits as patrolled - $set['rc_patrolled'] = 1; + $set['rc_patrolled'] = RecentChange::PRC_PATROLLED; } if ( count( $set ) ) { @@ -3597,14 +3619,12 @@ class WikiPage implements Page, IDBAccessObject { Hooks::run( 'CategoryAfterPageRemoved', [ $cat, $this, $id ] ); } - // Refresh counts on categories that should be empty now, to - // trigger possible deletion. Check master for the most - // up-to-date cat_pages. + // Refresh counts on categories that should be empty now if ( count( $deleted ) ) { $rows = $dbw->select( 'category', [ 'cat_id', 'cat_title', 'cat_pages', 'cat_subcats', 'cat_files' ], - [ 'cat_title' => $deleted, 'cat_pages <= 0' ], + [ 'cat_title' => $deleted, 'cat_pages <= 100' ], __METHOD__ ); foreach ( $rows as $row ) { diff --git a/includes/pager/IndexPager.php b/includes/pager/IndexPager.php index d1c98f22fc..6880d588d4 100644 --- a/includes/pager/IndexPager.php +++ b/includes/pager/IndexPager.php @@ -21,7 +21,7 @@ * @ingroup Pager */ -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\IDatabase; /** @@ -124,7 +124,7 @@ abstract class IndexPager extends ContextSource implements Pager { /** * Result object for the query. Warning: seek before use. * - * @var ResultWrapper + * @var IResultWrapper */ public $mResult; @@ -232,7 +232,7 @@ abstract class IndexPager extends ContextSource implements Pager { } /** - * @return ResultWrapper The result wrapper. + * @return IResultWrapper The result wrapper. */ function getResult() { return $this->mResult; @@ -292,9 +292,9 @@ abstract class IndexPager extends ContextSource implements Pager { * @param bool $isFirst False if there are rows before those fetched (i.e. * if a "previous" link would make sense) * @param int $limit Exact query limit - * @param ResultWrapper $res + * @param IResultWrapper $res */ - function extractResultInfo( $isFirst, $limit, ResultWrapper $res ) { + function extractResultInfo( $isFirst, $limit, IResultWrapper $res ) { $numRows = $res->numRows(); if ( $numRows ) { # Remove any table prefix from index field @@ -359,7 +359,7 @@ abstract class IndexPager extends ContextSource implements Pager { * @param string $offset Index offset, inclusive * @param int $limit Exact query limit * @param bool $descending Query direction, false for ascending, true for descending - * @return ResultWrapper + * @return IResultWrapper */ public function reallyDoQuery( $offset, $limit, $descending ) { list( $tables, $fields, $conds, $fname, $options, $join_conds ) = @@ -406,7 +406,7 @@ abstract class IndexPager extends ContextSource implements Pager { /** * Pre-process results; useful for performing batch existence checks, etc. * - * @param ResultWrapper $result + * @param IResultWrapper $result */ protected function preprocessResults( $result ) { } diff --git a/includes/parser/BlockLevelPass.php b/includes/parser/BlockLevelPass.php index acdc652806..c3669032d3 100644 --- a/includes/parser/BlockLevelPass.php +++ b/includes/parser/BlockLevelPass.php @@ -291,23 +291,42 @@ class BlockLevelPass { if ( 0 == $prefixLength ) { # No prefix (not in list)--go to paragraph mode # @todo consider using a stack for nestable elements like span, table and div + + // P-wrapping and indent-pre are suppressed inside, not outside + $blockElems = 'table|h1|h2|h3|h4|h5|h6|pre|p|ul|ol|dl|li'; + // P-wrapping and indent-pre are suppressed outside, not inside + $antiBlockElems = 'td|th'; + $openMatch = preg_match( - '/(?:closeParagraph(); + // Only close the paragraph if we're not inside a
     tag, or if
    +					// that 
     tag has just been opened
    +					if ( !$this->inPre || $preOpenMatch ) {
    +						// @todo T7718: paragraph closed
    +						$output .= $this->closeParagraph();
    +					}
     					if ( $preOpenMatch && !$preCloseMatch ) {
     						$this->inPre = true;
     					}
    diff --git a/includes/parser/CacheTime.php b/includes/parser/CacheTime.php
    index 05bcebef64..26d5bdd3f9 100644
    --- a/includes/parser/CacheTime.php
    +++ b/includes/parser/CacheTime.php
    @@ -27,21 +27,31 @@
      * @ingroup Parser
      */
     class CacheTime {
    -	/** @var array|bool ParserOptions which have been taken into account to
    -	 * produce output or false if not available.
    +	/**
    +	 * @var string[] ParserOptions which have been taken into account to produce output.
     	 */
     	public $mUsedOptions;
     
    -	# Compatibility check
    +	/**
    +	 * @var string|null Compatibility check
    +	 */
     	public $mVersion = Parser::VERSION;
     
    -	# Time when this object was generated, or -1 for uncacheable. Used in ParserCache.
    +	/**
    +	 * @var string|int TS_MW timestamp when this object was generated, or -1 for uncacheable. Used
    +	 * in ParserCache.
    +	 */
     	public $mCacheTime = '';
     
    -	# Seconds after which the object should expire, use 0 for uncacheable. Used in ParserCache.
    +	/**
    +	 * @var int|null Seconds after which the object should expire, use 0 for uncacheable. Used in
    +	 * ParserCache.
    +	 */
     	public $mCacheExpiry = null;
     
    -	# Revision ID that was parsed
    +	/**
    +	 * @var int|null Revision ID that was parsed
    +	 */
     	public $mCacheRevisionId = null;
     
     	/**
    @@ -71,7 +81,7 @@ class CacheTime {
     
     	/**
     	 * @since 1.23
    -	 * @param int $id Revision id
    +	 * @param int|null $id Revision ID
     	 */
     	public function setCacheRevisionId( $id ) {
     		$this->mCacheRevisionId = $id;
    @@ -105,7 +115,7 @@ class CacheTime {
     	 * The value returned by getCacheExpiry is smaller or equal to the smallest number
     	 * that was provided to a call of updateCacheExpiry(), and smaller or equal to the
     	 * value of $wgParserCacheExpireTime.
    -	 * @return int|mixed|null
    +	 * @return int
     	 */
     	public function getCacheExpiry() {
     		global $wgParserCacheExpireTime;
    diff --git a/includes/parser/DateFormatter.php b/includes/parser/DateFormatter.php
    index 0a4a60e9e5..2aefc0396f 100644
    --- a/includes/parser/DateFormatter.php
    +++ b/includes/parser/DateFormatter.php
    @@ -130,13 +130,10 @@ class DateFormatter {
     	 *     Defaults to the site content language
     	 * @return DateFormatter
     	 */
    -	public static function getInstance( $lang = null ) {
    +	public static function getInstance( Language $lang = null ) {
     		global $wgContLang, $wgMainCacheType;
     
    -		if ( is_string( $lang ) ) {
    -			wfDeprecated( __METHOD__ . ' with type string for $lang', '1.31' );
    -		}
    -		$lang = $lang ? wfGetLangObj( $lang ) : $wgContLang;
    +		$lang = $lang ?: $wgContLang;
     		$cache = ObjectCache::getLocalServerInstance( $wgMainCacheType );
     
     		static $dateFormatter = false;
    diff --git a/includes/parser/MWTidy.php b/includes/parser/MWTidy.php
    index 330859d4b5..5788986f2e 100644
    --- a/includes/parser/MWTidy.php
    +++ b/includes/parser/MWTidy.php
    @@ -52,27 +52,6 @@ class MWTidy {
     		return $driver->tidy( $text );
     	}
     
    -	/**
    -	 * Check HTML for errors, used if $wgValidateAllHtml = true.
    -	 *
    -	 * @param string $text
    -	 * @param string &$errorStr Return the error string
    -	 * @return bool Whether the HTML is valid
    -	 * @throws MWException
    -	 */
    -	public static function checkErrors( $text, &$errorStr = null ) {
    -		$driver = self::singleton();
    -		if ( !$driver ) {
    -			throw new MWException( __METHOD__ .
    -				': tidy is disabled, caller should have checked MWTidy::isEnabled()' );
    -		}
    -		if ( $driver->supportsValidate() ) {
    -			return $driver->validate( $text, $errorStr );
    -		} else {
    -			throw new MWException( __METHOD__ . ": tidy driver does not support validate()" );
    -		}
    -	}
    -
     	/**
     	 * @return bool
     	 */
    @@ -132,12 +111,6 @@ class MWTidy {
     			case 'RaggettExternal':
     				$instance = new MediaWiki\Tidy\RaggettExternal( $config );
     				break;
    -			case 'Html5Depurate':
    -				$instance = new MediaWiki\Tidy\Html5Depurate( $config );
    -				break;
    -			case 'Html5Internal':
    -				$instance = new MediaWiki\Tidy\Html5Internal( $config );
    -				break;
     			case 'RemexHtml':
     				$instance = new MediaWiki\Tidy\RemexDriver( $config );
     				break;
    diff --git a/includes/parser/Parser.php b/includes/parser/Parser.php
    index d34257fa5b..b66031cc88 100644
    --- a/includes/parser/Parser.php
    +++ b/includes/parser/Parser.php
    @@ -1471,7 +1471,7 @@ class Parser {
     	/**
     	 * @throws MWException
     	 * @param array $m
    -	 * @return HTML|string
    +	 * @return string HTML
     	 */
     	public function magicLinkCallback( $m ) {
     		if ( isset( $m[1] ) && $m[1] !== '' ) {
    diff --git a/includes/parser/ParserCache.php b/includes/parser/ParserCache.php
    index 8a7fca6c29..e6326e6c69 100644
    --- a/includes/parser/ParserCache.php
    +++ b/includes/parser/ParserCache.php
    @@ -291,8 +291,8 @@ class ParserCache {
     	 * @param ParserOutput $parserOutput
     	 * @param WikiPage $page
     	 * @param ParserOptions $popts
    -	 * @param string $cacheTime Time when the cache was generated
    -	 * @param int $revId Revision ID that was parsed
    +	 * @param string|null $cacheTime TS_MW timestamp when the cache was generated
    +	 * @param int|null $revId Revision ID that was parsed
     	 */
     	public function save( $parserOutput, $page, $popts, $cacheTime = null, $revId = null ) {
     		$expire = $parserOutput->getCacheExpiry();
    diff --git a/includes/parser/ParserOptions.php b/includes/parser/ParserOptions.php
    index ff21ef0239..8fb98579f2 100644
    --- a/includes/parser/ParserOptions.php
    +++ b/includes/parser/ParserOptions.php
    @@ -1211,7 +1211,7 @@ class ParserOptions {
     	 * in 1.16.
     	 * Used to get the old parser cache entries when available.
     	 * @deprecated since 1.30. You probably want self::allCacheVaryingOptions() instead.
    -	 * @return array
    +	 * @return string[]
     	 */
     	public static function legacyOptions() {
     		wfDeprecated( __METHOD__, '1.30' );
    @@ -1268,7 +1268,7 @@ class ParserOptions {
     	 * the same cached data safely.
     	 *
     	 * @since 1.17
    -	 * @param array $forOptions
    +	 * @param string[] $forOptions
     	 * @param Title $title Used to get the content language of the page (since r97636)
     	 * @return string Page rendering hash
     	 */
    diff --git a/includes/parser/ParserOutput.php b/includes/parser/ParserOutput.php
    index 8f0a1d7cc9..aa015a6eb1 100644
    --- a/includes/parser/ParserOutput.php
    +++ b/includes/parser/ParserOutput.php
    @@ -177,7 +177,7 @@ class ParserOutput extends CacheTime {
     	private $mIndexPolicy = '';
     
     	/**
    -	 * @var array $mAccessedOptions List of ParserOptions (stored in the keys).
    +	 * @var true[] $mAccessedOptions List of ParserOptions (stored in the keys).
     	 */
     	private $mAccessedOptions = [];
     
    @@ -685,9 +685,8 @@ class ParserOutput extends CacheTime {
     	/**
     	 * Register a file dependency for this output
     	 * @param string $name Title dbKey
    -	 * @param string $timestamp MW timestamp of file creation (or false if non-existing)
    -	 * @param string $sha1 Base 36 SHA-1 of file (or false if non-existing)
    -	 * @return void
    +	 * @param string|false|null $timestamp MW timestamp of file creation (or false if non-existing)
    +	 * @param string|false|null $sha1 Base 36 SHA-1 of file (or false if non-existing)
     	 */
     	public function addImage( $name, $timestamp = null, $sha1 = null ) {
     		$this->mImages[$name] = 1;
    @@ -701,7 +700,6 @@ class ParserOutput extends CacheTime {
     	 * @param Title $title
     	 * @param int $page_id
     	 * @param int $rev_id
    -	 * @return void
     	 */
     	public function addTemplate( $title, $page_id, $rev_id ) {
     		$ns = $title->getNamespace();
    @@ -746,14 +744,24 @@ class ParserOutput extends CacheTime {
     		}
     	}
     
    +	/**
    +	 * @see OutputPage::addModules
    +	 */
     	public function addModules( $modules ) {
     		$this->mModules = array_merge( $this->mModules, (array)$modules );
     	}
     
    +	/**
    +	 * @deprecated since 1.31 Use addModules() instead.
    +	 * @see OutputPage::addModuleScripts
    +	 */
     	public function addModuleScripts( $modules ) {
     		$this->mModuleScripts = array_merge( $this->mModuleScripts, (array)$modules );
     	}
     
    +	/**
    +	 * @see OutputPage::addModuleStyles
    +	 */
     	public function addModuleStyles( $modules ) {
     		$this->mModuleStyles = array_merge( $this->mModuleStyles, (array)$modules );
     	}
    @@ -968,8 +976,8 @@ class ParserOutput extends CacheTime {
     
     	/**
     	 * Returns the options from its ParserOptions which have been taken
    -	 * into account to produce this output or false if not available.
    -	 * @return array
    +	 * into account to produce this output.
    +	 * @return string[]
     	 */
     	public function getUsedOptions() {
     		if ( !isset( $this->mAccessedOptions ) ) {
    diff --git a/includes/parser/Preprocessor_DOM.php b/includes/parser/Preprocessor_DOM.php
    index 64edbb2f6c..104cd135e9 100644
    --- a/includes/parser/Preprocessor_DOM.php
    +++ b/includes/parser/Preprocessor_DOM.php
    @@ -566,6 +566,8 @@ class Preprocessor_DOM extends Preprocessor {
     			} elseif ( $found == 'line-end' ) {
     				$piece = $stack->top;
     				// A heading must be open, otherwise \n wouldn't have been in the search list
    +				// FIXME: Don't use assert()
    +				// phpcs:ignore MediaWiki.Usage.ForbiddenFunctions.assert
     				assert( $piece->open === "\n" );
     				$part = $piece->getCurrentPart();
     				// Search back through the input to see if it has a proper close.
    diff --git a/includes/parser/Preprocessor_Hash.php b/includes/parser/Preprocessor_Hash.php
    index c7f630d5db..8e74380c4d 100644
    --- a/includes/parser/Preprocessor_Hash.php
    +++ b/includes/parser/Preprocessor_Hash.php
    @@ -504,6 +504,8 @@ class Preprocessor_Hash extends Preprocessor {
     			} elseif ( $found == 'line-end' ) {
     				$piece = $stack->top;
     				// A heading must be open, otherwise \n wouldn't have been in the search list
    +				// FIXME: Don't use assert()
    +				// phpcs:ignore MediaWiki.Usage.ForbiddenFunctions.assert
     				assert( $piece->open === "\n" );
     				$part = $piece->getCurrentPart();
     				// Search back through the input to see if it has a proper close.
    diff --git a/includes/parser/Sanitizer.php b/includes/parser/Sanitizer.php
    index b13e59787f..118442db0a 100644
    --- a/includes/parser/Sanitizer.php
    +++ b/includes/parser/Sanitizer.php
    @@ -1180,13 +1180,12 @@ class Sanitizer {
     
     	/**
     	 * Given a value, escape it so that it can be used in an id attribute and
    -	 * return it.  This will use HTML5 validation if $wgExperimentalHtmlIds is
    -	 * true, allowing anything but ASCII whitespace.  Otherwise it will use
    -	 * HTML 4 rules, which means a narrow subset of ASCII, with bad characters
    -	 * escaped with lots of dots.
    +	 * return it.  This will use HTML5 validation, allowing anything but ASCII
    +	 * whitespace.
    +	 *
    +	 * To ensure we don't have to bother escaping anything, we also strip ', ".
    +	 * TODO: Is this the best tactic?
     	 *
    -	 * To ensure we don't have to bother escaping anything, we also strip ', ",
    -	 * & even if $wgExperimentalIds is true.  TODO: Is this the best tactic?
     	 * We also strip # because it upsets IE, and % because it could be
     	 * ambiguous if it's part of something that looks like a percent escape
     	 * (which don't work reliably in fragments cross-browser).
    @@ -1204,28 +1203,12 @@ class Sanitizer {
     	 * @param string|array $options String or array of strings (default is array()):
     	 *   'noninitial': This is a non-initial fragment of an id, not a full id,
     	 *       so don't pay attention if the first character isn't valid at the
    -	 *       beginning of an id.  Only matters if $wgExperimentalHtmlIds is
    -	 *       false.
    -	 *   'legacy': Behave the way the old HTML 4-based ID escaping worked even
    -	 *       if $wgExperimentalHtmlIds is used, so we can generate extra
    -	 *       anchors and links won't break.
    +	 *       beginning of an id.
     	 * @return string
     	 */
     	static function escapeId( $id, $options = [] ) {
    -		global $wgExperimentalHtmlIds;
     		$options = (array)$options;
     
    -		if ( $wgExperimentalHtmlIds && !in_array( 'legacy', $options ) ) {
    -			$id = preg_replace( '/[ \t\n\r\f_\'"&#%]+/', '_', $id );
    -			$id = trim( $id, '_' );
    -			if ( $id === '' ) {
    -				// Must have been all whitespace to start with.
    -				return '_';
    -			} else {
    -				return $id;
    -			}
    -		}
    -
     		// HTML4-style escaping
     		static $replace = [
     			'%3A' => ':',
    @@ -1337,14 +1320,6 @@ class Sanitizer {
     				$id = urlencode( str_replace( ' ', '_', $id ) );
     				$id = strtr( $id, $replace );
     				break;
    -			case 'html5-legacy':
    -				$id = preg_replace( '/[ \t\n\r\f_\'"&#%]+/', '_', $id );
    -				$id = trim( $id, '_' );
    -				if ( $id === '' ) {
    -					// Must have been all whitespace to start with.
    -					$id = '_';
    -				}
    -				break;
     			default:
     				throw new InvalidArgumentException( "Invalid mode '$mode' passed to '" . __METHOD__ );
     		}
    diff --git a/includes/preferences/DefaultPreferencesFactory.php b/includes/preferences/DefaultPreferencesFactory.php
    index b2b68d21f9..2d7d73fac3 100644
    --- a/includes/preferences/DefaultPreferencesFactory.php
    +++ b/includes/preferences/DefaultPreferencesFactory.php
    @@ -42,11 +42,16 @@ use MessageLocalizer;
     use MWException;
     use MWNamespace;
     use MWTimestamp;
    +use OutputPage;
     use Parser;
     use ParserOptions;
     use PreferencesForm;
    +use PreferencesFormOOUI;
    +use Psr\Log\LoggerAwareTrait;
    +use Psr\Log\NullLogger;
     use Skin;
     use SpecialPage;
    +use SpecialPreferences;
     use Status;
     use Title;
     use User;
    @@ -57,6 +62,7 @@ use Xml;
      * This is the default implementation of PreferencesFactory.
      */
     class DefaultPreferencesFactory implements PreferencesFactory {
    +	use LoggerAwareTrait;
     
     	/** @var Config */
     	protected $config;
    @@ -86,6 +92,7 @@ class DefaultPreferencesFactory implements PreferencesFactory {
     		$this->contLang = $contLang;
     		$this->authManager = $authManager;
     		$this->linkRenderer = $linkRenderer;
    +		$this->logger = new NullLogger();
     	}
     
     	/**
    @@ -123,6 +130,13 @@ class DefaultPreferencesFactory implements PreferencesFactory {
     	public function getFormDescriptor( User $user, IContextSource $context ) {
     		$preferences = [];
     
    +		if ( SpecialPreferences::isOouiEnabled( $context ) ) {
    +			OutputPage::setupOOUI(
    +				strtolower( $context->getSkin()->getSkinName() ),
    +				$context->getLanguage()->getDir()
    +			);
    +		}
    +
     		$canIPUseHTTPS = wfCanIPUseHTTPS( $context->getRequest()->getIP() );
     		$this->profilePreferences( $user, $context, $preferences, $canIPUseHTTPS );
     		$this->skinPreferences( $user, $context, $preferences );
    @@ -137,6 +151,7 @@ class DefaultPreferencesFactory implements PreferencesFactory {
     		Hooks::run( 'GetPreferences', [ $user, &$preferences ] );
     
     		$this->loadPreferenceValues( $user, $context, $preferences );
    +		$this->logger->debug( "Created form descriptor for user '{$user->getName()}'" );
     		return $preferences;
     	}
     
    @@ -249,6 +264,8 @@ class DefaultPreferencesFactory implements PreferencesFactory {
     	protected function profilePreferences(
     		User $user, IContextSource $context, &$defaultPreferences, $canIPUseHTTPS
     	) {
    +		$oouiEnabled = SpecialPreferences::isOouiEnabled( $context );
    +
     		// retrieving user name for GENDER and misc.
     		$userName = $user->getName();
     
    @@ -360,13 +377,23 @@ class DefaultPreferencesFactory implements PreferencesFactory {
     		if ( $canEditPrivateInfo && $this->authManager->allowsAuthenticationDataChange(
     			new PasswordAuthenticationRequest(), false )->isGood()
     		) {
    -			$link = $this->linkRenderer->makeLink( SpecialPage::getTitleFor( 'ChangePassword' ),
    -				$context->msg( 'prefs-resetpass' )->text(), [],
    -				[ 'returnto' => SpecialPage::getTitleFor( 'Preferences' )->getPrefixedText() ] );
    +			if ( $oouiEnabled ) {
    +				$link = new \OOUI\ButtonWidget( [
    +					'href' => SpecialPage::getTitleFor( 'ChangePassword' )->getLinkURL( [
    +						'returnto' => SpecialPage::getTitleFor( 'Preferences' )->getPrefixedText()
    +					] ),
    +					'label' => $context->msg( 'prefs-resetpass' )->text(),
    +				] );
    +			} else {
    +				$link = $this->linkRenderer->makeLink( SpecialPage::getTitleFor( 'ChangePassword' ),
    +					$context->msg( 'prefs-resetpass' )->text(), [],
    +					[ 'returnto' => SpecialPage::getTitleFor( 'Preferences' )->getPrefixedText() ] );
    +			}
    +
     			$defaultPreferences['password'] = [
     				'type' => 'info',
     				'raw' => true,
    -				'default' => $link,
    +				'default' => (string)$link,
     				'label-message' => 'yourpassword',
     				'section' => 'personal/info',
     			];
    @@ -514,16 +541,28 @@ class DefaultPreferencesFactory implements PreferencesFactory {
     
     				$emailAddress = $user->getEmail() ? htmlspecialchars( $user->getEmail() ) : '';
     				if ( $canEditPrivateInfo && $this->authManager->allowsPropertyChange( 'emailaddress' ) ) {
    -					$link = $this->linkRenderer->makeLink(
    -						SpecialPage::getTitleFor( 'ChangeEmail' ),
    -						$context->msg( $user->getEmail() ? 'prefs-changeemail' : 'prefs-setemail' )->text(),
    -						[],
    -						[ 'returnto' => SpecialPage::getTitleFor( 'Preferences' )->getPrefixedText() ] );
    -
    -					$emailAddress .= $emailAddress == '' ? $link : (
    -						$context->msg( 'word-separator' )->escaped()
    -						. $context->msg( 'parentheses' )->rawParams( $link )->escaped()
    -					);
    +					if ( $oouiEnabled ) {
    +						$link = new \OOUI\ButtonWidget( [
    +							'href' => SpecialPage::getTitleFor( 'ChangeEmail' )->getLinkURL( [
    +								'returnto' => SpecialPage::getTitleFor( 'Preferences' )->getPrefixedText()
    +							] ),
    +							'label' =>
    +								$context->msg( $user->getEmail() ? 'prefs-changeemail' : 'prefs-setemail' )->text(),
    +						] );
    +
    +						$emailAddress .= $emailAddress == '' ? $link : ( '
    ' . $link ); + } else { + $link = $this->linkRenderer->makeLink( + SpecialPage::getTitleFor( 'ChangeEmail' ), + $context->msg( $user->getEmail() ? 'prefs-changeemail' : 'prefs-setemail' )->text(), + [], + [ 'returnto' => SpecialPage::getTitleFor( 'Preferences' )->getPrefixedText() ] ); + + $emailAddress .= $emailAddress == '' ? $link : ( + $context->msg( 'word-separator' )->escaped() + . $context->msg( 'parentheses' )->rawParams( $link )->escaped() + ); + } } $defaultPreferences['emailaddress'] = [ @@ -557,11 +596,19 @@ class DefaultPreferencesFactory implements PreferencesFactory { $emailauthenticationclass = 'mw-email-authenticated'; } else { $disableEmailPrefs = true; - $emailauthenticated = $context->msg( 'emailnotauthenticated' )->parse() . '
    ' . - $this->linkRenderer->makeKnownLink( - SpecialPage::getTitleFor( 'Confirmemail' ), - $context->msg( 'emailconfirmlink' )->text() - ) . '
    '; + if ( $oouiEnabled ) { + $emailauthenticated = $context->msg( 'emailnotauthenticated' )->parse() . '
    ' . + new \OOUI\ButtonWidget( [ + 'href' => SpecialPage::getTitleFor( 'Confirmemail' )->getLinkURL(), + 'label' => $context->msg( 'emailconfirmlink' )->text(), + ] ); + } else { + $emailauthenticated = $context->msg( 'emailnotauthenticated' )->parse() . '
    ' . + $this->linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Confirmemail' ), + $context->msg( 'emailconfirmlink' )->text() + ) . '
    '; + } $emailauthenticationclass = "mw-email-not-authenticated"; } } else { @@ -805,6 +852,7 @@ class DefaultPreferencesFactory implements PreferencesFactory { 'default' => $tzSetting, 'size' => 20, 'section' => 'rendering/timeoffset', + 'id' => 'wpTimeCorrection', ]; } @@ -1043,28 +1091,44 @@ class DefaultPreferencesFactory implements PreferencesFactory { protected function watchlistPreferences( User $user, IContextSource $context, &$defaultPreferences ) { + $oouiEnabled = SpecialPreferences::isOouiEnabled( $context ); + $watchlistdaysMax = ceil( $this->config->get( 'RCMaxAge' ) / ( 3600 * 24 ) ); # # Watchlist ##################################### if ( $user->isAllowed( 'editmywatchlist' ) ) { - $editWatchlistLinks = []; + $editWatchlistLinks = ''; + $editWatchlistLinksOld = []; $editWatchlistModes = [ - 'edit' => [ 'EditWatchlist', false ], - 'raw' => [ 'EditWatchlist', 'raw' ], - 'clear' => [ 'EditWatchlist', 'clear' ], + 'edit' => [ 'subpage' => false, 'flags' => [] ], + 'raw' => [ 'subpage' => 'raw', 'flags' => [] ], + 'clear' => [ 'subpage' => 'clear', 'flags' => [ 'destructive' ] ], ]; - foreach ( $editWatchlistModes as $editWatchlistMode => $mode ) { + foreach ( $editWatchlistModes as $mode => $options ) { // Messages: prefs-editwatchlist-edit, prefs-editwatchlist-raw, prefs-editwatchlist-clear - $editWatchlistLinks[] = $this->linkRenderer->makeKnownLink( - SpecialPage::getTitleFor( $mode[0], $mode[1] ), - new HtmlArmor( $context->msg( "prefs-editwatchlist-{$editWatchlistMode}" )->parse() ) - ); + if ( $oouiEnabled ) { + $editWatchlistLinks .= + new \OOUI\ButtonWidget( [ + 'href' => SpecialPage::getTitleFor( 'EditWatchlist', $options['subpage'] )->getLinkURL(), + 'flags' => $options[ 'flags' ], + 'label' => new \OOUI\HtmlSnippet( + $context->msg( "prefs-editwatchlist-{$mode}" )->parse() + ), + ] ); + } else { + $editWatchlistLinksOld[] = $this->linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'EditWatchlist', $options['subpage'] ), + new HtmlArmor( $context->msg( "prefs-editwatchlist-{$mode}" )->parse() ) + ); + } } $defaultPreferences['editwatchlist'] = [ 'type' => 'info', 'raw' => true, - 'default' => $context->getLanguage()->pipeList( $editWatchlistLinks ), + 'default' => $oouiEnabled ? + $editWatchlistLinks : + $context->getLanguage()->pipeList( $editWatchlistLinksOld ), 'label-message' => 'prefs-editwatchlist-label', 'section' => 'watchlist/editwatchlist', ]; @@ -1183,10 +1247,26 @@ class DefaultPreferencesFactory implements PreferencesFactory { } } - if ( $this->config->get( 'EnableAPI' ) ) { - $defaultPreferences['watchlisttoken'] = [ - 'type' => 'api', + $defaultPreferences['watchlisttoken'] = [ + 'type' => 'api', + ]; + + if ( $oouiEnabled ) { + $tokenButton = new \OOUI\ButtonWidget( [ + 'href' => SpecialPage::getTitleFor( 'ResetTokens' )->getLinkURL( [ + 'returnto' => SpecialPage::getTitleFor( 'Preferences' )->getPrefixedText() + ] ), + 'label' => $context->msg( 'prefs-watchlist-managetokens' )->text(), + ] ); + $defaultPreferences['watchlisttoken-info'] = [ + 'type' => 'info', + 'section' => 'watchlist/tokenwatchlist', + 'label-message' => 'prefs-watchlist-token', + 'help-message' => 'prefs-help-tokenmanagement', + 'raw' => true, + 'default' => (string)$tokenButton, ]; + } else { $defaultPreferences['watchlisttoken-info'] = [ 'type' => 'info', 'section' => 'watchlist/tokenwatchlist', @@ -1403,14 +1483,19 @@ class DefaultPreferencesFactory implements PreferencesFactory { * @param IContextSource $context * @param string $formClass * @param array $remove Array of items to remove - * @return PreferencesForm|HTMLForm + * @return PreferencesForm */ public function getForm( User $user, IContextSource $context, - $formClass = PreferencesForm::class, + $formClass = PreferencesFormOOUI::class, array $remove = [] ) { + if ( SpecialPreferences::isOouiEnabled( $context ) ) { + // We use ButtonWidgets in some of the getPreferences() functions + $context->getOutput()->enableOOUI(); + } + $formDescriptor = $this->getFormDescriptor( $user, $context ); if ( count( $remove ) ) { $removeKeys = array_flip( $remove ); @@ -1639,17 +1724,25 @@ class DefaultPreferencesFactory implements PreferencesFactory { $res = $this->saveFormData( $formData, $form ); if ( $res ) { + $context = $form->getContext(); + $urlOptions = []; if ( $res === 'eauth' ) { $urlOptions['eauth'] = 1; } + if ( + $context->getRequest()->getFuzzyBool( 'ooui' ) !== + $context->getConfig()->get( 'OOUIPreferences' ) + ) { + $urlOptions[ 'ooui' ] = $context->getRequest()->getFuzzyBool( 'ooui' ) ? 1 : 0; + } + $urlOptions += $form->getExtraSuccessRedirectParameters(); $url = $form->getTitle()->getFullURL( $urlOptions ); - $context = $form->getContext(); // Set session data for the success message $context->getRequest()->getSession()->set( 'specialPreferencesSaveSuccess', 1 ); diff --git a/includes/profiler/output/ProfilerOutputDb.php b/includes/profiler/output/ProfilerOutputDb.php index 28dc2cc21e..6e0085d8fc 100644 --- a/includes/profiler/output/ProfilerOutputDb.php +++ b/includes/profiler/output/ProfilerOutputDb.php @@ -21,6 +21,7 @@ * @ingroup Profiler */ +use Wikimedia\Rdbms\Database; use Wikimedia\Rdbms\DBError; /** @@ -55,7 +56,7 @@ class ProfilerOutputDb extends ProfilerOutput { } $fname = __METHOD__; - $dbw->onTransactionIdle( function () use ( $stats, $dbw, $fname ) { + $dbw->onTransactionCommitOrIdle( function ( Database $dbw ) use ( $stats, $fname ) { $pfhost = $this->perHost ? wfHostname() : ''; // Sqlite: avoid excess b-tree rebuilds (mostly for non-WAL mode) // non-Sqlite: lower contention with small transactions diff --git a/includes/registration/ExtensionDependencyError.php b/includes/registration/ExtensionDependencyError.php new file mode 100644 index 0000000000..d380d07761 --- /dev/null +++ b/includes/registration/ExtensionDependencyError.php @@ -0,0 +1,81 @@ + + * + * 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. + * + */ + +/** + * @since 1.31 + */ +class ExtensionDependencyError extends Exception { + + /** + * @var string[] + */ + public $missingExtensions = []; + + /** + * @var string[] + */ + public $missingSkins = []; + + /** + * @var string[] + */ + public $incompatibleExtensions = []; + + /** + * @var string[] + */ + public $incompatibleSkins = []; + + /** + * @var bool + */ + public $incompatibleCore = false; + + /** + * @param array $errors Each error has a 'msg' and 'type' key at minimum + */ + public function __construct( array $errors ) { + $msg = ''; + foreach ( $errors as $info ) { + $msg .= $info['msg'] . "\n"; + switch ( $info['type'] ) { + case 'incompatible-core': + $this->incompatibleCore = true; + break; + case 'missing-skins': + $this->missingSkins[] = $info['missing']; + break; + case 'missing-extensions': + $this->missingExtensions[] = $info['missing']; + break; + case 'incompatible-skins': + $this->incompatibleSkins[] = $info['incompatible']; + break; + case 'incompatible-extensions': + $this->incompatibleExtensions[] = $info['incompatible']; + break; + // default: continue + } + } + + parent::__construct( $msg ); + } + +} diff --git a/includes/registration/ExtensionRegistry.php b/includes/registration/ExtensionRegistry.php index 1876645399..b34a123635 100644 --- a/includes/registration/ExtensionRegistry.php +++ b/includes/registration/ExtensionRegistry.php @@ -1,7 +1,5 @@ getLocalServerObjectCache(); + // Don't use MediaWikiServices here to prevent instantiating it before extensions have + // been loaded + $cacheId = ObjectCache::detectLocalServerCache(); + $cache = ObjectCache::newFromId( $cacheId ); } catch ( MWException $e ) { $cache = new EmptyBagOStuff(); } @@ -202,6 +203,7 @@ class ExtensionRegistry { * @param array $queue keys are filenames, values are ignored * @return array extracted info * @throws Exception + * @throws ExtensionDependencyError */ public function readFromQueue( array $queue ) { global $wgVersion; @@ -273,11 +275,7 @@ class ExtensionRegistry { ); if ( $incompatible ) { - if ( count( $incompatible ) === 1 ) { - throw new Exception( $incompatible[0] ); - } else { - throw new Exception( implode( "\n", $incompatible ) ); - } + throw new ExtensionDependencyError( $incompatible ); } // Need to set this so we can += to it later diff --git a/includes/registration/VersionChecker.php b/includes/registration/VersionChecker.php index 02e3a7c8ab..9c673bc5db 100644 --- a/includes/registration/VersionChecker.php +++ b/includes/registration/VersionChecker.php @@ -110,13 +110,18 @@ class VersionChecker { case ExtensionRegistry::MEDIAWIKI_CORE: $mwError = $this->handleMediaWikiDependency( $values, $extension ); if ( $mwError !== false ) { - $errors[] = $mwError; + $errors[] = [ + 'msg' => $mwError, + 'type' => 'incompatible-core', + ]; } break; case 'extensions': case 'skin': foreach ( $values as $dependency => $constraint ) { - $extError = $this->handleExtensionDependency( $dependency, $constraint, $extension ); + $extError = $this->handleExtensionDependency( + $dependency, $constraint, $extension, $dependencyType + ); if ( $extError !== false ) { $errors[] = $extError; } @@ -164,12 +169,19 @@ class VersionChecker { * @param string $dependencyName The name of the dependency * @param string $constraint The required version constraint for this dependency * @param string $checkedExt The Extension, which depends on this dependency - * @return bool|string false for no errors, or a string message + * @param string $type Either 'extension' or 'skin' + * @return bool|array false for no errors, or an array of info */ - private function handleExtensionDependency( $dependencyName, $constraint, $checkedExt ) { + private function handleExtensionDependency( $dependencyName, $constraint, $checkedExt, + $type + ) { // Check if the dependency is even installed if ( !isset( $this->loaded[$dependencyName] ) ) { - return "{$checkedExt} requires {$dependencyName} to be installed."; + return [ + 'msg' => "{$checkedExt} requires {$dependencyName} to be installed.", + 'type' => "missing-$type", + 'missing' => $dependencyName, + ]; } // Check if the dependency has specified a version if ( !isset( $this->loaded[$dependencyName]['version'] ) ) { @@ -180,8 +192,13 @@ class VersionChecker { return false; } else { // Otherwise, mark it as incompatible. - return "{$dependencyName} does not expose its version, but {$checkedExt}" + $msg = "{$dependencyName} does not expose its version, but {$checkedExt}" . " requires: {$constraint}."; + return [ + 'msg' => $msg, + 'type' => "incompatible-$type", + 'incompatible' => $checkedExt, + ]; } } else { // Try to get a constraint for the dependency version @@ -193,16 +210,24 @@ class VersionChecker { } catch ( UnexpectedValueException $e ) { // Non-parsable version, output an error message that the version // string is invalid - return "$dependencyName does not have a valid version string."; + return [ + 'msg' => "$dependencyName does not have a valid version string.", + 'type' => 'invalid-version', + ]; } // Check if the constraint actually matches... if ( !$this->versionParser->parseConstraints( $constraint )->matches( $installedVersion ) ) { - return "{$checkedExt} is not compatible with the current " + $msg = "{$checkedExt} is not compatible with the current " . "installed version of {$dependencyName} " . "({$this->loaded[$dependencyName]['version']}), " . "it requires: " . $constraint . '.'; + return [ + 'msg' => $msg, + 'type' => "incompatible-$type", + 'incompatible' => $checkedExt, + ]; } } diff --git a/includes/resourceloader/DerivativeResourceLoaderContext.php b/includes/resourceloader/DerivativeResourceLoaderContext.php index 418d17f39a..b11bd6fd33 100644 --- a/includes/resourceloader/DerivativeResourceLoaderContext.php +++ b/includes/resourceloader/DerivativeResourceLoaderContext.php @@ -44,6 +44,7 @@ class DerivativeResourceLoaderContext extends ResourceLoaderContext { protected $only = self::INHERIT_VALUE; protected $version = self::INHERIT_VALUE; protected $raw = self::INHERIT_VALUE; + protected $contentOverrideCallback = self::INHERIT_VALUE; public function __construct( ResourceLoaderContext $context ) { $this->context = $context; @@ -196,4 +197,21 @@ class DerivativeResourceLoaderContext extends ResourceLoaderContext { return $this->context->getResourceLoader(); } + public function getContentOverrideCallback() { + if ( $this->contentOverrideCallback === self::INHERIT_VALUE ) { + return $this->context->getContentOverrideCallback(); + } + return $this->contentOverrideCallback; + } + + /** + * @see self::getContentOverrideCallback + * @since 1.32 + * @param callable|null|int $callback As per self::getContentOverrideCallback, + * or self::INHERIT_VALUE + */ + public function setContentOverrideCallback( $callback ) { + $this->contentOverrideCallback = $callback; + } + } diff --git a/includes/resourceloader/ResourceLoader.php b/includes/resourceloader/ResourceLoader.php index 5ddb99bdc1..90c314068d 100644 --- a/includes/resourceloader/ResourceLoader.php +++ b/includes/resourceloader/ResourceLoader.php @@ -1723,10 +1723,8 @@ MESSAGE; * @return array Map of variable names to string CSS values. */ public function getLessVars() { - if ( !$this->lessVars ) { - $lessVars = $this->config->get( 'ResourceLoaderLESSVars' ); - Hooks::run( 'ResourceLoaderGetLessVars', [ &$lessVars ] ); - $this->lessVars = $lessVars; + if ( $this->lessVars === null ) { + $this->lessVars = $this->config->get( 'ResourceLoaderLESSVars' ); } return $this->lessVars; } diff --git a/includes/resourceloader/ResourceLoaderClientHtml.php b/includes/resourceloader/ResourceLoaderClientHtml.php index 545fd3bda7..bb8ab32998 100644 --- a/includes/resourceloader/ResourceLoaderClientHtml.php +++ b/includes/resourceloader/ResourceLoaderClientHtml.php @@ -148,15 +148,22 @@ class ResourceLoaderClientHtml { continue; } - $context = $this->getContext( $module->getGroup(), ResourceLoaderModule::TYPE_COMBINED ); + $group = $module->getGroup(); + $context = $this->getContext( $group, ResourceLoaderModule::TYPE_COMBINED ); if ( $module->isKnownEmpty( $context ) ) { // Avoid needless request or embed for empty module $data['states'][$name] = 'ready'; continue; } - if ( $module->shouldEmbedModule( $this->context ) ) { - // Embed via mw.loader.implement per T36907. + if ( $group === 'user' || $module->shouldEmbedModule( $this->context ) ) { + // Call makeLoad() to decide how to load these, instead of + // loading via mw.loader.load(). + // - For group=user: We need to provide a pre-generated load.php + // url to the client that has the 'user' and 'version' parameters + // filled in. Without this, the client would wrongly use the static + // version hash, per T64602. + // - For shouldEmbed=true: Embed via mw.loader.implement, per T36907. $data['embed']['general'][] = $name; // Avoid duplicate request from mw.loader $data['states'][$name] = 'loading'; @@ -351,7 +358,9 @@ class ResourceLoaderClientHtml { } $context = new ResourceLoaderContext( $mainContext->getResourceLoader(), $req ); // Allow caller to setVersion() and setModules() - return new DerivativeResourceLoaderContext( $context ); + $ret = new DerivativeResourceLoaderContext( $context ); + $ret->setContentOverrideCallback( $mainContext->getContentOverrideCallback() ); + return $ret; } /** diff --git a/includes/resourceloader/ResourceLoaderContext.php b/includes/resourceloader/ResourceLoaderContext.php index c4e9884a1e..d41198ae55 100644 --- a/includes/resourceloader/ResourceLoaderContext.php +++ b/includes/resourceloader/ResourceLoaderContext.php @@ -341,6 +341,22 @@ class ResourceLoaderContext implements MessageLocalizer { return $this->imageObj; } + /** + * Return the replaced-content mapping callback + * + * When editing a page that's used to generate the scripts or styles of a + * ResourceLoaderWikiModule, a preview should use the to-be-saved version of + * the page rather than the current version in the database. A context + * supporting such previews should return a callback to return these + * mappings here. + * + * @since 1.32 + * @return callable|null Signature is `Content|null func( Title $t )` + */ + public function getContentOverrideCallback() { + return null; + } + /** * @return bool */ diff --git a/includes/resourceloader/ResourceLoaderImage.php b/includes/resourceloader/ResourceLoaderImage.php index 072ae7944b..d38a175008 100644 --- a/includes/resourceloader/ResourceLoaderImage.php +++ b/includes/resourceloader/ResourceLoaderImage.php @@ -130,13 +130,27 @@ class ResourceLoaderImage { $desc = $this->descriptor; if ( is_string( $desc ) ) { return $this->basePath . '/' . $desc; - } elseif ( isset( $desc['lang'][$context->getLanguage()] ) ) { - return $this->basePath . '/' . $desc['lang'][$context->getLanguage()]; - } elseif ( isset( $desc[$context->getDirection()] ) ) { + } + if ( isset( $desc['lang'] ) ) { + $contextLang = $context->getLanguage(); + if ( isset( $desc['lang'][$contextLang] ) ) { + return $this->basePath . '/' . $desc['lang'][$contextLang]; + } + $fallbacks = Language::getFallbacksFor( $contextLang ); + foreach ( $fallbacks as $lang ) { + // Images will fallback to 'default' instead of 'en', except for 'en-*' variants + if ( + ( $lang !== 'en' || substr( $contextLang, 0, 3 ) === 'en-' ) && + isset( $desc['lang'][$lang] ) + ) { + return $this->basePath . '/' . $desc['lang'][$lang]; + } + } + } + if ( isset( $desc[$context->getDirection()] ) ) { return $this->basePath . '/' . $desc[$context->getDirection()]; - } else { - return $this->basePath . '/' . $desc['default']; } + return $this->basePath . '/' . $desc['default']; } /** diff --git a/includes/resourceloader/ResourceLoaderLessVarFileModule.php b/includes/resourceloader/ResourceLoaderLessVarFileModule.php new file mode 100644 index 0000000000..17d00e0fab --- /dev/null +++ b/includes/resourceloader/ResourceLoaderLessVarFileModule.php @@ -0,0 +1,69 @@ +messages, $this->lessVariables ); + } + + /** + * Exclude a set of messages from a JSON string representation + * @param string $blob + * @param array $exclusions + * @return array $blob + */ + protected function excludeMessagesFromBlob( $blob, $exclusions ) { + $data = json_decode( $blob, true ); + // unset the LESS variables so that they are not forwarded to JavaScript + foreach ( $exclusions as $key ) { + unset( $data[$key] ); + } + return $data; + } + + /** + * @inheritDoc + */ + protected function getMessageBlob( ResourceLoaderContext $context ) { + $blob = parent::getMessageBlob( $context ); + return json_encode( $this->excludeMessagesFromBlob( $blob, $this->lessVariables ) ); + } + + /** + * Takes a message and wraps it in quotes for compatibility with LESS parser + * (ModifyVars) method so that the variable can be loaded and made available to stylesheets. + * Note this does not take care of CSS escaping. That will be taken care of as part + * of CSS Janus. + * @param string $msg + * @return string wrapped LESS variable definition + */ + private static function wrapAndEscapeMessage( $msg ) { + return str_replace( "'", "\'", CSSMin::serializeStringValue( $msg ) ); + } + + /** + * @param \ResourceLoaderContext $context + * @return array LESS variables + */ + protected function getLessVars( \ResourceLoaderContext $context ) { + $blob = parent::getMessageBlob( $context ); + $lessMessages = $this->excludeMessagesFromBlob( $blob, $this->messages ); + + $vars = []; + foreach ( $lessMessages as $msgKey => $value ) { + $vars['msg-' . $msgKey] = self::wrapAndEscapeMessage( $value ); + } + return $vars; + } +} diff --git a/includes/resourceloader/ResourceLoaderModule.php b/includes/resourceloader/ResourceLoaderModule.php index 8bf71705d6..6d1529b11a 100644 --- a/includes/resourceloader/ResourceLoaderModule.php +++ b/includes/resourceloader/ResourceLoaderModule.php @@ -937,41 +937,6 @@ abstract class ResourceLoaderModule implements LoggerAwareInterface { return null; } - /** - * Back-compat dummy for old subclass implementations of getModifiedTime(). - * - * This method used to use ObjectCache to track when a hash was first seen. That principle - * stems from a time that ResourceLoader could only identify module versions by timestamp. - * That is no longer the case. Use getDefinitionSummary() directly. - * - * @deprecated since 1.26 Superseded by getVersionHash() - * @param ResourceLoaderContext $context - * @return int UNIX timestamp - */ - public function getHashMtime( ResourceLoaderContext $context ) { - if ( !is_string( $this->getModifiedHash( $context ) ) ) { - return 1; - } - // Dummy that is > 1 - return 2; - } - - /** - * Back-compat dummy for old subclass implementations of getModifiedTime(). - * - * @since 1.23 - * @deprecated since 1.26 Superseded by getVersionHash() - * @param ResourceLoaderContext $context - * @return int UNIX timestamp - */ - public function getDefinitionMtime( ResourceLoaderContext $context ) { - if ( $this->getDefinitionSummary( $context ) === null ) { - return 1; - } - // Dummy that is > 1 - return 2; - } - /** * Check whether this module is known to be empty. If a child class * has an easy and cheap way to determine that this module is diff --git a/includes/resourceloader/ResourceLoaderSkinModule.php b/includes/resourceloader/ResourceLoaderSkinModule.php index fbd0a24a7f..de25d32e54 100644 --- a/includes/resourceloader/ResourceLoaderSkinModule.php +++ b/includes/resourceloader/ResourceLoaderSkinModule.php @@ -93,6 +93,9 @@ class ResourceLoaderSkinModule extends ResourceLoaderFileModule { } /** + * Non-static proxy to ::getLogo (for overloading in sub classes or tests). + * + * @codeCoverageIgnore * @since 1.31 * @param Config $conf * @return string|array diff --git a/includes/resourceloader/ResourceLoaderStartUpModule.php b/includes/resourceloader/ResourceLoaderStartUpModule.php index 681e8dc005..2e3c6fc161 100644 --- a/includes/resourceloader/ResourceLoaderStartUpModule.php +++ b/includes/resourceloader/ResourceLoaderStartUpModule.php @@ -88,7 +88,6 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { 'wgUrlProtocols' => wfUrlProtocols(), 'wgArticlePath' => $conf->get( 'ArticlePath' ), 'wgScriptPath' => $conf->get( 'ScriptPath' ), - 'wgScriptExtension' => '.php', 'wgScript' => wfScript(), 'wgSearchType' => $conf->get( 'SearchType' ), 'wgVariantArticlePath' => $conf->get( 'VariantArticlePath' ), @@ -101,8 +100,8 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule { 'wgContentLanguage' => $wgContLang->getCode(), 'wgTranslateNumerals' => $conf->get( 'TranslateNumerals' ), 'wgVersion' => $conf->get( 'Version' ), - 'wgEnableAPI' => $conf->get( 'EnableAPI' ), - 'wgEnableWriteAPI' => $conf->get( 'EnableWriteAPI' ), + 'wgEnableAPI' => true, // Deprecated since MW 1.32 + 'wgEnableWriteAPI' => true, // Deprecated since MW 1.32 'wgMainPageTitle' => $mainPage->getPrefixedText(), 'wgFormattedNamespaces' => $wgContLang->getFormattedNamespaces(), 'wgNamespaceIds' => $namespaceIds, diff --git a/includes/resourceloader/ResourceLoaderUserModule.php b/includes/resourceloader/ResourceLoaderUserModule.php index 8e213819f6..e747373e1a 100644 --- a/includes/resourceloader/ResourceLoaderUserModule.php +++ b/includes/resourceloader/ResourceLoaderUserModule.php @@ -58,8 +58,9 @@ class ResourceLoaderUserModule extends ResourceLoaderWikiModule { } } - // Hack for T28283: Allow excluding pages for preview on a CSS/JS page. - // The excludepage parameter is set by OutputPage. + // This is obsolete since 1.32 (T112474). It was formerly used by + // OutputPage to implement previewing of user CSS and JS. + // @todo: Remove it once we're sure nothing else is using the parameter $excludepage = $context->getRequest()->getVal( 'excludepage' ); if ( isset( $pages[$excludepage] ) ) { unset( $pages[$excludepage] ); diff --git a/includes/resourceloader/ResourceLoaderUserStylesModule.php b/includes/resourceloader/ResourceLoaderUserStylesModule.php index 8d8e008593..69e8a97a13 100644 --- a/includes/resourceloader/ResourceLoaderUserStylesModule.php +++ b/includes/resourceloader/ResourceLoaderUserStylesModule.php @@ -58,8 +58,9 @@ class ResourceLoaderUserStylesModule extends ResourceLoaderWikiModule { } } - // Hack for T28283: Allow excluding pages for preview on a CSS/JS page. - // The excludepage parameter is set by OutputPage. + // This is obsolete since 1.32 (T112474). It was formerly used by + // OutputPage to implement previewing of user CSS and JS. + // @todo: Remove it once we're sure nothing else is using the parameter $excludepage = $context->getRequest()->getVal( 'excludepage' ); if ( isset( $pages[$excludepage] ) ) { unset( $pages[$excludepage] ); diff --git a/includes/resourceloader/ResourceLoaderWikiModule.php b/includes/resourceloader/ResourceLoaderWikiModule.php index 5b512af7b9..085244acf3 100644 --- a/includes/resourceloader/ResourceLoaderWikiModule.php +++ b/includes/resourceloader/ResourceLoaderWikiModule.php @@ -22,6 +22,7 @@ * @author Roan Kattouw */ +use MediaWiki\Linker\LinkTarget; use Wikimedia\Rdbms\Database; use Wikimedia\Rdbms\IDatabase; @@ -50,7 +51,19 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { // Origin defaults to users with sitewide authority protected $origin = self::ORIGIN_USER_SITEWIDE; - // In-process cache for title info + // In-process cache for title info, structured as an array + // [ + // // Pipe-separated list of sorted keys from getPages + // => [ + // => [ // Normalised title key + // 'page_len' => .., + // 'page_latest' => .., + // 'page_touched' => .., + // ] + // ] + // ] + // @see self::fetchTitleInfo() + // @see self::makeTitleKey() protected $titleInfo = []; // List of page names that contain CSS @@ -144,24 +157,22 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { /** * @param string $titleText + * @param ResourceLoaderContext|null $context (but passing null is deprecated) * @return null|string + * @since 1.32 added the $context parameter */ - protected function getContent( $titleText ) { + protected function getContent( $titleText, ResourceLoaderContext $context = null ) { $title = Title::newFromText( $titleText ); if ( !$title ) { return null; // Bad title } - // If the page is a redirect, follow the redirect. - if ( $title->isRedirect() ) { - $content = $this->getContentObj( $title ); - $title = $content ? $content->getUltimateRedirectTarget() : null; - if ( !$title ) { - return null; // Dead redirect - } + $content = $this->getContentObj( $title, $context ); + if ( !$content ) { + return null; // No content found } - $handler = ContentHandler::getForTitle( $title ); + $handler = $content->getContentHandler(); if ( $handler->isSupportedFormat( CONTENT_FORMAT_CSS ) ) { $format = CONTENT_FORMAT_CSS; } elseif ( $handler->isSupportedFormat( CONTENT_FORMAT_JAVASCRIPT ) ) { @@ -170,31 +181,81 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { return null; // Bad content model } - $content = $this->getContentObj( $title ); - if ( !$content ) { - return null; // No content found - } - return $content->serialize( $format ); } /** * @param Title $title + * @param ResourceLoaderContext|null $context (but passing null is deprecated) + * @param int|null $maxRedirects Maximum number of redirects to follow. If + * null, uses $wgMaxRedirects * @return Content|null + * @since 1.32 added the $context and $maxRedirects parameters */ - protected function getContentObj( Title $title ) { - $revision = Revision::newKnownCurrent( wfGetDB( DB_REPLICA ), $title ); - if ( !$revision ) { - return null; + protected function getContentObj( + Title $title, ResourceLoaderContext $context = null, $maxRedirects = null + ) { + if ( $context === null ) { + wfDeprecated( __METHOD__ . ' without a ResourceLoader context', '1.32' ); } - $content = $revision->getContent( Revision::RAW ); - if ( !$content ) { - wfDebugLog( 'resourceloader', __METHOD__ . ': failed to load content of JS/CSS page!' ); - return null; + + $overrideCallback = $context ? $context->getContentOverrideCallback() : null; + $content = $overrideCallback ? call_user_func( $overrideCallback, $title ) : null; + if ( $content ) { + if ( !$content instanceof Content ) { + $this->getLogger()->error( + 'Bad content override for "{title}" in ' . __METHOD__, + [ 'title' => $title->getPrefixedText() ] + ); + return null; + } + } else { + $revision = Revision::newKnownCurrent( wfGetDB( DB_REPLICA ), $title ); + if ( !$revision ) { + return null; + } + $content = $revision->getContent( Revision::RAW ); + + if ( !$content ) { + $this->getLogger()->error( + 'Failed to load content of JS/CSS page "{title}" in ' . __METHOD__, + [ 'title' => $title->getPrefixedText() ] + ); + return null; + } + } + + if ( $content && $content->isRedirect() ) { + if ( $maxRedirects === null ) { + $maxRedirects = $this->getConfig()->get( 'MaxRedirects' ) ?: 0; + } + if ( $maxRedirects > 0 ) { + $newTitle = $content->getRedirectTarget(); + return $newTitle ? $this->getContentObj( $newTitle, $context, $maxRedirects - 1 ) : null; + } } + return $content; } + /** + * @param ResourceLoaderContext $context + * @return bool + */ + public function shouldEmbedModule( ResourceLoaderContext $context ) { + $overrideCallback = $context->getContentOverrideCallback(); + if ( $overrideCallback && $this->getSource() === 'local' ) { + foreach ( $this->getPages( $context ) as $page => $info ) { + $title = Title::newFromText( $page ); + if ( $title && call_user_func( $overrideCallback, $title ) !== null ) { + return true; + } + } + } + + return parent::shouldEmbedModule( $context ); + } + /** * @param ResourceLoaderContext $context * @return string JavaScript code @@ -205,7 +266,7 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { if ( $options['type'] !== 'script' ) { continue; } - $script = $this->getContent( $titleText ); + $script = $this->getContent( $titleText, $context ); if ( strval( $script ) !== '' ) { $script = $this->validateScriptFile( $titleText, $script ); $scripts .= ResourceLoader::makeComment( $titleText ) . $script . "\n"; @@ -225,7 +286,7 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { continue; } $media = isset( $options['media'] ) ? $options['media'] : 'all'; - $style = $this->getContent( $titleText ); + $style = $this->getContent( $titleText, $context ); if ( strval( $style ) === '' ) { continue; } @@ -278,6 +339,10 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { public function isKnownEmpty( ResourceLoaderContext $context ) { $revisions = $this->getTitleInfo( $context ); + // If a module has dependencies it cannot be empty. An empty array will be cast to false + if ( $this->getDependencies() ) { + return false; + } // For user modules, don't needlessly load if there are no non-empty pages if ( $this->getGroup() === 'user' ) { foreach ( $revisions as $revision ) { @@ -295,8 +360,13 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { return count( $revisions ) === 0; } - private function setTitleInfo( $key, array $titleInfo ) { - $this->titleInfo[$key] = $titleInfo; + private function setTitleInfo( $batchKey, array $titleInfo ) { + $this->titleInfo[$batchKey] = $titleInfo; + } + + private static function makeTitleKey( LinkTarget $title ) { + // Used for keys in titleInfo. + return "{$title->getNamespace()}:{$title->getDBkey()}"; } /** @@ -313,11 +383,30 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { $pageNames = array_keys( $this->getPages( $context ) ); sort( $pageNames ); - $key = implode( '|', $pageNames ); - if ( !isset( $this->titleInfo[$key] ) ) { - $this->titleInfo[$key] = static::fetchTitleInfo( $dbr, $pageNames, __METHOD__ ); + $batchKey = implode( '|', $pageNames ); + if ( !isset( $this->titleInfo[$batchKey] ) ) { + $this->titleInfo[$batchKey] = static::fetchTitleInfo( $dbr, $pageNames, __METHOD__ ); + } + + $titleInfo = $this->titleInfo[$batchKey]; + + // Override the title info from the overrides, if any + $overrideCallback = $context->getContentOverrideCallback(); + if ( $overrideCallback ) { + foreach ( $pageNames as $page ) { + $title = Title::newFromText( $page ); + $content = $title ? call_user_func( $overrideCallback, $title ) : null; + if ( $content !== null ) { + $titleInfo[$title->getPrefixedText()] = [ + 'page_len' => $content->getSize(), + 'page_latest' => 'TBD', // None available + 'page_touched' => wfTimestamp( TS_MW ), + ]; + } + } } - return $this->titleInfo[$key]; + + return $titleInfo; } protected static function fetchTitleInfo( IDatabase $db, array $pages, $fname = __METHOD__ ) { @@ -340,8 +429,8 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { foreach ( $res as $row ) { // Avoid including ids or timestamps of revision/page tables so // that versions are not wasted - $title = Title::makeTitle( $row->page_namespace, $row->page_title ); - $titleInfo[$title->getPrefixedText()] = [ + $title = new TitleValue( (int)$row->page_namespace, $row->page_title ); + $titleInfo[ self::makeTitleKey( $title ) ] = [ 'page_len' => $row->page_len, 'page_latest' => $row->page_latest, 'page_touched' => $row->page_touched, @@ -410,23 +499,23 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule { $pages = $wikiModule->getPages( $context ); // Before we intersect, map the names to canonical form (T145673). $intersect = []; - foreach ( $pages as $page => $unused ) { - $title = Title::newFromText( $page ); + foreach ( $pages as $pageName => $unused ) { + $title = Title::newFromText( $pageName ); if ( $title ) { - $intersect[ $title->getPrefixedText() ] = 1; + $intersect[ self::makeTitleKey( $title ) ] = 1; } else { // Page name may be invalid if user-provided (e.g. gadgets) $rl->getLogger()->info( 'Invalid wiki page title "{title}" in ' . __METHOD__, - [ 'title' => $page ] + [ 'title' => $pageName ] ); } } $info = array_intersect_key( $allInfo, $intersect ); $pageNames = array_keys( $pages ); sort( $pageNames ); - $key = implode( '|', $pageNames ); - $wikiModule->setTitleInfo( $key, $info ); + $batchKey = implode( '|', $pageNames ); + $wikiModule->setTitleInfo( $batchKey, $info ); } } diff --git a/includes/revisiondelete/RevDelLogItem.php b/includes/revisiondelete/RevDelLogItem.php index 198a28b503..36198cd2cf 100644 --- a/includes/revisiondelete/RevDelLogItem.php +++ b/includes/revisiondelete/RevDelLogItem.php @@ -75,7 +75,7 @@ class RevDelLogItem extends RevDelItem { $dbw->update( 'recentchanges', [ 'rc_deleted' => $bits, - 'rc_patrolled' => 1 + 'rc_patrolled' => RecentChange::PRC_PATROLLED ], [ 'rc_logid' => $this->row->log_id, diff --git a/includes/revisiondelete/RevDelRevisionItem.php b/includes/revisiondelete/RevDelRevisionItem.php index cb5ce48e87..7b5d130b24 100644 --- a/includes/revisiondelete/RevDelRevisionItem.php +++ b/includes/revisiondelete/RevDelRevisionItem.php @@ -83,7 +83,7 @@ class RevDelRevisionItem extends RevDelItem { $dbw->update( 'recentchanges', [ 'rc_deleted' => $bits, - 'rc_patrolled' => 1 + 'rc_patrolled' => RecentChange::PRC_PATROLLED ], [ 'rc_this_oldid' => $this->revision->getId(), // condition diff --git a/includes/search/SearchEngine.php b/includes/search/SearchEngine.php index 7e6e8e6d0d..0f65711bf6 100644 --- a/includes/search/SearchEngine.php +++ b/includes/search/SearchEngine.php @@ -335,12 +335,25 @@ abstract class SearchEngine { return false; } $extractedNamespace = null; + $allkeywords = []; - $allkeyword = wfMessage( 'searchall' )->inContentLanguage()->text() . ":"; - if ( strncmp( $query, $allkeyword, strlen( $allkeyword ) ) == 0 ) { - $extractedNamespace = null; - $parsed = substr( $query, strlen( $allkeyword ) ); - } elseif ( strpos( $query, ':' ) !== false ) { + $allkeywords[] = wfMessage( 'searchall' )->inContentLanguage()->text() . ":"; + // force all: so that we have a common syntax for all the wikis + if ( !in_array( 'all:', $allkeywords ) ) { + $allkeywords[] = 'all:'; + } + + $allQuery = false; + foreach ( $allkeywords as $kw ) { + if ( strncmp( $query, $kw, strlen( $kw ) ) == 0 ) { + $extractedNamespace = null; + $parsed = substr( $query, strlen( $kw ) ); + $allQuery = true; + break; + } + } + + if ( !$allQuery && strpos( $query, ':' ) !== false ) { // TODO: should we unify with PrefixSearch::extractNamespace ? $prefix = str_replace( ' ', '_', substr( $query, 0, strpos( $query, ':' ) ) ); $index = $wgContLang->getNsIndex( $prefix ); diff --git a/includes/search/SearchMySQL.php b/includes/search/SearchMySQL.php index 8e705c1fe8..c98f7e337c 100644 --- a/includes/search/SearchMySQL.php +++ b/includes/search/SearchMySQL.php @@ -34,13 +34,13 @@ class SearchMySQL extends SearchDatabase { private static $mMinSearchLength; /** - * Parse the user's query and transform it into an SQL fragment which will - * become part of a WHERE clause + * Parse the user's query and transform it into two SQL fragments: + * a WHERE condition and an ORDER BY expression * * @param string $filteredText * @param string $fulltext * - * @return string + * @return array */ function parseQuery( $filteredText, $fulltext ) { global $wgContLang; @@ -127,7 +127,10 @@ class SearchMySQL extends SearchDatabase { $searchon = $this->db->addQuotes( $searchon ); $field = $this->getIndexField( $fulltext ); - return " MATCH($field) AGAINST($searchon IN BOOLEAN MODE) "; + return [ + " MATCH($field) AGAINST($searchon IN BOOLEAN MODE) ", + " MATCH($field) AGAINST($searchon IN NATURAL LANGUAGE MODE) DESC " + ]; } function regexTerm( $string, $wildcard ) { @@ -303,7 +306,8 @@ class SearchMySQL extends SearchDatabase { $query['fields'][] = 'page_namespace'; $query['fields'][] = 'page_title'; $query['conds'][] = 'page_id=si_page'; - $query['conds'][] = $match; + $query['conds'][] = $match[0]; + $query['options']['ORDER BY'] = $match[1]; } /** @@ -318,7 +322,7 @@ class SearchMySQL extends SearchDatabase { $query = [ 'tables' => [ 'page', 'searchindex' ], 'fields' => [ 'COUNT(*) as c' ], - 'conds' => [ 'page_id=si_page', $match ], + 'conds' => [ 'page_id=si_page', $match[0] ], 'options' => [], 'joins' => [], ]; diff --git a/includes/shell/CommandFactory.php b/includes/shell/CommandFactory.php index 78f1d8008f..b4b9b921a9 100644 --- a/includes/shell/CommandFactory.php +++ b/includes/shell/CommandFactory.php @@ -100,6 +100,7 @@ class CommandFactory { public function create() { if ( $this->restrictionMethod === 'firejail' ) { $command = new FirejailCommand( $this->findFirejail() ); + $command->restrict( Shell::RESTRICT_DEFAULT ); } else { $command = new Command(); } diff --git a/includes/shell/Shell.php b/includes/shell/Shell.php index d57bf4fcdc..742e1424ad 100644 --- a/includes/shell/Shell.php +++ b/includes/shell/Shell.php @@ -22,6 +22,7 @@ namespace MediaWiki\Shell; +use Hooks; use MediaWiki\MediaWikiServices; /** @@ -100,6 +101,13 @@ class Shell { */ const NO_LOCALSETTINGS = 32; + /** + * Don't apply any restrictions + * + * @since 1.31 + */ + const RESTRICT_NONE = 0; + /** * Returns a new instance of Command class * @@ -212,4 +220,32 @@ class Shell { } return $retVal; } + + /** + * Generate a Command object to run a MediaWiki CLI script. + * Note that $parameters should be a flat array and an option with an argument + * should consist of two consecutive items in the array (do not use "--option value"). + * + * @param string $script MediaWiki CLI script with full path + * @param string[] $parameters Arguments and options to the script + * @param array $options Associative array of options: + * 'php': The path to the php executable + * 'wrapper': Path to a PHP wrapper to handle the maintenance script + * @return Command + */ + public static function makeScriptCommand( $script, $parameters, $options = [] ) { + global $wgPhpCli; + // Give site config file a chance to run the script in a wrapper. + // The caller may likely want to call wfBasename() on $script. + Hooks::run( 'wfShellWikiCmd', [ &$script, &$parameters, &$options ] ); + $cmd = isset( $options['php'] ) ? [ $options['php'] ] : [ $wgPhpCli ]; + if ( isset( $options['wrapper'] ) ) { + $cmd[] = $options['wrapper']; + } + $cmd[] = $script; + + return self::command( $cmd ) + ->params( $parameters ) + ->restrict( self::RESTRICT_DEFAULT & ~self::NO_LOCALSETTINGS ); + } } diff --git a/includes/site/MediaWikiPageNameNormalizer.php b/includes/site/MediaWikiPageNameNormalizer.php index c4e490a4c7..8a12c4f7ce 100644 --- a/includes/site/MediaWikiPageNameNormalizer.php +++ b/includes/site/MediaWikiPageNameNormalizer.php @@ -53,7 +53,8 @@ class MediaWikiPageNameNormalizer { /** * Returns the normalized form of the given page title, using the * normalization rules of the given site. If the given title is a redirect, - * the redirect weill be resolved and the redirect target is returned. + * the redirect will be resolved and the redirect target is returned. + * Only titles of existing pages will be returned. * * @note This actually makes an API request to the remote site, so beware * that this function is slow and depends on an external service. @@ -65,7 +66,9 @@ class MediaWikiPageNameNormalizer { * @param string $pageName * @param string $apiUrl * - * @return string + * @return string|false The normalized form of the title, + * or false to indicate an invalid title, a missing page, + * or some other kind of error. * @throws \MWException */ public function normalizePageName( $pageName, $apiUrl ) { diff --git a/includes/site/MediaWikiSite.php b/includes/site/MediaWikiSite.php index f31a77d3a4..e1e7ce69cf 100644 --- a/includes/site/MediaWikiSite.php +++ b/includes/site/MediaWikiSite.php @@ -64,7 +64,8 @@ class MediaWikiSite extends Site { /** * Returns the normalized form of the given page title, using the * normalization rules of the given site. If the given title is a redirect, - * the redirect weill be resolved and the redirect target is returned. + * the redirect will be resolved and the redirect target is returned. + * Only titles of existing pages will be returned. * * @note This actually makes an API request to the remote site, so beware * that this function is slow and depends on an external service. @@ -79,7 +80,9 @@ class MediaWikiSite extends Site { * * @param string $pageName * - * @return string + * @return string|false The normalized form of the title, + * or false to indicate an invalid title, a missing page, + * or some other kind of error. * @throws MWException */ public function normalizePageName( $pageName ) { diff --git a/includes/site/Site.php b/includes/site/Site.php index a6e63391da..f5e3f22ee6 100644 --- a/includes/site/Site.php +++ b/includes/site/Site.php @@ -382,8 +382,10 @@ class Site implements Serializable { } /** - * Returns $pageName without changes. - * Subclasses may override this to apply some kind of normalization. + * Attempt to normalize the page name in some fashion. + * May return false to indicate various kinds of failure. + * + * This implementation returns $pageName without changes. * * @see Site::normalizePageName * @@ -391,7 +393,7 @@ class Site implements Serializable { * * @param string $pageName * - * @return string + * @return string|false */ public function normalizePageName( $pageName ) { return $pageName; diff --git a/includes/skins/MediaWikiI18N.php b/includes/skins/MediaWikiI18N.php deleted file mode 100644 index 731897e4c5..0000000000 --- a/includes/skins/MediaWikiI18N.php +++ /dev/null @@ -1,60 +0,0 @@ -context[$varName] = $value; - } - - /** - * @deprecate since 1.31 Use BaseTemplate::msg(), Skin::msg(), or wfMessage() instead. - */ - function translate( $value ) { - wfDeprecated( __METHOD__, '1.31' ); - // Hack for i18n:attributes in PHPTAL 1.0.0 dev version as of 2004-10-23 - $value = preg_replace( '/^string:/', '', $value ); - - $value = wfMessage( $value )->text(); - // interpolate variables - $m = []; - while ( preg_match( '/\$([0-9]*?)/sm', $value, $m ) ) { - list( $src, $var ) = $m; - Wikimedia\suppressWarnings(); - $varValue = $this->context[$var]; - Wikimedia\restoreWarnings(); - $value = str_replace( $src, $varValue, $value ); - } - return $value; - } -} diff --git a/includes/skins/QuickTemplate.php b/includes/skins/QuickTemplate.php index 1886746489..aa20e202eb 100644 --- a/includes/skins/QuickTemplate.php +++ b/includes/skins/QuickTemplate.php @@ -31,11 +31,6 @@ abstract class QuickTemplate { */ public $data; - /** - * @var MediaWikiI18N - */ - public $translator; - /** @var Config $config */ protected $config; @@ -44,7 +39,6 @@ abstract class QuickTemplate { */ function __construct( Config $config = null ) { $this->data = []; - $this->translator = new MediaWikiI18N(); if ( $config === null ) { wfDebug( __METHOD__ . ' was called with no Config instance passed to it' ); $config = MediaWikiServices::getInstance()->getMainConfig(); @@ -102,16 +96,6 @@ abstract class QuickTemplate { $this->data[$name] =& $value; } - /** - * @param MediaWikiI18N &$t - * @deprecate since 1.31 Use BaseTemplate::msg() or Skin::msg() instead for setting - * message parameters. - */ - public function setTranslator( &$t ) { - wfDeprecated( __METHOD__, '1.31' ); - $this->translator = &$t; - } - /** * Main function, used by classes that subclass QuickTemplate * to show the actual HTML output diff --git a/includes/skins/Skin.php b/includes/skins/Skin.php index 65a300afef..340bc2f5bd 100644 --- a/includes/skins/Skin.php +++ b/includes/skins/Skin.php @@ -166,26 +166,37 @@ abstract class Skin extends ContextSource { * It is recommended that skins wishing to override call parent::getDefaultModules() * and substitute out any modules they wish to change by using a key to look them up * - * For style modules, use setupSkinUserCss() instead. + * Any modules defined with the 'styles' key will be added as render blocking CSS via + * Output::addModuleStyles. Similarly, each key should refer to a list of modules * * @return array Array of modules with helper keys for easy overriding */ public function getDefaultModules() { - global $wgUseAjax, $wgEnableAPI, $wgEnableWriteAPI; - $out = $this->getOutput(); $config = $this->getConfig(); - $user = $out->getUser(); + $user = $this->getUser(); + + // Modules declared in the $modules literal are loaded + // for ALL users, on ALL pages, in ALL skins. + // Keep this list as small as possible! $modules = [ - // modules not specific to any specific skin or page + 'styles' => [ + // The 'styles' key sets render-blocking style modules + // Unlike other keys in $modules, this is an associative array + // where each key is its own group pointing to a list of modules + 'core' => [ + 'mediawiki.legacy.shared', + 'mediawiki.legacy.commonPrint', + ], + 'content' => [], + 'syndicate' => [], + ], 'core' => [ - // Enforce various default modules for all pages and all skins - // Keep this list as small as possible 'site', 'mediawiki.page.startup', 'mediawiki.user', ], - // modules that enhance the page content in some way + // modules that enhance the content in some way 'content' => [ 'mediawiki.page.ready', ], @@ -195,6 +206,8 @@ abstract class Skin extends ContextSource { 'watch' => [], // modules which relate to the current users preferences 'user' => [], + // modules relating to RSS/Atom Feeds + 'syndicate' => [], ]; // Support for high-density display images if enabled @@ -205,11 +218,19 @@ abstract class Skin extends ContextSource { // Preload jquery.tablesorter for mediawiki.page.ready if ( strpos( $out->getHTML(), 'sortable' ) !== false ) { $modules['content'][] = 'jquery.tablesorter'; + $modules['styles']['content'][] = 'jquery.tablesorter.styles'; } // Preload jquery.makeCollapsible for mediawiki.page.ready if ( strpos( $out->getHTML(), 'mw-collapsible' ) !== false ) { $modules['content'][] = 'jquery.makeCollapsible'; + $modules['styles']['content'][] = 'jquery.makeCollapsible.styles'; + } + + // Deprecated since 1.26: Unconditional loading of mediawiki.ui.button + // on every page is deprecated. Express a dependency instead. + if ( strpos( $out->getHTML(), 'mw-ui-button' ) !== false ) { + $modules['styles']['content'][] = 'mediawiki.ui.button'; } if ( $out->isTOCEnabled() ) { @@ -217,17 +238,15 @@ abstract class Skin extends ContextSource { } // Add various resources if required - if ( $wgUseAjax && $wgEnableAPI ) { - if ( $wgEnableWriteAPI && $user->isLoggedIn() - && $user->isAllowedAll( 'writeapi', 'viewmywatchlist', 'editmywatchlist' ) - && $this->getRelevantTitle()->canExist() - ) { - $modules['watch'][] = 'mediawiki.page.watch.ajax'; - } - - $modules['search'][] = 'mediawiki.searchSuggest'; + if ( $user->isLoggedIn() + && $user->isAllowedAll( 'writeapi', 'viewmywatchlist', 'editmywatchlist' ) + && $this->getRelevantTitle()->canExist() + ) { + $modules['watch'][] = 'mediawiki.page.watch.ajax'; } + $modules['search'][] = 'mediawiki.searchSuggest'; + if ( $user->getBoolOption( 'editsectiononrightclick' ) ) { $modules['user'][] = 'mediawiki.action.view.rightClickEdit'; } @@ -236,6 +255,11 @@ abstract class Skin extends ContextSource { if ( $out->isArticle() && $user->getOption( 'editondblclick' ) ) { $modules['user'][] = 'mediawiki.action.view.dblClickEdit'; } + + if ( $out->isSyndicated() ) { + $modules['styles']['syndicate'][] = 'mediawiki.feedlink'; + } + return $modules; } @@ -407,14 +431,14 @@ abstract class Skin extends ContextSource { } /** - * Add skin specific stylesheets - * Calling this method with an $out of anything but the same OutputPage - * inside ->getOutput() is deprecated. The $out arg is kept - * for compatibility purposes with skins. - * @param OutputPage $out - * @todo delete + * Hook point for adding style modules to OutputPage. + * + * @deprecated since 1.32 Use getDefaultModules() instead. + * @param OutputPage $out Legacy parameter, identical to $this->getOutput() */ - abstract function setupSkinUserCss( OutputPage $out ); + public function setupSkinUserCss( OutputPage $out ) { + // Stub. + } /** * TODO: document @@ -1093,7 +1117,7 @@ abstract class Skin extends ContextSource { /* these are used extensively in SkinTemplate, but also some other places */ /** - * @param string $urlaction + * @param string|string[] $urlaction * @return string */ static function makeMainPageUrl( $urlaction = '' ) { @@ -1110,7 +1134,7 @@ abstract class Skin extends ContextSource { * URL with the protocol specified. * * @param string $name Name of the Special page - * @param string $urlaction Query to append + * @param string|string[] $urlaction Query to append * @param string|null $proto Protocol to use or null for a local URL * @return string */ @@ -1126,7 +1150,7 @@ abstract class Skin extends ContextSource { /** * @param string $name * @param string $subpage - * @param string $urlaction + * @param string|string[] $urlaction * @return string */ static function makeSpecialUrlSubpage( $name, $subpage, $urlaction = '' ) { @@ -1136,7 +1160,7 @@ abstract class Skin extends ContextSource { /** * @param string $name - * @param string $urlaction + * @param string|string[] $urlaction * @return string */ static function makeI18nUrl( $name, $urlaction = '' ) { @@ -1147,7 +1171,7 @@ abstract class Skin extends ContextSource { /** * @param string $name - * @param string $urlaction + * @param string|string[] $urlaction * @return string */ static function makeUrl( $name, $urlaction = '' ) { @@ -1174,7 +1198,7 @@ abstract class Skin extends ContextSource { /** * this can be passed the NS number as defined in Language.php * @param string $name - * @param string $urlaction + * @param string|string[] $urlaction * @param int $namespace * @return string */ @@ -1188,7 +1212,7 @@ abstract class Skin extends ContextSource { /** * these return an array with the 'href' and boolean 'exists' * @param string $name - * @param string $urlaction + * @param string|string[] $urlaction * @return array */ static function makeUrlDetails( $name, $urlaction = '' ) { @@ -1204,7 +1228,7 @@ abstract class Skin extends ContextSource { /** * Make URL details where the article exists (or at least it's convenient to think so) * @param string $name Article name - * @param string $urlaction + * @param string|string[] $urlaction * @return array */ static function makeKnownUrlDetails( $name, $urlaction = '' ) { diff --git a/includes/skins/SkinApi.php b/includes/skins/SkinApi.php index 6966ff71be..38d94e4b3a 100644 --- a/includes/skins/SkinApi.php +++ b/includes/skins/SkinApi.php @@ -32,9 +32,10 @@ class SkinApi extends SkinTemplate { public $skinname = 'apioutput'; public $template = SkinApiTemplate::class; - public function setupSkinUserCss( OutputPage $out ) { - parent::setupSkinUserCss( $out ); - $out->addModuleStyles( 'mediawiki.skinning.interface' ); + public function getDefaultModules() { + $modules = parent::getDefaultModules(); + $modules['styles']['skin'][] = 'mediawiki.skinning.interface'; + return $modules; } // Skip work and hooks for stuff we don't use diff --git a/includes/skins/SkinFallback.php b/includes/skins/SkinFallback.php index d5f764c6e4..09042f0317 100644 --- a/includes/skins/SkinFallback.php +++ b/includes/skins/SkinFallback.php @@ -2,8 +2,6 @@ /** * Skin file for the fallback skin. * - * The structure is copied from the example skin (mediawiki/skins/Example). - * * @since 1.24 * @file */ @@ -16,14 +14,10 @@ class SkinFallback extends SkinTemplate { public $skinname = 'fallback'; public $template = SkinFallbackTemplate::class; - /** - * Add CSS via ResourceLoader - * - * @param OutputPage $out - */ - public function setupSkinUserCss( OutputPage $out ) { - parent::setupSkinUserCss( $out ); - $out->addModuleStyles( 'mediawiki.skinning.interface' ); + public function getDefaultModules() { + $modules = parent::getDefaultModules(); + $modules['styles']['skin'][] = 'mediawiki.skinning.interface'; + return $modules; } /** diff --git a/includes/skins/SkinTemplate.php b/includes/skins/SkinTemplate.php index 45875334bc..1d5d534ace 100644 --- a/includes/skins/SkinTemplate.php +++ b/includes/skins/SkinTemplate.php @@ -56,30 +56,6 @@ class SkinTemplate extends Skin { public $username; public $userpageUrlDetails; - /** - * Add specific styles for this skin - * - * @param OutputPage $out - */ - public function setupSkinUserCss( OutputPage $out ) { - $moduleStyles = [ - 'mediawiki.legacy.shared', - 'mediawiki.legacy.commonPrint', - 'mediawiki.sectionAnchor' - ]; - if ( $out->isSyndicated() ) { - $moduleStyles[] = 'mediawiki.feedlink'; - } - - // Deprecated since 1.26: Unconditional loading of mediawiki.ui.button - // on every page is deprecated. Express a dependency instead. - if ( strpos( $out->getHTML(), 'mw-ui-button' ) !== false ) { - $moduleStyles[] = 'mediawiki.ui.button'; - } - - $out->addModuleStyles( $moduleStyles ); - } - /** * Create the template engine object; we feed it a bunch of data * and eventually it spits out some HTML. Should have interface diff --git a/includes/sparql/SparqlClient.php b/includes/sparql/SparqlClient.php index 6c913d2a44..778a3b3248 100644 --- a/includes/sparql/SparqlClient.php +++ b/includes/sparql/SparqlClient.php @@ -173,9 +173,9 @@ class SparqlClient { throw new SparqlException( "HTTP error: {$status->getWikiText()}" ); } $result = $request->getContent(); - \MediaWiki\suppressWarnings(); + \Wikimedia\suppressWarnings(); $data = json_decode( $result, true ); - \MediaWiki\restoreWarnings(); + \Wikimedia\restoreWarnings(); if ( $data === null || $data === false ) { throw new SparqlException( "HTTP request failed, response:\n" . substr( $result, 1024 ) ); diff --git a/includes/specialpage/ChangesListSpecialPage.php b/includes/specialpage/ChangesListSpecialPage.php index b9d20bea16..ac13f113b2 100644 --- a/includes/specialpage/ChangesListSpecialPage.php +++ b/includes/specialpage/ChangesListSpecialPage.php @@ -22,7 +22,7 @@ */ use MediaWiki\Logger\LoggerFactory; use Wikimedia\Rdbms\DBQueryTimeoutError; -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\FakeResultWrapper; use Wikimedia\Rdbms\IDatabase; @@ -87,9 +87,12 @@ abstract class ChangesListSpecialPage extends SpecialPage { // Same format as filterGroupDefinitions, but for a single group (reviewStatus) // that is registered conditionally. + private $legacyReviewStatusFilterGroupDefinition; + + // Single filter group registered conditionally private $reviewStatusFilterGroupDefinition; - // Single filter registered conditionally + // Single filter group registered conditionally private $hideCategorizationFilterDefinition; /** @@ -276,7 +279,7 @@ abstract class ChangesListSpecialPage extends SpecialPage { 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds ) { - $conds[] = 'rc_bot = 0'; + $conds['rc_bot'] = 0; }, 'cssClassSuffix' => 'bot', 'isRowApplicableCallable' => function ( $ctx, $rc ) { @@ -291,7 +294,7 @@ abstract class ChangesListSpecialPage extends SpecialPage { 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds ) { - $conds[] = 'rc_bot = 1'; + $conds['rc_bot'] = 1; }, 'cssClassSuffix' => 'human', 'isRowApplicableCallable' => function ( $ctx, $rc ) { @@ -301,7 +304,7 @@ abstract class ChangesListSpecialPage extends SpecialPage { ] ], - // reviewStatus (conditional) + // significance (conditional) [ 'name' => 'significance', @@ -457,17 +460,14 @@ abstract class ChangesListSpecialPage extends SpecialPage { ]; - $this->reviewStatusFilterGroupDefinition = [ + $this->legacyReviewStatusFilterGroupDefinition = [ [ - 'name' => 'reviewStatus', + 'name' => 'legacyReviewStatus', 'title' => 'rcfilters-filtergroup-reviewstatus', 'class' => ChangesListBooleanFilterGroup::class, - 'priority' => -5, 'filters' => [ [ 'name' => 'hidepatrolled', - 'label' => 'rcfilters-filter-patrolled-label', - 'description' => 'rcfilters-filter-patrolled-description', // rcshowhidepatr-show, rcshowhidepatr-hide // wlshowhidepatr 'showHideSuffix' => 'showhidepatr', @@ -475,29 +475,77 @@ abstract class ChangesListSpecialPage extends SpecialPage { 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds ) { - $conds[] = 'rc_patrolled = 0'; - }, - 'cssClassSuffix' => 'patrolled', - 'isRowApplicableCallable' => function ( $ctx, $rc ) { - return $rc->getAttribute( 'rc_patrolled' ); + $conds['rc_patrolled'] = RecentChange::PRC_UNPATROLLED; }, + 'isReplacedInStructuredUi' => true, ], [ 'name' => 'hideunpatrolled', - 'label' => 'rcfilters-filter-unpatrolled-label', - 'description' => 'rcfilters-filter-unpatrolled-description', 'default' => false, 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds, &$query_options, &$join_conds ) { - $conds[] = 'rc_patrolled != 0'; + $conds[] = 'rc_patrolled != ' . RecentChange::PRC_UNPATROLLED; + }, + 'isReplacedInStructuredUi' => true, + ], + ], + ] + ]; + + $this->reviewStatusFilterGroupDefinition = [ + [ + 'name' => 'reviewStatus', + 'title' => 'rcfilters-filtergroup-reviewstatus', + 'class' => ChangesListStringOptionsFilterGroup::class, + 'isFullCoverage' => true, + 'priority' => -5, + 'filters' => [ + [ + 'name' => 'unpatrolled', + 'label' => 'rcfilters-filter-reviewstatus-unpatrolled-label', + 'description' => 'rcfilters-filter-reviewstatus-unpatrolled-description', + 'cssClassSuffix' => 'reviewstatus-unpatrolled', + 'isRowApplicableCallable' => function ( $ctx, $rc ) { + return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED; }, - 'cssClassSuffix' => 'unpatrolled', + ], + [ + 'name' => 'manual', + 'label' => 'rcfilters-filter-reviewstatus-manual-label', + 'description' => 'rcfilters-filter-reviewstatus-manual-description', + 'cssClassSuffix' => 'reviewstatus-manual', 'isRowApplicableCallable' => function ( $ctx, $rc ) { - return !$rc->getAttribute( 'rc_patrolled' ); + return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_PATROLLED; + }, + ], + [ + 'name' => 'auto', + 'label' => 'rcfilters-filter-reviewstatus-auto-label', + 'description' => 'rcfilters-filter-reviewstatus-auto-description', + 'cssClassSuffix' => 'reviewstatus-auto', + 'isRowApplicableCallable' => function ( $ctx, $rc ) { + return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_AUTOPATROLLED; }, ], ], + 'default' => ChangesListStringOptionsFilterGroup::NONE, + 'queryCallable' => function ( $specialPageClassName, $ctx, $dbr, + &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selected + ) { + if ( $selected === [] ) { + return; + } + $rcPatrolledValues = [ + 'unpatrolled' => RecentChange::PRC_UNPATROLLED, + 'manual' => RecentChange::PRC_PATROLLED, + 'auto' => RecentChange::PRC_AUTOPATROLLED, + ]; + // e.g. rc_patrolled IN (0, 2) + $conds['rc_patrolled'] = array_map( function ( $s ) use ( $rcPatrolledValues ) { + return $rcPatrolledValues[ $s ]; + }, $selected ); + } ] ]; @@ -866,7 +914,7 @@ abstract class ChangesListSpecialPage extends SpecialPage { /** * Get the database result for this special page instance. Used by ApiFeedRecentChanges. * - * @return bool|ResultWrapper Result or false + * @return bool|IResultWrapper Result or false */ public function getRows() { $opts = $this->getOptions(); @@ -910,6 +958,7 @@ abstract class ChangesListSpecialPage extends SpecialPage { // information to all users just because the user that saves the edit can // patrol or is logged in) if ( !$this->including() && $this->getUser()->useRCPatrol() ) { + $this->registerFiltersFromDefinitions( $this->legacyReviewStatusFilterGroupDefinition ); $this->registerFiltersFromDefinitions( $this->reviewStatusFilterGroupDefinition ); } @@ -1339,7 +1388,7 @@ abstract class ChangesListSpecialPage extends SpecialPage { } /** - * Replace old options 'hideanons' or 'hideliu' with structured UI equivalent + * Replace old options with their structured UI equivalents * * @param FormOptions $opts * @return bool True if the change was made @@ -1349,21 +1398,40 @@ abstract class ChangesListSpecialPage extends SpecialPage { return false; } + $changed = false; + // At this point 'hideanons' and 'hideliu' cannot be both true, // because fixBackwardsCompatibilityOptions resets (at least) 'hideanons' in such case if ( $opts[ 'hideanons' ] ) { $opts->reset( 'hideanons' ); $opts[ 'userExpLevel' ] = 'registered'; - return true; + $changed = true; } if ( $opts[ 'hideliu' ] ) { $opts->reset( 'hideliu' ); $opts[ 'userExpLevel' ] = 'unregistered'; - return true; + $changed = true; } - return false; + if ( $this->getFilterGroup( 'legacyReviewStatus' ) ) { + if ( $opts[ 'hidepatrolled' ] ) { + $opts->reset( 'hidepatrolled' ); + $opts[ 'reviewStatus' ] = 'unpatrolled'; + $changed = true; + } + + if ( $opts[ 'hideunpatrolled' ] ) { + $opts->reset( 'hideunpatrolled' ); + $opts[ 'reviewStatus' ] = implode( + ChangesListStringOptionsFilterGroup::SEPARATOR, + [ 'manual', 'auto' ] + ); + $changed = true; + } + } + + return $changed; } /** @@ -1455,7 +1523,7 @@ abstract class ChangesListSpecialPage extends SpecialPage { * @param array $query_options Array of query options; see IDatabase::select $options * @param array $join_conds Array of join conditions; see IDatabase::select $join_conds * @param FormOptions $opts - * @return bool|ResultWrapper Result or false + * @return bool|IResultWrapper Result or false */ protected function doMainQuery( $tables, $fields, $conds, $query_options, $join_conds, FormOptions $opts @@ -1526,7 +1594,7 @@ abstract class ChangesListSpecialPage extends SpecialPage { /** * Send output to the OutputPage object, only called if not used feeds * - * @param ResultWrapper $rows Database rows + * @param IResultWrapper $rows Database rows * @param FormOptions $opts */ public function webOutput( $rows, $opts ) { @@ -1545,7 +1613,7 @@ abstract class ChangesListSpecialPage extends SpecialPage { /** * Build and output the actual changes list. * - * @param ResultWrapper $rows Database rows + * @param IResultWrapper $rows Database rows * @param FormOptions $opts */ abstract public function outputChangesList( $rows, $opts ); diff --git a/includes/specialpage/ImageQueryPage.php b/includes/specialpage/ImageQueryPage.php index 59abefd83e..49aaffd004 100644 --- a/includes/specialpage/ImageQueryPage.php +++ b/includes/specialpage/ImageQueryPage.php @@ -21,7 +21,7 @@ * @ingroup SpecialPage */ -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\IDatabase; /** @@ -39,7 +39,7 @@ abstract class ImageQueryPage extends QueryPage { * @param OutputPage $out OutputPage to print to * @param Skin $skin User skin to use [unused] * @param IDatabase $dbr (read) connection to use - * @param ResultWrapper $res Result pointer + * @param IResultWrapper $res Result pointer * @param int $num Number of available result rows * @param int $offset Paging offset */ diff --git a/includes/specialpage/LoginSignupSpecialPage.php b/includes/specialpage/LoginSignupSpecialPage.php index d6ace0afcf..1c54d13d97 100644 --- a/includes/specialpage/LoginSignupSpecialPage.php +++ b/includes/specialpage/LoginSignupSpecialPage.php @@ -515,7 +515,6 @@ abstract class LoginSignupSpecialPage extends AuthManagerSpecialPage { * @private */ protected function mainLoginForm( array $requests, $msg = '', $msgtype = 'error' ) { - $titleObj = $this->getPageTitle(); $user = $this->getUser(); $out = $this->getOutput(); diff --git a/includes/specialpage/PageQueryPage.php b/includes/specialpage/PageQueryPage.php index f7f0499350..7d6db0544d 100644 --- a/includes/specialpage/PageQueryPage.php +++ b/includes/specialpage/PageQueryPage.php @@ -21,7 +21,7 @@ * @ingroup SpecialPage */ -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\IDatabase; /** @@ -36,7 +36,7 @@ abstract class PageQueryPage extends QueryPage { * This should be done for live data and cached data. * * @param IDatabase $db - * @param ResultWrapper $res + * @param IResultWrapper $res */ public function preprocessResults( $db, $res ) { $this->executeLBFromResultWrapper( $res ); diff --git a/includes/specialpage/QueryPage.php b/includes/specialpage/QueryPage.php index b20f22214b..f642106af7 100644 --- a/includes/specialpage/QueryPage.php +++ b/includes/specialpage/QueryPage.php @@ -21,7 +21,7 @@ * @ingroup SpecialPage */ -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\IDatabase; use Wikimedia\Rdbms\DBError; @@ -387,7 +387,7 @@ abstract class QueryPage extends SpecialPage { * Run the query and return the result * @param int|bool $limit Numerical limit or false for no limit * @param int|bool $offset Numerical offset or false for no offset - * @return ResultWrapper + * @return IResultWrapper * @since 1.18 */ public function reallyDoQuery( $limit, $offset = false ) { @@ -439,7 +439,7 @@ abstract class QueryPage extends SpecialPage { * Somewhat deprecated, you probably want to be using execute() * @param int|bool $offset * @param int|bool $limit - * @return ResultWrapper + * @return IResultWrapper */ public function doQuery( $offset = false, $limit = false ) { if ( $this->isCached() && $this->isCacheable() ) { @@ -453,7 +453,7 @@ abstract class QueryPage extends SpecialPage { * Fetch the query results from the query cache * @param int|bool $limit Numerical limit or false for no limit * @param int|bool $offset Numerical offset or false for no offset - * @return ResultWrapper + * @return IResultWrapper * @since 1.18 */ public function fetchFromCache( $limit, $offset = false ) { @@ -685,7 +685,7 @@ abstract class QueryPage extends SpecialPage { * @param OutputPage $out OutputPage to print to * @param Skin $skin User skin to use * @param IDatabase $dbr Database (read) connection to use - * @param ResultWrapper $res Result pointer + * @param IResultWrapper $res Result pointer * @param int $num Number of available result rows * @param int $offset Paging offset */ @@ -751,7 +751,7 @@ abstract class QueryPage extends SpecialPage { /** * Do any necessary preprocessing of the result object. * @param IDatabase $db - * @param ResultWrapper $res + * @param IResultWrapper $res */ function preprocessResults( $db, $res ) { } @@ -853,12 +853,12 @@ abstract class QueryPage extends SpecialPage { * title and optional the namespace field) and executes the batch. This operation will pre-cache * LinkCache information like page existence and information for stub color and redirect hints. * - * @param ResultWrapper $res The ResultWrapper object to process. Needs to include the title + * @param IResultWrapper $res The ResultWrapper object to process. Needs to include the title * field and namespace field, if the $ns parameter isn't set. * @param null $ns Use this namespace for the given titles in the ResultWrapper object, * instead of the namespace value of $res. */ - protected function executeLBFromResultWrapper( ResultWrapper $res, $ns = null ) { + protected function executeLBFromResultWrapper( IResultWrapper $res, $ns = null ) { if ( !$res->numRows() ) { return; } diff --git a/includes/specialpage/WantedQueryPage.php b/includes/specialpage/WantedQueryPage.php index 8b60387efa..83ffe40a51 100644 --- a/includes/specialpage/WantedQueryPage.php +++ b/includes/specialpage/WantedQueryPage.php @@ -21,7 +21,7 @@ * @ingroup SpecialPage */ -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\IDatabase; /** @@ -41,7 +41,7 @@ abstract class WantedQueryPage extends QueryPage { /** * Cache page existence for performance * @param IDatabase $db - * @param ResultWrapper $res + * @param IResultWrapper $res */ function preprocessResults( $db, $res ) { $this->executeLBFromResultWrapper( $res ); diff --git a/includes/specials/SpecialActiveusers.php b/includes/specials/SpecialActiveusers.php index 902878781c..0c709af761 100644 --- a/includes/specials/SpecialActiveusers.php +++ b/includes/specials/SpecialActiveusers.php @@ -80,10 +80,12 @@ class SpecialActiveUsers extends SpecialPage { protected function buildForm() { $groups = User::getAllGroups(); + $options = []; foreach ( $groups as $group ) { $msg = htmlspecialchars( UserGroupMembership::getGroupName( $group ) ); $options[$msg] = $group; } + asort( $options ); // Backwards-compatibility with old URLs $req = $this->getRequest(); diff --git a/includes/specials/SpecialApiSandbox.php b/includes/specials/SpecialApiSandbox.php index 2733e75716..c000d546d1 100644 --- a/includes/specials/SpecialApiSandbox.php +++ b/includes/specials/SpecialApiSandbox.php @@ -35,10 +35,6 @@ class SpecialApiSandbox extends SpecialPage { $out = $this->getOutput(); $this->addHelpLink( 'Help:ApiSandbox' ); - if ( !$this->getConfig()->get( 'EnableAPI' ) ) { - $out->showErrorPage( 'error', 'apisandbox-api-disabled' ); - } - $out->addJsConfigVars( 'apihighlimits', $this->getUser()->isAllowed( 'apihighlimits' ) ); $out->addModuleStyles( [ 'mediawiki.special.apisandbox.styles', diff --git a/includes/specials/SpecialAutoblockList.php b/includes/specials/SpecialAutoblockList.php index bf138656bd..e1909f5243 100644 --- a/includes/specials/SpecialAutoblockList.php +++ b/includes/specials/SpecialAutoblockList.php @@ -42,7 +42,6 @@ class SpecialAutoblockList extends SpecialPage { $this->setHeaders(); $this->outputHeader(); $out = $this->getOutput(); - $lang = $this->getLanguage(); $out->setPageTitle( $this->msg( 'autoblocklist' ) ); $this->addHelpLink( 'Autoblock' ); $out->addModuleStyles( [ 'mediawiki.special' ] ); @@ -55,13 +54,7 @@ class SpecialAutoblockList extends SpecialPage { 'Limit' => [ 'type' => 'limitselect', 'label-message' => 'table_pager_limit_label', - 'options' => [ - $lang->formatNum( 20 ) => 20, - $lang->formatNum( 50 ) => 50, - $lang->formatNum( 100 ) => 100, - $lang->formatNum( 250 ) => 250, - $lang->formatNum( 500 ) => 500, - ], + 'options' => $pager->getLimitSelectList(), 'name' => 'limit', 'default' => $pager->getLimit(), ] @@ -74,7 +67,6 @@ class SpecialAutoblockList extends SpecialPage { ->setFormIdentifier( 'blocklist' ) ->setWrapperLegendMsg( 'autoblocklist-legend' ) ->setSubmitTextMsg( 'autoblocklist-submit' ) - ->setSubmitProgressive() ->prepareForm() ->displayForm( false ); diff --git a/includes/specials/SpecialBlock.php b/includes/specials/SpecialBlock.php index 23691b251a..efe354a346 100644 --- a/includes/specials/SpecialBlock.php +++ b/includes/specials/SpecialBlock.php @@ -151,11 +151,10 @@ class SpecialBlock extends FormSpecialPage { 'validation-callback' => [ __CLASS__, 'validateTargetField' ], ], 'Expiry' => [ - 'type' => !count( $suggestedDurations ) ? 'text' : 'selectorother', + 'type' => 'expiry', 'label-message' => 'ipbexpiry', 'required' => true, 'options' => $suggestedDurations, - 'other' => $this->msg( 'ipbother' )->text(), 'default' => $this->msg( 'ipb-default-expiry' )->inContentLanguage()->text(), ], 'Reason' => [ @@ -876,29 +875,38 @@ class SpecialBlock extends FormSpecialPage { $a[$show] = $value; } + if ( $a ) { + // if options exist, add other to the end instead of the begining (which + // is what happens by default). + $a[ wfMessage( 'ipbother' )->text() ] = 'other'; + } + return $a; } /** * Convert a submitted expiry time, which may be relative ("2 weeks", etc) or absolute * ("24 May 2034", etc), into an absolute timestamp we can put into the database. + * + * @todo strtotime() only accepts English strings. This means the expiry input + * can only be specified in English. + * @see https://secure.php.net/manual/en/function.strtotime.php + * * @param string $expiry Whatever was typed into the form - * @return string Timestamp or 'infinity' + * @return string|bool Timestamp or 'infinity' or false on error. */ public static function parseExpiryInput( $expiry ) { if ( wfIsInfinity( $expiry ) ) { - $expiry = 'infinity'; - } else { - $expiry = strtotime( $expiry ); + return 'infinity'; + } - if ( $expiry < 0 || $expiry === false ) { - return false; - } + $expiry = strtotime( $expiry ); - $expiry = wfTimestamp( TS_MW, $expiry ); + if ( $expiry < 0 || $expiry === false ) { + return false; } - return $expiry; + return wfTimestamp( TS_MW, $expiry ); } /** diff --git a/includes/specials/SpecialBlockList.php b/includes/specials/SpecialBlockList.php index 0899d5809c..186e5ad741 100644 --- a/includes/specials/SpecialBlockList.php +++ b/includes/specials/SpecialBlockList.php @@ -44,7 +44,6 @@ class SpecialBlockList extends SpecialPage { $this->setHeaders(); $this->outputHeader(); $out = $this->getOutput(); - $lang = $this->getLanguage(); $out->setPageTitle( $this->msg( 'ipblocklist' ) ); $out->addModuleStyles( [ 'mediawiki.special' ] ); @@ -89,13 +88,7 @@ class SpecialBlockList extends SpecialPage { 'Limit' => [ 'type' => 'limitselect', 'label-message' => 'table_pager_limit_label', - 'options' => [ - $lang->formatNum( 20 ) => 20, - $lang->formatNum( 50 ) => 50, - $lang->formatNum( 100 ) => 100, - $lang->formatNum( 250 ) => 250, - $lang->formatNum( 500 ) => 500, - ], + 'options' => $pager->getLimitSelectList(), 'name' => 'limit', 'default' => $pager->getLimit(), ], @@ -108,7 +101,6 @@ class SpecialBlockList extends SpecialPage { ->setFormIdentifier( 'blocklist' ) ->setWrapperLegendMsg( 'ipblocklist-legend' ) ->setSubmitTextMsg( 'ipblocklist-submit' ) - ->setSubmitProgressive() ->prepareForm() ->displayForm( false ); diff --git a/includes/specials/SpecialBotPasswords.php b/includes/specials/SpecialBotPasswords.php index f76c318e26..7b2d1bcbfd 100644 --- a/includes/specials/SpecialBotPasswords.php +++ b/includes/specials/SpecialBotPasswords.php @@ -107,6 +107,9 @@ class SpecialBotPasswords extends FormSpecialPage { 'type' => 'check', 'label-message' => 'botpasswords-label-resetpassword', ]; + if ( $this->botPassword->isInvalid() ) { + $fields['resetPassword']['default'] = true; + } } $lang = $this->getLanguage(); @@ -153,22 +156,39 @@ class SpecialBotPasswords extends FormSpecialPage { } else { $linkRenderer = $this->getLinkRenderer(); + $passwordFactory = new PasswordFactory(); + $passwordFactory->init( $this->getConfig() ); + $dbr = BotPassword::getDB( DB_REPLICA ); $res = $dbr->select( 'bot_passwords', - [ 'bp_app_id' ], + [ 'bp_app_id', 'bp_password' ], [ 'bp_user' => $this->userId ], __METHOD__ ); foreach ( $res as $row ) { + try { + $password = $passwordFactory->newFromCiphertext( $row->bp_password ); + $passwordInvalid = $password instanceof InvalidPassword; + unset( $password ); + } catch ( PasswordError $ex ) { + $passwordInvalid = true; + } + + $text = $linkRenderer->makeKnownLink( + $this->getPageTitle( $row->bp_app_id ), + $row->bp_app_id + ); + if ( $passwordInvalid ) { + $text .= $this->msg( 'word-separator' )->escaped() + . $this->msg( 'botpasswords-label-needsreset' )->parse(); + } + $fields[] = [ 'section' => 'existing', 'type' => 'info', 'raw' => true, - 'default' => $linkRenderer->makeKnownLink( - $this->getPageTitle( $row->bp_app_id ), - $row->bp_app_id - ), + 'default' => $text, ]; } diff --git a/includes/specials/SpecialBrokenRedirects.php b/includes/specials/SpecialBrokenRedirects.php index cf9ae07187..3e1909b836 100644 --- a/includes/specials/SpecialBrokenRedirects.php +++ b/includes/specials/SpecialBrokenRedirects.php @@ -21,7 +21,7 @@ * @ingroup SpecialPage */ -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\IDatabase; /** @@ -167,7 +167,7 @@ class BrokenRedirectsPage extends QueryPage { * Cache page content model for performance * * @param IDatabase $db - * @param ResultWrapper $res + * @param IResultWrapper $res */ function preprocessResults( $db, $res ) { $this->executeLBFromResultWrapper( $res ); diff --git a/includes/specials/SpecialDoubleRedirects.php b/includes/specials/SpecialDoubleRedirects.php index d73ac19875..77c59f0387 100644 --- a/includes/specials/SpecialDoubleRedirects.php +++ b/includes/specials/SpecialDoubleRedirects.php @@ -21,7 +21,7 @@ * @ingroup SpecialPage */ -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\IDatabase; /** @@ -202,7 +202,7 @@ class DoubleRedirectsPage extends QueryPage { * Cache page content model and gender distinction for performance * * @param IDatabase $db - * @param ResultWrapper $res + * @param IResultWrapper $res */ function preprocessResults( $db, $res ) { if ( !$res->numRows() ) { diff --git a/includes/specials/SpecialEditWatchlist.php b/includes/specials/SpecialEditWatchlist.php index f702bc0bcc..5e04d8d35c 100644 --- a/includes/specials/SpecialEditWatchlist.php +++ b/includes/specials/SpecialEditWatchlist.php @@ -667,7 +667,7 @@ class SpecialEditWatchlist extends UnlistedSpecialPage { ]; $context = new DerivativeContext( $this->getContext() ); $context->setTitle( $this->getPageTitle( 'raw' ) ); // Reset subpage - $form = new HTMLForm( $fields, $context ); + $form = new OOUIHTMLForm( $fields, $context ); $form->setSubmitTextMsg( 'watchlistedit-raw-submit' ); # Used message keys: 'accesskey-watchlistedit-raw-submit', 'tooltip-watchlistedit-raw-submit' $form->setSubmitTooltip( 'watchlistedit-raw-submit' ); @@ -686,7 +686,7 @@ class SpecialEditWatchlist extends UnlistedSpecialPage { protected function getClearForm() { $context = new DerivativeContext( $this->getContext() ); $context->setTitle( $this->getPageTitle( 'clear' ) ); // Reset subpage - $form = new HTMLForm( [], $context ); + $form = new OOUIHTMLForm( [], $context ); $form->setSubmitTextMsg( 'watchlistedit-clear-submit' ); # Used message keys: 'accesskey-watchlistedit-clear-submit', 'tooltip-watchlistedit-clear-submit' $form->setSubmitTooltip( 'watchlistedit-clear-submit' ); diff --git a/includes/specials/SpecialFileDuplicateSearch.php b/includes/specials/SpecialFileDuplicateSearch.php index 7694a61069..e6d81c99fe 100644 --- a/includes/specials/SpecialFileDuplicateSearch.php +++ b/includes/specials/SpecialFileDuplicateSearch.php @@ -131,7 +131,6 @@ class FileDuplicateSearchPage extends QueryPage { $htmlForm->addHiddenFields( $hiddenFields ); $htmlForm->setAction( wfScript() ); $htmlForm->setMethod( 'get' ); - $htmlForm->setSubmitProgressive(); $htmlForm->setSubmitTextMsg( $this->msg( 'fileduplicatesearch-submit' ) ); // The form should be visible always, even if it was submitted (e.g. to perform another action). diff --git a/includes/specials/SpecialLinkSearch.php b/includes/specials/SpecialLinkSearch.php index cda0854d4b..ef9525438c 100644 --- a/includes/specials/SpecialLinkSearch.php +++ b/includes/specials/SpecialLinkSearch.php @@ -22,7 +22,7 @@ * @author Brion Vibber */ -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\IDatabase; /** @@ -225,7 +225,7 @@ class LinkSearchPage extends QueryPage { * Pre-fill the link cache * * @param IDatabase $db - * @param ResultWrapper $res + * @param IResultWrapper $res */ function preprocessResults( $db, $res ) { $this->executeLBFromResultWrapper( $res ); diff --git a/includes/specials/SpecialListDuplicatedFiles.php b/includes/specials/SpecialListDuplicatedFiles.php index d5fb0018ca..4c847e9e39 100644 --- a/includes/specials/SpecialListDuplicatedFiles.php +++ b/includes/specials/SpecialListDuplicatedFiles.php @@ -24,7 +24,7 @@ * @author Brian Wolff */ -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\IDatabase; /** @@ -75,7 +75,7 @@ class ListDuplicatedFilesPage extends QueryPage { * Pre-fill the link cache * * @param IDatabase $db - * @param ResultWrapper $res + * @param IResultWrapper $res */ function preprocessResults( $db, $res ) { $this->executeLBFromResultWrapper( $res ); diff --git a/includes/specials/SpecialListredirects.php b/includes/specials/SpecialListredirects.php index f81c03c77a..48f364027e 100644 --- a/includes/specials/SpecialListredirects.php +++ b/includes/specials/SpecialListredirects.php @@ -24,7 +24,7 @@ * @author Rob Church */ -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\IDatabase; /** @@ -76,7 +76,7 @@ class ListredirectsPage extends QueryPage { * Cache page existence for performance * * @param IDatabase $db - * @param ResultWrapper $res + * @param IResultWrapper $res */ function preprocessResults( $db, $res ) { if ( !$res->numRows() ) { diff --git a/includes/specials/SpecialLog.php b/includes/specials/SpecialLog.php index 6a11bf4d94..bad17466b5 100644 --- a/includes/specials/SpecialLog.php +++ b/includes/specials/SpecialLog.php @@ -51,6 +51,7 @@ class SpecialLog extends SpecialPage { $opts->add( 'dir', '' ); $opts->add( 'offender', '' ); $opts->add( 'subtype', '' ); + $opts->add( 'logid', '' ); // Set values $opts->fetchValuesFromRequest( $this->getRequest() ); @@ -169,6 +170,16 @@ class SpecialLog extends SpecialPage { return $subpages; } + /** + * Set options based on the subpage title parts: + * - One part that is a valid log type: Special:Log/logtype + * - Two parts: Special:Log/logtype/username + * - Otherwise, assume the whole subpage is a username. + * + * @param FormOptions $opts + * @param $par + * @throws ConfigException + */ private function parseParams( FormOptions $opts, $par ) { # Get parameters $par = $par !== null ? $par : ''; @@ -204,7 +215,8 @@ class SpecialLog extends SpecialPage { $opts->getValue( 'year' ), $opts->getValue( 'month' ), $opts->getValue( 'tagfilter' ), - $opts->getValue( 'subtype' ) + $opts->getValue( 'subtype' ), + $opts->getValue( 'logid' ) ); $this->addHeader( $opts->getValue( 'type' ) ); diff --git a/includes/specials/SpecialMediaStatistics.php b/includes/specials/SpecialMediaStatistics.php index 15749b20cd..943fa57062 100644 --- a/includes/specials/SpecialMediaStatistics.php +++ b/includes/specials/SpecialMediaStatistics.php @@ -22,7 +22,7 @@ * @author Brian Wolff */ -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\IDatabase; /** @@ -114,7 +114,7 @@ class MediaStatisticsPage extends QueryPage { * @param OutputPage $out * @param Skin $skin (deprecated presumably) * @param IDatabase $dbr - * @param ResultWrapper $res Results from query + * @param IResultWrapper $res Results from query * @param int $num Number of results * @param int $offset Paging offset (Should always be 0 in our case) */ @@ -356,7 +356,7 @@ class MediaStatisticsPage extends QueryPage { * Initialize total values so we can figure out percentages later. * * @param IDatabase $dbr - * @param ResultWrapper $res + * @param IResultWrapper $res */ public function preprocessResults( $dbr, $res ) { $this->executeLBFromResultWrapper( $res ); diff --git a/includes/specials/SpecialMostcategories.php b/includes/specials/SpecialMostcategories.php index bebed12e38..123c174080 100644 --- a/includes/specials/SpecialMostcategories.php +++ b/includes/specials/SpecialMostcategories.php @@ -24,7 +24,7 @@ * @author Ævar Arnfjörð Bjarmason */ -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\IDatabase; /** @@ -69,7 +69,7 @@ class MostcategoriesPage extends QueryPage { /** * @param IDatabase $db - * @param ResultWrapper $res + * @param IResultWrapper $res */ function preprocessResults( $db, $res ) { $this->executeLBFromResultWrapper( $res ); diff --git a/includes/specials/SpecialMostinterwikis.php b/includes/specials/SpecialMostinterwikis.php index 5e56694f6f..c963838462 100644 --- a/includes/specials/SpecialMostinterwikis.php +++ b/includes/specials/SpecialMostinterwikis.php @@ -21,7 +21,7 @@ * @ingroup SpecialPage */ -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\IDatabase; /** @@ -72,7 +72,7 @@ class MostinterwikisPage extends QueryPage { * Pre-fill the link cache * * @param IDatabase $db - * @param ResultWrapper $res + * @param IResultWrapper $res */ function preprocessResults( $db, $res ) { $this->executeLBFromResultWrapper( $res ); diff --git a/includes/specials/SpecialMostlinked.php b/includes/specials/SpecialMostlinked.php index fbfaa73831..c4553a4fad 100644 --- a/includes/specials/SpecialMostlinked.php +++ b/includes/specials/SpecialMostlinked.php @@ -25,7 +25,7 @@ * @author Rob Church */ -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\IDatabase; /** @@ -78,7 +78,7 @@ class MostlinkedPage extends QueryPage { * Pre-fill the link cache * * @param IDatabase $db - * @param ResultWrapper $res + * @param IResultWrapper $res */ function preprocessResults( $db, $res ) { $this->executeLBFromResultWrapper( $res ); diff --git a/includes/specials/SpecialMostlinkedcategories.php b/includes/specials/SpecialMostlinkedcategories.php index 956207f883..f238f6c086 100644 --- a/includes/specials/SpecialMostlinkedcategories.php +++ b/includes/specials/SpecialMostlinkedcategories.php @@ -24,7 +24,7 @@ * @author Ævar Arnfjörð Bjarmason */ -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\IDatabase; /** @@ -59,7 +59,7 @@ class MostlinkedCategoriesPage extends QueryPage { * Fetch user page links and cache their existence * * @param IDatabase $db - * @param ResultWrapper $res + * @param IResultWrapper $res */ function preprocessResults( $db, $res ) { $this->executeLBFromResultWrapper( $res ); diff --git a/includes/specials/SpecialMostlinkedtemplates.php b/includes/specials/SpecialMostlinkedtemplates.php index dee1c8ec5b..4544468d69 100644 --- a/includes/specials/SpecialMostlinkedtemplates.php +++ b/includes/specials/SpecialMostlinkedtemplates.php @@ -22,7 +22,7 @@ * @author Rob Church */ -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\IDatabase; /** @@ -79,7 +79,7 @@ class MostlinkedTemplatesPage extends QueryPage { * Pre-cache page existence to speed up link generation * * @param IDatabase $db - * @param ResultWrapper $res + * @param IResultWrapper $res */ public function preprocessResults( $db, $res ) { $this->executeLBFromResultWrapper( $res ); diff --git a/includes/specials/SpecialNewpages.php b/includes/specials/SpecialNewpages.php index 46d5276c97..cd3da4f0fa 100644 --- a/includes/specials/SpecialNewpages.php +++ b/includes/specials/SpecialNewpages.php @@ -207,7 +207,6 @@ class SpecialNewpages extends IncludableSpecialPage { protected function form() { $out = $this->getOutput(); - $out->addModules( 'mediawiki.userSuggest' ); // Consume values $this->opts->consumeValue( 'offset' ); // don't carry offset, DWIW @@ -251,13 +250,12 @@ class SpecialNewpages extends IncludableSpecialPage { 'default' => $tagFilterVal, ], 'username' => [ - 'type' => 'text', + 'type' => 'user', 'name' => 'username', 'label-message' => 'newpages-username', 'default' => $userText, 'id' => 'mw-np-username', 'size' => 30, - 'cssclass' => 'mw-autocomplete-user', // used by mediawiki.userSuggest ], 'size' => [ 'type' => 'sizefilter', @@ -269,7 +267,6 @@ class SpecialNewpages extends IncludableSpecialPage { $htmlForm = new HTMLForm( $form, $this->getContext() ); $htmlForm->setSubmitText( $this->msg( 'newpages-submit' )->text() ); - $htmlForm->setSubmitProgressive(); // The form should be visible on each request (inclusive requests with submitted forms), so // return always false here. $htmlForm->setSubmitCallback( diff --git a/includes/specials/SpecialPasswordReset.php b/includes/specials/SpecialPasswordReset.php index 84292f3ed9..753923597b 100644 --- a/includes/specials/SpecialPasswordReset.php +++ b/includes/specials/SpecialPasswordReset.php @@ -105,6 +105,8 @@ class SpecialPasswordReset extends FormSpecialPage { public function alterForm( HTMLForm $form ) { $resetRoutes = $this->getConfig()->get( 'PasswordResetRoutes' ); + $form->setSubmitDestructive(); + $form->addHiddenFields( $this->getRequest()->getValues( 'returnto', 'returntoquery' ) ); $i = 0; diff --git a/includes/specials/SpecialPreferences.php b/includes/specials/SpecialPreferences.php index a5c24e7b1e..1cfcffa85d 100644 --- a/includes/specials/SpecialPreferences.php +++ b/includes/specials/SpecialPreferences.php @@ -29,15 +29,33 @@ use MediaWiki\MediaWikiServices; * @ingroup SpecialPage */ class SpecialPreferences extends SpecialPage { + /** + * @var bool Whether OOUI should be enabled here + */ + private $oouiEnabled = false; + function __construct() { parent::__construct( 'Preferences' ); } + /** + * Check if OOUI mode is enabled, by config or query string + * @param IContextSource $context The context. + * @return bool + */ + public static function isOouiEnabled( IContextSource $context ) { + return $context->getRequest()->getFuzzyBool( 'ooui', + $context->getConfig()->get( 'OOUIPreferences' ) + ); + } + public function doesWrites() { return true; } public function execute( $par ) { + $this->oouiEnabled = static::isOouiEnabled( $this->getContext() ); + $this->setHeaders(); $this->outputHeader(); $out = $this->getOutput(); @@ -52,8 +70,13 @@ class SpecialPreferences extends SpecialPage { return; } - $out->addModules( 'mediawiki.special.preferences' ); - $out->addModuleStyles( 'mediawiki.special.preferences.styles' ); + if ( $this->oouiEnabled ) { + $out->addModules( 'mediawiki.special.preferences.ooui' ); + $out->addModuleStyles( 'mediawiki.special.preferences.styles.ooui' ); + } else { + $out->addModules( 'mediawiki.special.preferences' ); + $out->addModuleStyles( 'mediawiki.special.preferences.styles' ); + } $session = $this->getRequest()->getSession(); if ( $session->get( 'specialPreferencesSaveSuccess' ) ) { @@ -86,35 +109,53 @@ class SpecialPreferences extends SpecialPage { $htmlForm = $this->getFormObject( $user, $this->getContext() ); $sectionTitles = $htmlForm->getPreferenceSections(); - $prefTabs = ''; - foreach ( $sectionTitles as $key ) { - $prefTabs .= Html::rawElement( 'li', - [ - 'role' => 'presentation', - 'class' => ( $key === 'personal' ) ? 'selected' : null - ], - Html::rawElement( 'a', + if ( $this->oouiEnabled ) { + $prefTabs = []; + foreach ( $sectionTitles as $key ) { + $prefTabs[] = [ + 'name' => $key, + 'label' => $htmlForm->getLegend( $key ), + ]; + } + $out->addJsConfigVars( 'wgPreferencesTabs', $prefTabs ); + + // TODO: Render fake tabs here to avoid FOUC. + // $out->addHTML( $fakeTabs ); + } else { + + $prefTabs = ''; + foreach ( $sectionTitles as $key ) { + $prefTabs .= Html::rawElement( 'li', [ - 'id' => 'preftab-' . $key, - 'role' => 'tab', - 'href' => '#mw-prefsection-' . $key, - 'aria-controls' => 'mw-prefsection-' . $key, - 'aria-selected' => ( $key === 'personal' ) ? 'true' : 'false', - 'tabIndex' => ( $key === 'personal' ) ? 0 : -1, + 'role' => 'presentation', + 'class' => ( $key === 'personal' ) ? 'selected' : null ], - $htmlForm->getLegend( $key ) - ) + Html::rawElement( 'a', + [ + 'id' => 'preftab-' . $key, + 'role' => 'tab', + 'href' => '#mw-prefsection-' . $key, + 'aria-controls' => 'mw-prefsection-' . $key, + 'aria-selected' => ( $key === 'personal' ) ? 'true' : 'false', + 'tabIndex' => ( $key === 'personal' ) ? 0 : -1, + ], + $htmlForm->getLegend( $key ) + ) + ); + } + + $out->addHTML( + Html::rawElement( 'ul', + [ + 'id' => 'preftoc', + 'role' => 'tablist' + ], + $prefTabs ) ); } - $out->addHTML( - Html::rawElement( 'ul', - [ - 'id' => 'preftoc', - 'role' => 'tablist' - ], - $prefTabs ) - ); + $htmlForm->addHiddenField( 'ooui', $this->oouiEnabled ? '1' : '0' ); + $htmlForm->show(); } @@ -126,7 +167,11 @@ class SpecialPreferences extends SpecialPage { */ protected function getFormObject( $user, IContextSource $context ) { $preferencesFactory = MediaWikiServices::getInstance()->getPreferencesFactory(); - $form = $preferencesFactory->getForm( $user, $context ); + if ( $this->oouiEnabled ) { + $form = $preferencesFactory->getForm( $user, $context, PreferencesFormOOUI::class ); + } else { + $form = $preferencesFactory->getForm( $user, $context, PreferencesFormLegacy::class ); + } return $form; } @@ -139,7 +184,9 @@ class SpecialPreferences extends SpecialPage { $context = new DerivativeContext( $this->getContext() ); $context->setTitle( $this->getPageTitle( 'reset' ) ); // Reset subpage - $htmlForm = new HTMLForm( [], $context, 'prefs-restore' ); + $htmlForm = HTMLForm::factory( + $this->oouiEnabled ? 'ooui' : 'vform', [], $context, 'prefs-restore' + ); $htmlForm->setSubmitTextMsg( 'restoreprefs' ); $htmlForm->setSubmitDestructive(); diff --git a/includes/specials/SpecialPrefixindex.php b/includes/specials/SpecialPrefixindex.php index 34ffa07363..fac71b2d78 100644 --- a/includes/specials/SpecialPrefixindex.php +++ b/includes/specials/SpecialPrefixindex.php @@ -98,56 +98,39 @@ class SpecialPrefixindex extends SpecialAllPages { * @return string */ protected function namespacePrefixForm( $namespace = NS_MAIN, $from = '' ) { - $out = Xml::openElement( 'div', [ 'class' => 'namespaceoptions' ] ); - $out .= Xml::openElement( - 'form', - [ 'method' => 'get', 'action' => $this->getConfig()->get( 'Script' ) ] - ); - $out .= Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ); - $out .= Xml::openElement( 'fieldset' ); - $out .= Xml::element( 'legend', null, $this->msg( 'allpages' )->text() ); - $out .= Xml::openElement( 'table', [ 'id' => 'nsselect', 'class' => 'allpages' ] ); - $out .= " - " . - Xml::label( $this->msg( 'allpagesprefix' )->text(), 'nsfrom' ) . - " - " . - Xml::input( 'prefix', 30, str_replace( '_', ' ', $from ), [ 'id' => 'nsfrom' ] ) . - " - - - " . - Xml::label( $this->msg( 'namespace' )->text(), 'namespace' ) . - " - " . - Html::namespaceSelector( [ - 'selected' => $namespace, - ], [ + $formDescriptor = [ + 'prefix' => [ + 'label-message' => 'allpagesprefix', + 'name' => 'prefix', + 'type' => 'text', + 'size' => '30', + ], + 'namespace' => [ + 'type' => 'namespaceselect', 'name' => 'namespace', 'id' => 'namespace', - 'class' => 'namespaceselector', - ] ) . - Xml::checkLabel( - $this->msg( 'allpages-hide-redirects' )->text(), - 'hideredirects', - 'hideredirects', - $this->hideRedirects - ) . ' ' . - Xml::checkLabel( - $this->msg( 'prefixindex-strip' )->text(), - 'stripprefix', - 'stripprefix', - $this->stripPrefix - ) . ' ' . - Xml::submitButton( $this->msg( 'prefixindex-submit' )->text() ) . - " - "; - $out .= Xml::closeElement( 'table' ); - $out .= Xml::closeElement( 'fieldset' ); - $out .= Xml::closeElement( 'form' ); - $out .= Xml::closeElement( 'div' ); - - return $out; + 'label-message' => 'namespace', + 'all' => null, + 'value' => $namespace, + ], + 'hidedirects' => [ + 'class' => 'HTMLCheckField', + 'name' => 'hideredirects', + 'label-message' => 'allpages-hide-redirects', + ], + 'stripprefix' => [ + 'class' => 'HTMLCheckField', + 'name' => 'stripprefix', + 'label-message' => 'prefixindex-strip', + ], + ]; + $htmlForm = new HTMLForm( $formDescriptor, $this->getContext() ); + $htmlForm + ->setMethod( 'get' ) + ->setWrapperLegendMsg( 'prefixindex' ) + ->setSubmitTextMsg( 'prefixindex-submit' ); + + return $htmlForm->prepareForm()->getHTML( false ); } /** diff --git a/includes/specials/SpecialProtectedpages.php b/includes/specials/SpecialProtectedpages.php index d693b99007..26f4da5f46 100644 --- a/includes/specials/SpecialProtectedpages.php +++ b/includes/specials/SpecialProtectedpages.php @@ -45,9 +45,12 @@ class SpecialProtectedpages extends SpecialPage { $sizetype = $request->getVal( 'size-mode' ); $size = $request->getIntOrNull( 'size' ); $ns = $request->getIntOrNull( 'namespace' ); - $indefOnly = $request->getBool( 'indefonly' ) ? 1 : 0; - $cascadeOnly = $request->getBool( 'cascadeonly' ) ? 1 : 0; - $noRedirect = $request->getBool( 'noredirect' ) ? 1 : 0; + + $filters = $request->getArray( 'wpfilters' ); + $filters = is_null( $filters ) ? [] : $filters; + $indefOnly = in_array( 'indefonly', $filters ); + $cascadeOnly = in_array( 'cascadeonly', $filters ); + $noRedirect = in_array( 'noredirect', $filters ); $pager = new ProtectedPagesPager( $this, @@ -69,9 +72,7 @@ class SpecialProtectedpages extends SpecialPage { $level, $sizetype, $size, - $indefOnly, - $cascadeOnly, - $noRedirect + $filters ) ); if ( $pager->getNumRows() ) { @@ -87,13 +88,12 @@ class SpecialProtectedpages extends SpecialPage { * @param string $level Restriction level * @param string $sizetype "min" or "max" * @param int $size - * @param bool $indefOnly Only indefinite protection - * @param bool $cascadeOnly Only cascading protection - * @param bool $noRedirect Don't show redirects + * @param array $filters Filters set for the pager: indefOnly, + * cascadeOnly, noRedirect * @return string Input form */ protected function showOptions( $namespace, $type = 'edit', $level, $sizetype, - $size, $indefOnly, $cascadeOnly, $noRedirect + $size, $filters ) { $formDescriptor = [ 'namespace' => [ @@ -106,30 +106,23 @@ class SpecialProtectedpages extends SpecialPage { ], 'typemenu' => $this->getTypeMenu( $type ), 'levelmenu' => $this->getLevelMenu( $level ), - 'expirycheck' => [ - 'type' => 'check', - 'label' => $this->msg( 'protectedpages-indef' )->text(), - 'name' => 'indefonly', - 'id' => 'indefonly', - ], - 'cascadecheck' => [ - 'type' => 'check', - 'label' => $this->msg( 'protectedpages-cascade' )->text(), - 'name' => 'cascadeonly', - 'id' => 'cascadeonly', - ], - 'redirectcheck' => [ - 'type' => 'check', - 'label' => $this->msg( 'protectedpages-noredirect' )->text(), - 'name' => 'noredirect', - 'id' => 'noredirect', + 'filters' => [ + 'class' => 'HTMLMultiSelectField', + 'label' => $this->msg( 'protectedpages-filters' )->text(), + 'flatlist' => true, + 'options' => [ + $this->msg( 'protectedpages-indef' )->text() => 'indefonly', + $this->msg( 'protectedpages-cascade' )->text() => 'cascadeonly', + $this->msg( 'protectedpages-noredirect' )->text() => 'noredirect', + ], + 'default' => $filters, ], 'sizelimit' => [ 'class' => HTMLSizeFilterField::class, 'name' => 'size', ] ]; - $htmlForm = new HTMLForm( $formDescriptor, $this->getContext() ); + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); $htmlForm ->setMethod( 'get' ) ->setWrapperLegendMsg( 'protectedpages' ) diff --git a/includes/specials/SpecialProtectedtitles.php b/includes/specials/SpecialProtectedtitles.php index fa12f507f9..2770bc5a4b 100644 --- a/includes/specials/SpecialProtectedtitles.php +++ b/includes/specials/SpecialProtectedtitles.php @@ -125,7 +125,7 @@ class SpecialProtectedtitles extends SpecialPage { 'levelmenu' => $this->getLevelMenu( $level ) ]; - $htmlForm = new HTMLForm( $formDescriptor, $this->getContext() ); + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); $htmlForm ->setMethod( 'get' ) ->setWrapperLegendMsg( 'protectedtitles' ) diff --git a/includes/specials/SpecialRecentchanges.php b/includes/specials/SpecialRecentchanges.php index d6d4c2723f..bfef5e0363 100644 --- a/includes/specials/SpecialRecentchanges.php +++ b/includes/specials/SpecialRecentchanges.php @@ -22,7 +22,7 @@ */ use MediaWiki\MediaWikiServices; -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\FakeResultWrapper; /** @@ -208,8 +208,12 @@ class SpecialRecentChanges extends ChangesListSpecialPage { $reviewStatus = $this->getFilterGroup( 'reviewStatus' ); if ( $reviewStatus !== null ) { // Conditional on feature being available and rights - $hidePatrolled = $reviewStatus->getFilter( 'hidepatrolled' ); - $hidePatrolled->setDefault( $user->getBoolOption( 'hidepatrolled' ) ); + if ( $user->getBoolOption( 'hidepatrolled' ) ) { + $reviewStatus->setDefault( 'unpatrolled' ); + $legacyReviewStatus = $this->getFilterGroup( 'legacyReviewStatus' ); + $legacyHidePatrolled = $legacyReviewStatus->getFilter( 'hidepatrolled' ); + $legacyHidePatrolled->setDefault( true ); + } } $changeType = $this->getFilterGroup( 'changeType' ); @@ -389,7 +393,7 @@ class SpecialRecentChanges extends ChangesListSpecialPage { /** * Build and output the actual changes list. * - * @param ResultWrapper $rows Database rows + * @param IResultWrapper $rows Database rows * @param FormOptions $opts */ public function outputChangesList( $rows, $opts ) { @@ -722,7 +726,7 @@ class SpecialRecentChanges extends ChangesListSpecialPage { * * @deprecated since 1.31 * - * @param ResultWrapper &$rows Database rows + * @param IResultWrapper &$rows Database rows * @param FormOptions $opts */ function filterByCategories( &$rows, FormOptions $opts ) { diff --git a/includes/specials/SpecialRedirect.php b/includes/specials/SpecialRedirect.php index 36e777940e..e827911382 100644 --- a/includes/specials/SpecialRedirect.php +++ b/includes/specials/SpecialRedirect.php @@ -162,7 +162,7 @@ class SpecialRedirect extends FormSpecialPage { /** * Handle Special:Redirect/logid/xxx - * (by redirecting to index.php?title=Special:Log) + * (by redirecting to index.php?title=Special:Log&logid=xxx) * * @since 1.27 * @return string|null Url to redirect to, or null if $mValue is invalid. @@ -176,80 +176,8 @@ class SpecialRedirect extends FormSpecialPage { if ( $logid === 0 ) { return null; } - - $logQuery = ActorMigration::newMigration()->getJoin( 'log_user' ); - - $logparams = [ - 'log_id' => 'log_id', - 'log_timestamp' => 'log_timestamp', - 'log_type' => 'log_type', - 'log_user_text' => $logQuery['fields']['log_user_text'], - ]; - - $dbr = wfGetDB( DB_REPLICA ); - - // Gets the nested SQL statement which - // returns timestamp of the log with the given log ID - $inner = $dbr->selectSQLText( - 'logging', - [ 'log_timestamp' ], - [ 'log_id' => $logid ] - ); - - // Returns all fields mentioned in $logparams of the logs - // with the same timestamp as the one returned by the statement above - $logsSameTimestamps = $dbr->select( - [ 'logging' ] + $logQuery['tables'], - $logparams, - [ "log_timestamp = ($inner)" ], - __METHOD__, - [], - $logQuery['joins'] - ); - if ( $logsSameTimestamps->numRows() === 0 ) { - return null; - } - - // Stores the row with the same log ID as the one given - $rowMain = []; - foreach ( $logsSameTimestamps as $row ) { - if ( (int)$row->log_id === $logid ) { - $rowMain = $row; - } - } - - array_shift( $logparams ); - - // Stores all the rows with the same values in each column - // as $rowMain - foreach ( $logparams as $key => $dummy ) { - $matchedRows = []; - foreach ( $logsSameTimestamps as $row ) { - if ( $row->$key === $rowMain->$key ) { - $matchedRows[] = $row; - } - } - if ( count( $matchedRows ) === 1 ) { - break; - } - $logsSameTimestamps = $matchedRows; - } - $query = [ 'title' => 'Special:Log', 'limit' => count( $matchedRows ) ]; - - // A map of database field names from table 'logging' to the values of $logparams - $keys = [ - 'log_timestamp' => 'offset', - 'log_type' => 'type', - 'log_user_text' => 'user' - ]; - - foreach ( $logparams as $logKey => $dummy ) { - $query[$keys[$logKey]] = $matchedRows[0]->$logKey; - } - $query['offset'] = $query['offset'] + 1; - $url = $query; - - return wfAppendQuery( wfScript( 'index' ), $url ); + $query = [ 'title' => 'Special:Log', 'logid' => $logid ]; + return wfAppendQuery( wfScript( 'index' ), $query ); } /** diff --git a/includes/specials/SpecialResetTokens.php b/includes/specials/SpecialResetTokens.php index 964a261a6b..d5b0903588 100644 --- a/includes/specials/SpecialResetTokens.php +++ b/includes/specials/SpecialResetTokens.php @@ -121,6 +121,7 @@ class SpecialResetTokens extends FormSpecialPage { * @param HTMLForm $form */ protected function alterForm( HTMLForm $form ) { + $form->setSubmitDestructive(); if ( $this->getTokensList() ) { $form->setSubmitTextMsg( 'resettokens-resetbutton' ); } else { diff --git a/includes/specials/SpecialShortpages.php b/includes/specials/SpecialShortpages.php index e9c15e7bed..d90f72c2b9 100644 --- a/includes/specials/SpecialShortpages.php +++ b/includes/specials/SpecialShortpages.php @@ -21,7 +21,7 @@ * @ingroup SpecialPage */ -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\IDatabase; /** @@ -124,7 +124,7 @@ class ShortPagesPage extends QueryPage { /** * @param IDatabase $db - * @param ResultWrapper $res + * @param IResultWrapper $res */ function preprocessResults( $db, $res ) { $this->executeLBFromResultWrapper( $res ); diff --git a/includes/specials/SpecialStatistics.php b/includes/specials/SpecialStatistics.php index a60549bf73..d5e14d299b 100644 --- a/includes/specials/SpecialStatistics.php +++ b/includes/specials/SpecialStatistics.php @@ -168,7 +168,11 @@ class SpecialStatistics extends SpecialPage { Xml::tags( 'th', [ 'colspan' => '2' ], $this->msg( 'statistics-header-users' )->parse() ) . Xml::closeElement( 'tr' ) . - $this->formatRow( $this->msg( 'statistics-users' )->parse(), + $this->formatRow( $this->msg( 'statistics-users' )->parse() . ' ' . + $this->getLinkRenderer()->makeKnownLink( + SpecialPage::getTitleFor( 'Listusers' ), + $this->msg( 'listgrouprights-members' )->text() + ), $this->getLanguage()->formatNum( $this->users ), [ 'class' => 'mw-statistics-users' ] ) . diff --git a/includes/specials/SpecialUndelete.php b/includes/specials/SpecialUndelete.php index 6e6ad779b3..540dbc6bf5 100644 --- a/includes/specials/SpecialUndelete.php +++ b/includes/specials/SpecialUndelete.php @@ -21,7 +21,7 @@ * @ingroup SpecialPage */ -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; /** * Special page allowing users with the appropriate permissions to view @@ -306,7 +306,7 @@ class SpecialUndelete extends SpecialPage { /** * Generic list of deleted pages * - * @param ResultWrapper $result + * @param IResultWrapper $result * @return bool */ private function showList( $result ) { diff --git a/includes/specials/SpecialUnwatchedpages.php b/includes/specials/SpecialUnwatchedpages.php index fea7e2160d..0ea7dfae14 100644 --- a/includes/specials/SpecialUnwatchedpages.php +++ b/includes/specials/SpecialUnwatchedpages.php @@ -24,7 +24,7 @@ * @author Ævar Arnfjörð Bjarmason */ -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\IDatabase; /** @@ -50,7 +50,7 @@ class UnwatchedpagesPage extends QueryPage { * Pre-cache page existence to speed up link generation * * @param IDatabase $db - * @param ResultWrapper $res + * @param IResultWrapper $res */ public function preprocessResults( $db, $res ) { if ( !$res->numRows() ) { diff --git a/includes/specials/SpecialUserrights.php b/includes/specials/SpecialUserrights.php index 40f02a5f4d..a05452d014 100644 --- a/includes/specials/SpecialUserrights.php +++ b/includes/specials/SpecialUserrights.php @@ -325,8 +325,8 @@ class UserrightsPage extends SpecialPage { * containing only those groups that are to have new expiry values set * @return array Tuple of added, then removed groups */ - function doSaveUserGroups( $user, $add, $remove, $reason = '', $tags = [], - $groupExpiries = [] + function doSaveUserGroups( $user, array $add, array $remove, $reason = '', + array $tags = [], array $groupExpiries = [] ) { // Validate input set... $isself = $user->getName() == $this->getUser()->getName(); @@ -427,13 +427,13 @@ class UserrightsPage extends SpecialPage { * @param User|UserRightsProxy $user * @param array $oldGroups * @param array $newGroups - * @param array $reason + * @param string $reason * @param array $tags Change tags for the log entry * @param array $oldUGMs Associative array of (group name => UserGroupMembership) * @param array $newUGMs Associative array of (group name => UserGroupMembership) */ - protected function addLogEntry( $user, $oldGroups, $newGroups, $reason, $tags, - $oldUGMs, $newUGMs + protected function addLogEntry( $user, array $oldGroups, array $newGroups, $reason, + array $tags, array $oldUGMs, array $newUGMs ) { // make sure $oldUGMs and $newUGMs are in the same order, and serialise // each UGM object to a simplified array @@ -882,7 +882,7 @@ class UserrightsPage extends SpecialPage { } // T171345: Add a hidden form element so that other groups can still be manipulated, // otherwise saving errors out with an invalid expiry time for this group. - $expiryHtml .= Html::Hidden( "wpExpiry-$group", + $expiryHtml .= Html::hidden( "wpExpiry-$group", $currentExpiry ? 'existing' : 'infinite' ); $expiryHtml .= "
    \n"; } else { diff --git a/includes/specials/SpecialVersion.php b/includes/specials/SpecialVersion.php index 1088d72ed5..6590756f8b 100644 --- a/includes/specials/SpecialVersion.php +++ b/includes/specials/SpecialVersion.php @@ -368,6 +368,7 @@ class SpecialVersion extends SpecialPage { if ( self::$extensionTypes === false ) { self::$extensionTypes = [ 'specialpage' => wfMessage( 'version-specialpages' )->text(), + 'editor' => wfMessage( 'version-editors' )->text(), 'parserhook' => wfMessage( 'version-parserhooks' )->text(), 'variable' => wfMessage( 'version-variables' )->text(), 'media' => wfMessage( 'version-mediahandlers' )->text(), diff --git a/includes/specials/SpecialWatchlist.php b/includes/specials/SpecialWatchlist.php index 7b3f25c838..dda1dac3af 100644 --- a/includes/specials/SpecialWatchlist.php +++ b/includes/specials/SpecialWatchlist.php @@ -22,7 +22,7 @@ */ use MediaWiki\MediaWikiServices; -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\IDatabase; /** @@ -264,8 +264,12 @@ class SpecialWatchlist extends ChangesListSpecialPage { $reviewStatus = $this->getFilterGroup( 'reviewStatus' ); if ( $reviewStatus !== null ) { // Conditional on feature being available and rights - $hidePatrolled = $reviewStatus->getFilter( 'hidepatrolled' ); - $hidePatrolled->setDefault( $user->getBoolOption( 'watchlisthidepatrolled' ) ); + if ( $user->getBoolOption( 'watchlisthidepatrolled' ) ) { + $reviewStatus->setDefault( 'unpatrolled' ); + $legacyReviewStatus = $this->getFilterGroup( 'legacyReviewStatus' ); + $legacyHidePatrolled = $legacyReviewStatus->getFilter( 'hidepatrolled' ); + $legacyHidePatrolled->setDefault( true ); + } } $authorship = $this->getFilterGroup( 'authorship' ); @@ -477,7 +481,7 @@ class SpecialWatchlist extends ChangesListSpecialPage { /** * Build and output the actual changes list. * - * @param ResultWrapper $rows Database rows + * @param IResultWrapper $rows Database rows * @param FormOptions $opts */ public function outputChangesList( $rows, $opts ) { diff --git a/includes/specials/formfields/Licenses.php b/includes/specials/formfields/Licenses.php index 931cd240a0..a2f3128462 100644 --- a/includes/specials/formfields/Licenses.php +++ b/includes/specials/formfields/Licenses.php @@ -57,9 +57,25 @@ class Licenses extends HTMLFormField { * @return string */ protected static function getMessageFromParams( $params ) { - return empty( $params['licenses'] ) - ? wfMessage( 'licenses' )->inContentLanguage()->plain() - : $params['licenses']; + global $wgContLang; + + if ( !empty( $params['licenses'] ) ) { + return $params['licenses']; + } + + // If the licenses page is in $wgForceUIMsgAsContentMsg (which is the case + // on Commons), translations will be in the database, in subpages of this + // message (e.g. MediaWiki:Licenses/) + // If there is no such translation, the result will be '-' (the empty default + // in the i18n files), so we'll need to force it to look up the actual licenses + // in the default site language (= get the translation from MediaWiki:Licenses) + // Also see https://phabricator.wikimedia.org/T3495 + $defaultMsg = wfMessage( 'licenses' )->inContentLanguage(); + if ( !$defaultMsg->exists() || $defaultMsg->plain() === '-' ) { + $defaultMsg = wfMessage( 'licenses' )->inLanguage( $wgContLang ); + } + + return $defaultMsg->plain(); } /** diff --git a/includes/specials/forms/EditWatchlistNormalHTMLForm.php b/includes/specials/forms/EditWatchlistNormalHTMLForm.php index 723093a772..b60882a98a 100644 --- a/includes/specials/forms/EditWatchlistNormalHTMLForm.php +++ b/includes/specials/forms/EditWatchlistNormalHTMLForm.php @@ -19,9 +19,9 @@ */ /** - * Extend HTMLForm purely so we can have a more sane way of getting the section headers + * Extend OOUIHTMLForm purely so we can have a more sane way of getting the section headers */ -class EditWatchlistNormalHTMLForm extends HTMLForm { +class EditWatchlistNormalHTMLForm extends OOUIHTMLForm { public function getLegend( $namespace ) { $namespace = substr( $namespace, 2 ); @@ -29,8 +29,4 @@ class EditWatchlistNormalHTMLForm extends HTMLForm { ? $this->msg( 'blanknamespace' )->escaped() : htmlspecialchars( $this->getContext()->getLanguage()->getFormattedNsText( $namespace ) ); } - - public function getBody() { - return $this->displaySection( $this->mFieldTree, '', 'editwatchlist-' ); - } } diff --git a/includes/specials/forms/PreferencesForm.php b/includes/specials/forms/PreferencesForm.php index d4e5ef4fdd..a12441030a 100644 --- a/includes/specials/forms/PreferencesForm.php +++ b/includes/specials/forms/PreferencesForm.php @@ -18,126 +18,11 @@ * @file */ -use MediaWiki\MediaWikiServices; - /** - * Form to edit user preferences. + * Temporarily define PreferencesForm as an interface, so PreferencesFormOOUI + * and PreferencesFormLegacy can implement it. + * + * When PreferencesFormLegacy we can merge PreferencesFormOOUI with PreferencesForm. */ -class PreferencesForm extends HTMLForm { - // Override default value from HTMLForm - protected $mSubSectionBeforeFields = false; - - private $modifiedUser; - - /** - * @param User $user - */ - public function setModifiedUser( $user ) { - $this->modifiedUser = $user; - } - - /** - * @return User - */ - public function getModifiedUser() { - if ( $this->modifiedUser === null ) { - return $this->getUser(); - } else { - return $this->modifiedUser; - } - } - - /** - * Get extra parameters for the query string when redirecting after - * successful save. - * - * @return array - */ - public function getExtraSuccessRedirectParameters() { - return []; - } - - /** - * @param string $html - * @return string - */ - function wrapForm( $html ) { - $html = Xml::tags( 'div', [ 'id' => 'preferences' ], $html ); - - return parent::wrapForm( $html ); - } - - /** - * @return string - */ - function getButtons() { - $attrs = [ 'id' => 'mw-prefs-restoreprefs' ]; - - if ( !$this->getModifiedUser()->isAllowedAny( 'editmyprivateinfo', 'editmyoptions' ) ) { - return ''; - } - - $html = parent::getButtons(); - - if ( $this->getModifiedUser()->isAllowed( 'editmyoptions' ) ) { - $t = $this->getTitle()->getSubpage( 'reset' ); - - $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); - $html .= "\n" . $linkRenderer->makeLink( $t, $this->msg( 'restoreprefs' )->text(), - Html::buttonAttributes( $attrs, [ 'mw-ui-quiet' ] ) ); - - $html = Xml::tags( 'div', [ 'class' => 'mw-prefs-buttons' ], $html ); - } - - return $html; - } - - /** - * Separate multi-option preferences into multiple preferences, since we - * have to store them separately - * @param array $data - * @return array - */ - function filterDataForSubmit( $data ) { - foreach ( $this->mFlatFields as $fieldname => $field ) { - if ( $field instanceof HTMLNestedFilterable ) { - $info = $field->mParams; - $prefix = isset( $info['prefix'] ) ? $info['prefix'] : $fieldname; - foreach ( $field->filterDataForSubmit( $data[$fieldname] ) as $key => $value ) { - $data["$prefix$key"] = $value; - } - unset( $data[$fieldname] ); - } - } - - return $data; - } - - /** - * Get the whole body of the form. - * @return string - */ - function getBody() { - return $this->displaySection( $this->mFieldTree, '', 'mw-prefsection-' ); - } - - /** - * Get the "" for a given section key. Normally this is the - * prefs-$key message but we'll allow extensions to override it. - * @param string $key - * @return string - */ - function getLegend( $key ) { - $legend = parent::getLegend( $key ); - Hooks::run( 'PreferencesGetLegend', [ $this, $key, &$legend ] ); - return $legend; - } - - /** - * Get the keys of each top level preference section. - * @return array of section keys - */ - function getPreferenceSections() { - return array_keys( array_filter( $this->mFieldTree, 'is_array' ) ); - } +interface PreferencesForm { } diff --git a/includes/specials/forms/PreferencesFormLegacy.php b/includes/specials/forms/PreferencesFormLegacy.php new file mode 100644 index 0000000000..e6bc494904 --- /dev/null +++ b/includes/specials/forms/PreferencesFormLegacy.php @@ -0,0 +1,143 @@ +modifiedUser = $user; + } + + /** + * @return User + */ + public function getModifiedUser() { + if ( $this->modifiedUser === null ) { + return $this->getUser(); + } else { + return $this->modifiedUser; + } + } + + /** + * Get extra parameters for the query string when redirecting after + * successful save. + * + * @return array + */ + public function getExtraSuccessRedirectParameters() { + return []; + } + + /** + * @param string $html + * @return string + */ + function wrapForm( $html ) { + $html = Xml::tags( 'div', [ 'id' => 'preferences' ], $html ); + + return parent::wrapForm( $html ); + } + + /** + * @return string + */ + function getButtons() { + $attrs = [ 'id' => 'mw-prefs-restoreprefs' ]; + + if ( !$this->getModifiedUser()->isAllowedAny( 'editmyprivateinfo', 'editmyoptions' ) ) { + return ''; + } + + $html = parent::getButtons(); + + if ( $this->getModifiedUser()->isAllowed( 'editmyoptions' ) ) { + $t = $this->getTitle()->getSubpage( 'reset' ); + + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); + $html .= "\n" . $linkRenderer->makeLink( $t, $this->msg( 'restoreprefs' )->text(), + Html::buttonAttributes( $attrs, [ 'mw-ui-quiet' ] ) ); + + $html = Xml::tags( 'div', [ 'class' => 'mw-prefs-buttons' ], $html ); + } + + return $html; + } + + /** + * Separate multi-option preferences into multiple preferences, since we + * have to store them separately + * @param array $data + * @return array + */ + function filterDataForSubmit( $data ) { + foreach ( $this->mFlatFields as $fieldname => $field ) { + if ( $field instanceof HTMLNestedFilterable ) { + $info = $field->mParams; + $prefix = isset( $info['prefix'] ) ? $info['prefix'] : $fieldname; + foreach ( $field->filterDataForSubmit( $data[$fieldname] ) as $key => $value ) { + $data["$prefix$key"] = $value; + } + unset( $data[$fieldname] ); + } + } + + return $data; + } + + /** + * Get the whole body of the form. + * @return string + */ + function getBody() { + return $this->displaySection( $this->mFieldTree, '', 'mw-prefsection-' ); + } + + /** + * Get the "" for a given section key. Normally this is the + * prefs-$key message but we'll allow extensions to override it. + * @param string $key + * @return string + */ + function getLegend( $key ) { + $legend = parent::getLegend( $key ); + Hooks::run( 'PreferencesGetLegend', [ $this, $key, &$legend ] ); + return $legend; + } + + /** + * Get the keys of each top level preference section. + * @return array of section keys + */ + function getPreferenceSections() { + return array_keys( array_filter( $this->mFieldTree, 'is_array' ) ); + } +} diff --git a/includes/specials/forms/PreferencesFormOOUI.php b/includes/specials/forms/PreferencesFormOOUI.php new file mode 100644 index 0000000000..a781254352 --- /dev/null +++ b/includes/specials/forms/PreferencesFormOOUI.php @@ -0,0 +1,144 @@ +modifiedUser = $user; + } + + /** + * @return User + */ + public function getModifiedUser() { + if ( $this->modifiedUser === null ) { + return $this->getUser(); + } else { + return $this->modifiedUser; + } + } + + /** + * Get extra parameters for the query string when redirecting after + * successful save. + * + * @return array + */ + public function getExtraSuccessRedirectParameters() { + return []; + } + + /** + * @param string $html + * @return string + */ + function wrapForm( $html ) { + $html = Xml::tags( 'div', [ 'id' => 'preferences' ], $html ); + + return parent::wrapForm( $html ); + } + + /** + * @return string + */ + function getButtons() { + if ( !$this->getModifiedUser()->isAllowedAny( 'editmyprivateinfo', 'editmyoptions' ) ) { + return ''; + } + + $html = parent::getButtons(); + + if ( $this->getModifiedUser()->isAllowed( 'editmyoptions' ) ) { + $t = $this->getTitle()->getSubpage( 'reset' ); + + $html .= new OOUI\ButtonWidget( [ + 'infusable' => true, + 'id' => 'mw-prefs-restoreprefs', + 'label' => $this->msg( 'restoreprefs' )->text(), + 'href' => $t->getLinkURL(), + 'flags' => [ 'destructive' ], + 'framed' => false, + ] ); + + $html = Xml::tags( 'div', [ 'class' => 'mw-prefs-buttons' ], $html ); + } + + return $html; + } + + /** + * Separate multi-option preferences into multiple preferences, since we + * have to store them separately + * @param array $data + * @return array + */ + function filterDataForSubmit( $data ) { + foreach ( $this->mFlatFields as $fieldname => $field ) { + if ( $field instanceof HTMLNestedFilterable ) { + $info = $field->mParams; + $prefix = isset( $info['prefix'] ) ? $info['prefix'] : $fieldname; + foreach ( $field->filterDataForSubmit( $data[$fieldname] ) as $key => $value ) { + $data["$prefix$key"] = $value; + } + unset( $data[$fieldname] ); + } + } + + return $data; + } + + /** + * Get the whole body of the form. + * @return string + */ + function getBody() { + return $this->displaySection( $this->mFieldTree, '', 'mw-prefsection-' ); + } + + /** + * Get the "" for a given section key. Normally this is the + * prefs-$key message but we'll allow extensions to override it. + * @param string $key + * @return string + */ + function getLegend( $key ) { + $legend = parent::getLegend( $key ); + Hooks::run( 'PreferencesGetLegend', [ $this, $key, &$legend ] ); + return $legend; + } + + /** + * Get the keys of each top level preference section. + * @return array of section keys + */ + function getPreferenceSections() { + return array_keys( array_filter( $this->mFieldTree, 'is_array' ) ); + } +} diff --git a/includes/specials/forms/UploadForm.php b/includes/specials/forms/UploadForm.php index eacdace18a..e561fe5882 100644 --- a/includes/specials/forms/UploadForm.php +++ b/includes/specials/forms/UploadForm.php @@ -406,14 +406,11 @@ class UploadForm extends HTMLForm { protected function addUploadJS() { $config = $this->getConfig(); - $useAjaxDestCheck = $config->get( 'UseAjax' ) && $config->get( 'AjaxUploadDestCheck' ); - $useAjaxLicensePreview = $config->get( 'UseAjax' ) && - $config->get( 'AjaxLicensePreview' ) && $config->get( 'EnableAPI' ); $this->mMaxUploadSize['*'] = UploadBase::getMaxUploadSize(); $scriptVars = [ - 'wgAjaxUploadDestCheck' => $useAjaxDestCheck, - 'wgAjaxLicensePreview' => $useAjaxLicensePreview, + 'wgAjaxUploadDestCheck' => $config->get( 'AjaxUploadDestCheck' ), + 'wgAjaxLicensePreview' => $config->get( 'AjaxLicensePreview' ), 'wgUploadAutoFill' => !$this->mForReUpload && // If we received mDestFile from the request, don't autofill // the wpDestFile textbox diff --git a/includes/specials/pagers/BlockListPager.php b/includes/specials/pagers/BlockListPager.php index 4234292700..5789c283be 100644 --- a/includes/specials/pagers/BlockListPager.php +++ b/includes/specials/pagers/BlockListPager.php @@ -23,7 +23,7 @@ * @ingroup Pager */ use MediaWiki\MediaWikiServices; -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; class BlockListPager extends TablePager { @@ -289,7 +289,7 @@ class BlockListPager extends TablePager { /** * Do a LinkBatch query to minimise database load when generating all these links - * @param ResultWrapper $result + * @param IResultWrapper $result */ function preprocessResults( $result ) { # Do a link batch query diff --git a/includes/specials/pagers/ContribsPager.php b/includes/specials/pagers/ContribsPager.php index 520e88dfd4..e31498ac38 100644 --- a/includes/specials/pagers/ContribsPager.php +++ b/includes/specials/pagers/ContribsPager.php @@ -24,7 +24,7 @@ * @ingroup Pager */ use MediaWiki\MediaWikiServices; -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\FakeResultWrapper; use Wikimedia\Rdbms\IDatabase; @@ -113,7 +113,7 @@ class ContribsPager extends RangeChronologicalPager { * @param string $offset Index offset, inclusive * @param int $limit Exact query limit * @param bool $descending Query direction, false for ascending, true for descending - * @return ResultWrapper + * @return IResultWrapper */ function reallyDoQuery( $offset, $limit, $descending ) { list( $tables, $fields, $conds, $fname, $options, $join_conds ) = $this->buildQueryInfo( diff --git a/includes/specials/pagers/DeletedContribsPager.php b/includes/specials/pagers/DeletedContribsPager.php index d642e661f1..f3de64d6e5 100644 --- a/includes/specials/pagers/DeletedContribsPager.php +++ b/includes/specials/pagers/DeletedContribsPager.php @@ -23,7 +23,7 @@ * @ingroup Pager */ use MediaWiki\MediaWikiServices; -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\FakeResultWrapper; class DeletedContribsPager extends IndexPager { @@ -96,7 +96,7 @@ class DeletedContribsPager extends IndexPager { * @param string $offset Index offset, inclusive * @param int $limit Exact query limit * @param bool $descending Query direction, false for ascending, true for descending - * @return ResultWrapper + * @return IResultWrapper */ function reallyDoQuery( $offset, $limit, $descending ) { $data = [ parent::reallyDoQuery( $offset, $limit, $descending ) ]; diff --git a/includes/specials/pagers/ImageListPager.php b/includes/specials/pagers/ImageListPager.php index 75c2f776ab..b2f1487645 100644 --- a/includes/specials/pagers/ImageListPager.php +++ b/includes/specials/pagers/ImageListPager.php @@ -23,7 +23,7 @@ * @ingroup Pager */ use MediaWiki\MediaWikiServices; -use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\FakeResultWrapper; class ImageListPager extends TablePager { @@ -356,8 +356,8 @@ class ImageListPager extends TablePager { * * Note: This will throw away some results * - * @param ResultWrapper $res1 - * @param ResultWrapper $res2 + * @param IResultWrapper $res1 + * @param IResultWrapper $res2 * @param int $limit * @param bool $ascending See note about $asc in $this->reallyDoQuery * @return FakeResultWrapper $res1 and $res2 combined @@ -519,8 +519,8 @@ class ImageListPager extends TablePager { } function getForm() { - $fields = []; - $fields['limit'] = [ + $formDescriptor = []; + $formDescriptor['limit'] = [ 'type' => 'select', 'name' => 'limit', 'label-message' => 'table_pager_limit_label', @@ -529,7 +529,7 @@ class ImageListPager extends TablePager { ]; if ( !$this->getConfig()->get( 'MiserMode' ) ) { - $fields['ilsearch'] = [ + $formDescriptor['ilsearch'] = [ 'type' => 'text', 'name' => 'ilsearch', 'id' => 'mw-ilsearch', @@ -540,19 +540,17 @@ class ImageListPager extends TablePager { ]; } - $this->getOutput()->addModules( 'mediawiki.userSuggest' ); - $fields['user'] = [ - 'type' => 'text', + $formDescriptor['user'] = [ + 'type' => 'user', 'name' => 'user', 'id' => 'mw-listfiles-user', 'label-message' => 'username', 'default' => $this->mUserName, 'size' => '40', 'maxlength' => '255', - 'cssclass' => 'mw-autocomplete-user', // used by mediawiki.userSuggest ]; - $fields['ilshowall'] = [ + $formDescriptor['ilshowall'] = [ 'type' => 'check', 'name' => 'ilshowall', 'id' => 'mw-listfiles-show-all', @@ -567,17 +565,16 @@ class ImageListPager extends TablePager { unset( $query['ilshowall'] ); unset( $query['user'] ); - $form = new HTMLForm( $fields, $this->getContext() ); - - $form->setMethod( 'get' ); - $form->setTitle( $this->getTitle() ); - $form->setId( 'mw-listfiles-form' ); - $form->setWrapperLegendMsg( 'listfiles' ); - $form->setSubmitTextMsg( 'table_pager_limit_submit' ); - $form->addHiddenFields( $query ); - - $form->prepareForm(); - $form->displayForm( '' ); + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); + $htmlForm + ->setMethod( 'get' ) + ->setId( 'mw-listfiles-form' ) + ->setTitle( $this->getTitle() ) + ->setSubmitTextMsg( 'table_pager_limit_submit' ) + ->setWrapperLegendMsg( 'listfiles' ) + ->addHiddenFields( $query ) + ->prepareForm() + ->displayForm( '' ); } protected function getTableClass() { diff --git a/includes/specials/pagers/NewFilesPager.php b/includes/specials/pagers/NewFilesPager.php index 57cdad9ad7..c214f1f77b 100644 --- a/includes/specials/pagers/NewFilesPager.php +++ b/includes/specials/pagers/NewFilesPager.php @@ -110,7 +110,7 @@ class NewFilesPager extends RangeChronologicalPager { $tables[] = 'recentchanges'; $conds['rc_type'] = RC_LOG; $conds['rc_log_type'] = 'upload'; - $conds['rc_patrolled'] = 0; + $conds['rc_patrolled'] = RecentChange::PRC_UNPATROLLED; $conds['rc_namespace'] = NS_FILE; if ( $wgActorTableSchemaMigrationStage === MIGRATION_NEW ) { diff --git a/includes/specials/pagers/NewPagesPager.php b/includes/specials/pagers/NewPagesPager.php index efdc75a344..f16a5cb615 100644 --- a/includes/specials/pagers/NewPagesPager.php +++ b/includes/specials/pagers/NewPagesPager.php @@ -82,7 +82,7 @@ class NewPagesPager extends ReverseChronologicalPager { # If this user cannot see patrolled edits or they are off, don't do dumb queries! if ( $this->opts->getValue( 'hidepatrolled' ) && $this->getUser()->useNPPatrol() ) { - $conds['rc_patrolled'] = 0; + $conds['rc_patrolled'] = RecentChange::PRC_UNPATROLLED; } if ( $this->opts->getValue( 'hidebots' ) ) { diff --git a/includes/templates/NoLocalSettings.mustache b/includes/templates/NoLocalSettings.mustache index 545794919e..36391f5bfd 100644 --- a/includes/templates/NoLocalSettings.mustache +++ b/includes/templates/NoLocalSettings.mustache @@ -28,12 +28,12 @@ {{^localSettingsExists}}

    LocalSettings.php not found.

    {{#installerStarted}} -

    Please complete the installation and download LocalSettings.php.

    +

    Please complete the installation and download LocalSettings.php.

    {{/installerStarted}} {{^installerStarted}} -

    Please set up the wiki first.

    +

    Please set up the wiki first.

    {{/installerStarted}} {{/localSettingsExists}} - \ No newline at end of file + diff --git a/includes/tidy/Balancer.php b/includes/tidy/Balancer.php deleted file mode 100644 index c7d9a265aa..0000000000 --- a/includes/tidy/Balancer.php +++ /dev/null @@ -1,3581 +0,0 @@ - [ - 'html' => true, 'head' => true, 'body' => true, 'frameset' => true, - 'frame' => true, - 'plaintext' => true, - 'xmp' => true, 'iframe' => true, 'noembed' => true, - 'noscript' => true, 'script' => true, - 'title' => true - ] - ]; - - public static $emptyElementSet = [ - self::HTML_NAMESPACE => [ - 'area' => true, 'base' => true, 'basefont' => true, - 'bgsound' => true, 'br' => true, 'col' => true, 'command' => true, - 'embed' => true, 'frame' => true, 'hr' => true, 'img' => true, - 'input' => true, 'keygen' => true, 'link' => true, 'meta' => true, - 'param' => true, 'source' => true, 'track' => true, 'wbr' => true - ] - ]; - - public static $extraLinefeedSet = [ - self::HTML_NAMESPACE => [ - 'pre' => true, 'textarea' => true, 'listing' => true, - ] - ]; - - public static $headingSet = [ - self::HTML_NAMESPACE => [ - 'h1' => true, 'h2' => true, 'h3' => true, - 'h4' => true, 'h5' => true, 'h6' => true - ] - ]; - - public static $specialSet = [ - self::HTML_NAMESPACE => [ - 'address' => true, 'applet' => true, 'area' => true, - 'article' => true, 'aside' => true, 'base' => true, - 'basefont' => true, 'bgsound' => true, 'blockquote' => true, - 'body' => true, 'br' => true, 'button' => true, 'caption' => true, - 'center' => true, 'col' => true, 'colgroup' => true, 'dd' => true, - 'details' => true, 'dir' => true, 'div' => true, 'dl' => true, - 'dt' => true, 'embed' => true, 'fieldset' => true, - 'figcaption' => true, 'figure' => true, 'footer' => true, - 'form' => true, 'frame' => true, 'frameset' => true, 'h1' => true, - 'h2' => true, 'h3' => true, 'h4' => true, 'h5' => true, - 'h6' => true, 'head' => true, 'header' => true, 'hgroup' => true, - 'hr' => true, 'html' => true, 'iframe' => true, 'img' => true, - 'input' => true, 'li' => true, 'link' => true, - 'listing' => true, 'main' => true, 'marquee' => true, - 'menu' => true, 'meta' => true, 'nav' => true, - 'noembed' => true, 'noframes' => true, 'noscript' => true, - 'object' => true, 'ol' => true, 'p' => true, 'param' => true, - 'plaintext' => true, 'pre' => true, 'script' => true, - 'section' => true, 'select' => true, 'source' => true, - 'style' => true, 'summary' => true, 'table' => true, - 'tbody' => true, 'td' => true, 'template' => true, - 'textarea' => true, 'tfoot' => true, 'th' => true, 'thead' => true, - 'title' => true, 'tr' => true, 'track' => true, 'ul' => true, - 'wbr' => true, 'xmp' => true - ], - self::SVG_NAMESPACE => [ - 'foreignobject' => true, 'desc' => true, 'title' => true - ], - self::MATHML_NAMESPACE => [ - 'mi' => true, 'mo' => true, 'mn' => true, 'ms' => true, - 'mtext' => true, 'annotation-xml' => true - ] - ]; - - public static $addressDivPSet = [ - self::HTML_NAMESPACE => [ - 'address' => true, 'div' => true, 'p' => true - ] - ]; - - public static $tableSectionRowSet = [ - self::HTML_NAMESPACE => [ - 'table' => true, 'thead' => true, 'tbody' => true, - 'tfoot' => true, 'tr' => true - ] - ]; - - public static $impliedEndTagsSet = [ - self::HTML_NAMESPACE => [ - 'dd' => true, 'dt' => true, 'li' => true, - 'menuitem' => true, 'optgroup' => true, - 'option' => true, 'p' => true, 'rb' => true, 'rp' => true, - 'rt' => true, 'rtc' => true - ] - ]; - - public static $thoroughImpliedEndTagsSet = [ - self::HTML_NAMESPACE => [ - 'caption' => true, 'colgroup' => true, 'dd' => true, 'dt' => true, - 'li' => true, 'optgroup' => true, 'option' => true, 'p' => true, - 'rb' => true, 'rp' => true, 'rt' => true, 'rtc' => true, - 'tbody' => true, 'td' => true, 'tfoot' => true, 'th' => true, - 'thead' => true, 'tr' => true - ] - ]; - - public static $tableCellSet = [ - self::HTML_NAMESPACE => [ - 'td' => true, 'th' => true - ] - ]; - public static $tableContextSet = [ - self::HTML_NAMESPACE => [ - 'table' => true, 'template' => true, 'html' => true - ] - ]; - - public static $tableBodyContextSet = [ - self::HTML_NAMESPACE => [ - 'tbody' => true, 'tfoot' => true, 'thead' => true, - 'template' => true, 'html' => true - ] - ]; - - public static $tableRowContextSet = [ - self::HTML_NAMESPACE => [ - 'tr' => true, 'template' => true, 'html' => true - ] - ]; - - // See https://html.spec.whatwg.org/multipage/forms.html#form-associated-element - public static $formAssociatedSet = [ - self::HTML_NAMESPACE => [ - 'button' => true, 'fieldset' => true, 'input' => true, - 'keygen' => true, 'object' => true, 'output' => true, - 'select' => true, 'textarea' => true, 'img' => true - ] - ]; - - public static $inScopeSet = [ - self::HTML_NAMESPACE => [ - 'applet' => true, 'caption' => true, 'html' => true, - 'marquee' => true, 'object' => true, - 'table' => true, 'td' => true, 'template' => true, - 'th' => true - ], - self::SVG_NAMESPACE => [ - 'foreignobject' => true, 'desc' => true, 'title' => true - ], - self::MATHML_NAMESPACE => [ - 'mi' => true, 'mo' => true, 'mn' => true, 'ms' => true, - 'mtext' => true, 'annotation-xml' => true - ] - ]; - - private static $inListItemScopeSet = null; - public static function inListItemScopeSet() { - if ( self::$inListItemScopeSet === null ) { - self::$inListItemScopeSet = self::$inScopeSet; - self::$inListItemScopeSet[self::HTML_NAMESPACE]['ol'] = true; - self::$inListItemScopeSet[self::HTML_NAMESPACE]['ul'] = true; - } - return self::$inListItemScopeSet; - } - - private static $inButtonScopeSet = null; - public static function inButtonScopeSet() { - if ( self::$inButtonScopeSet === null ) { - self::$inButtonScopeSet = self::$inScopeSet; - self::$inButtonScopeSet[self::HTML_NAMESPACE]['button'] = true; - } - return self::$inButtonScopeSet; - } - - public static $inTableScopeSet = [ - self::HTML_NAMESPACE => [ - 'html' => true, 'table' => true, 'template' => true - ] - ]; - - public static $inInvertedSelectScopeSet = [ - self::HTML_NAMESPACE => [ - 'option' => true, 'optgroup' => true - ] - ]; - - public static $mathmlTextIntegrationPointSet = [ - self::MATHML_NAMESPACE => [ - 'mi' => true, 'mo' => true, 'mn' => true, 'ms' => true, - 'mtext' => true - ] - ]; - - public static $htmlIntegrationPointSet = [ - self::SVG_NAMESPACE => [ - 'foreignobject' => true, - 'desc' => true, - 'title' => true - ] - ]; - - // For tidy compatibility. - public static $tidyPWrapSet = [ - self::HTML_NAMESPACE => [ - 'body' => true, 'blockquote' => true, - // We parse with as the fragment context, but the top-level - // element on the stack is actually . We could use the - // "adjusted current node" everywhere to work around this, but it's - // easier just to add to the p-wrap set. - 'html' => true, - ], - ]; - public static $tidyInlineSet = [ - self::HTML_NAMESPACE => [ - 'a' => true, 'abbr' => true, 'acronym' => true, 'applet' => true, - 'b' => true, 'basefont' => true, 'bdo' => true, 'big' => true, - 'br' => true, 'button' => true, 'cite' => true, 'code' => true, - 'dfn' => true, 'em' => true, 'font' => true, 'i' => true, - 'iframe' => true, 'img' => true, 'input' => true, 'kbd' => true, - 'label' => true, 'legend' => true, 'map' => true, 'object' => true, - 'param' => true, 'q' => true, 'rb' => true, 'rbc' => true, - 'rp' => true, 'rt' => true, 'rtc' => true, 'ruby' => true, - 's' => true, 'samp' => true, 'select' => true, 'small' => true, - 'span' => true, 'strike' => true, 'strong' => true, 'sub' => true, - 'sup' => true, 'textarea' => true, 'tt' => true, 'u' => true, - 'var' => true, - ], - ]; -} - -/** - * A BalanceElement is a simplified version of a DOM Node. The main - * difference is that we only keep BalanceElements around for nodes - * currently on the BalanceStack of open elements. As soon as an - * element is closed, with some minor exceptions relating to the - * tree builder "adoption agency algorithm", the element and all its - * children are serialized to a string using the flatten() method. - * This keeps our memory usage low. - * - * @ingroup Parser - * @since 1.27 - */ -class BalanceElement { - /** - * The namespace of the element. - * @var string $namespaceURI - */ - public $namespaceURI; - /** - * The lower-cased name of the element. - * @var string $localName - */ - public $localName; - /** - * Attributes for the element, in array form - * @var array $attribs - */ - public $attribs; - - /** - * Parent of this element, or the string "flat" if this element has - * already been flattened into its parent. - * @var BalanceElement|string|null $parent - */ - public $parent; - - /** - * An array of children of this element. Typically only the last - * child will be an actual BalanceElement object; the rest will - * be strings, representing either text nodes or flattened - * BalanceElement objects. - * @var BalanceElement[]|string[] $children - */ - public $children; - - /** - * A unique string identifier for Noah's Ark purposes, lazy initialized - */ - private $noahKey; - - /** - * The next active formatting element in the list, or null if this is the - * end of the AFE list or if the element is not in the AFE list. - */ - public $nextAFE; - - /** - * The previous active formatting element in the list, or null if this is - * the start of the list or if the element is not in the AFE list. - */ - public $prevAFE; - - /** - * The next element in the Noah's Ark species bucket. - */ - public $nextNoah; - - /** - * Make a new BalanceElement corresponding to the HTML DOM Element - * with the given localname, namespace, and attributes. - * - * @param string $namespaceURI The namespace of the element. - * @param string $localName The lowercased name of the tag. - * @param array $attribs Attributes of the element - */ - public function __construct( $namespaceURI, $localName, array $attribs ) { - $this->localName = $localName; - $this->namespaceURI = $namespaceURI; - $this->attribs = $attribs; - $this->contents = ''; - $this->parent = null; - $this->children = []; - } - - /** - * Remove the given child from this element. - * @param BalanceElement $elt - */ - private function removeChild( BalanceElement $elt ) { - Assert::precondition( - $this->parent !== 'flat', "Can't removeChild after flattening $this" - ); - Assert::parameter( - $elt->parent === $this, 'elt', 'must have $this as a parent' - ); - $idx = array_search( $elt, $this->children, true ); - Assert::parameter( $idx !== false, '$elt', 'must be a child of $this' ); - $elt->parent = null; - array_splice( $this->children, $idx, 1 ); - } - - /** - * Find $a in the list of children and insert $b before it. - * @param BalanceElement $a - * @param BalanceElement|string $b - */ - public function insertBefore( BalanceElement $a, $b ) { - Assert::precondition( - $this->parent !== 'flat', "Can't insertBefore after flattening." - ); - $idx = array_search( $a, $this->children, true ); - Assert::parameter( $idx !== false, '$a', 'must be a child of $this' ); - if ( is_string( $b ) ) { - array_splice( $this->children, $idx, 0, [ $b ] ); - } else { - Assert::parameter( $b->parent !== 'flat', '$b', "Can't be flat" ); - if ( $b->parent !== null ) { - $b->parent->removeChild( $b ); - } - array_splice( $this->children, $idx, 0, [ $b ] ); - $b->parent = $this; - } - } - - /** - * Append $elt to the end of the list of children. - * @param BalanceElement|string $elt - */ - public function appendChild( $elt ) { - Assert::precondition( - $this->parent !== 'flat', "Can't appendChild after flattening." - ); - if ( is_string( $elt ) ) { - array_push( $this->children, $elt ); - return; - } - // Remove $elt from parent, if it had one. - if ( $elt->parent !== null ) { - $elt->parent->removeChild( $elt ); - } - array_push( $this->children, $elt ); - $elt->parent = $this; - } - - /** - * Transfer all of the children of $elt to $this. - * @param BalanceElement $elt - */ - public function adoptChildren( BalanceElement $elt ) { - Assert::precondition( - $elt->parent !== 'flat', "Can't adoptChildren after flattening." - ); - foreach ( $elt->children as $child ) { - if ( !is_string( $child ) ) { - // This is an optimization which avoids an O(n^2) set of - // array_splice operations. - $child->parent = null; - } - $this->appendChild( $child ); - } - $elt->children = []; - } - - /** - * Flatten this node and all of its children into a string, as specified - * by the HTML serialization specification, and replace this node - * in its parent by that string. - * - * @param array $config Balancer configuration; see Balancer::__construct(). - * @return string - * - * @see __toString() - */ - public function flatten( array $config ) { - Assert::parameter( $this->parent !== null, '$this', 'must be a child' ); - Assert::parameter( $this->parent !== 'flat', '$this', 'already flat' ); - $idx = array_search( $this, $this->parent->children, true ); - Assert::parameter( - $idx !== false, '$this', 'must be a child of its parent' - ); - $tidyCompat = $config['tidyCompat']; - if ( $tidyCompat ) { - $blank = true; - foreach ( $this->children as $elt ) { - if ( !is_string( $elt ) ) { - $elt = $elt->flatten( $config ); - } - if ( $blank && preg_match( '/[^\t\n\f\r ]/', $elt ) ) { - $blank = false; - } - } - if ( $this->isHtmlNamed( 'mw:p-wrap' ) ) { - $this->localName = 'p'; - } elseif ( $blank ) { - // Add 'mw-empty-elt' class so elements can be hidden via CSS - // for compatibility with legacy tidy. - if ( !count( $this->attribs ) && - ( $this->localName === 'tr' || $this->localName === 'li' ) - ) { - $this->attribs = [ 'class' => "mw-empty-elt" ]; - } - $blank = false; - } elseif ( - $this->isA( BalanceSets::$extraLinefeedSet ) && - count( $this->children ) > 0 && - substr( $this->children[0], 0, 1 ) == "\n" - ) { - // Double the linefeed after pre/listing/textarea - // according to the (old) HTML5 fragment serialization - // algorithm (see https://github.com/whatwg/html/issues/944) - // to ensure this will round-trip. - array_unshift( $this->children, "\n" ); - } - $flat = $blank ? '' : "{$this}"; - } else { - $flat = "{$this}"; - } - $this->parent->children[$idx] = $flat; - $this->parent = 'flat'; // for assertion checking - return $flat; - } - - /** - * Serialize this node and all of its children to a string, as specified - * by the HTML serialization specification. - * - * @return string The serialization of the BalanceElement - * @see https://html.spec.whatwg.org/multipage/syntax.html#serialising-html-fragments - */ - public function __toString() { - $encAttribs = ''; - foreach ( $this->attribs as $name => $value ) { - $encValue = Sanitizer::encodeAttribute( $value ); - $encAttribs .= " $name=\"$encValue\""; - } - if ( !$this->isA( BalanceSets::$emptyElementSet ) ) { - $out = "<{$this->localName}{$encAttribs}>"; - $len = strlen( $out ); - // flatten children - foreach ( $this->children as $elt ) { - $out .= "{$elt}"; - } - $out .= "localName}>"; - } else { - $out = "<{$this->localName}{$encAttribs} />"; - Assert::invariant( - count( $this->children ) === 0, - "Empty elements shouldn't have children." - ); - } - return $out; - } - - // Utility functions on BalanceElements. - - /** - * Determine if $this represents a specific HTML tag, is a member of - * a tag set, or is equal to another BalanceElement. - * - * @param BalanceElement|array|string $set The target BalanceElement, - * set (from the BalanceSets class), or string (HTML tag name). - * @return bool - */ - public function isA( $set ) { - if ( $set instanceof BalanceElement ) { - return $this === $set; - } elseif ( is_array( $set ) ) { - return isset( $set[$this->namespaceURI] ) && - isset( $set[$this->namespaceURI][$this->localName] ); - } else { - // assume this is an HTML element name. - return $this->isHtml() && $this->localName === $set; - } - } - - /** - * Determine if this element is an HTML element with the specified name - * @param string $tagName - * @return bool - */ - public function isHtmlNamed( $tagName ) { - return $this->namespaceURI === BalanceSets::HTML_NAMESPACE - && $this->localName === $tagName; - } - - /** - * Determine if $this represents an element in the HTML namespace. - * - * @return bool - */ - public function isHtml() { - return $this->namespaceURI === BalanceSets::HTML_NAMESPACE; - } - - /** - * Determine if $this represents a MathML text integration point, - * as defined in the HTML5 specification. - * - * @return bool - * @see https://html.spec.whatwg.org/multipage/syntax.html#mathml-text-integration-point - */ - public function isMathmlTextIntegrationPoint() { - return $this->isA( BalanceSets::$mathmlTextIntegrationPointSet ); - } - - /** - * Determine if $this represents an HTML integration point, - * as defined in the HTML5 specification. - * - * @return bool - * @see https://html.spec.whatwg.org/multipage/syntax.html#html-integration-point - */ - public function isHtmlIntegrationPoint() { - if ( $this->isA( BalanceSets::$htmlIntegrationPointSet ) ) { - return true; - } - if ( - $this->namespaceURI === BalanceSets::MATHML_NAMESPACE && - $this->localName === 'annotation-xml' && - isset( $this->attribs['encoding'] ) && - ( strcasecmp( $this->attribs['encoding'], 'text/html' ) == 0 || - strcasecmp( $this->attribs['encoding'], 'application/xhtml+xml' ) == 0 ) - ) { - return true; - } - return false; - } - - /** - * Get a string key for the Noah's Ark algorithm - * @return string - */ - public function getNoahKey() { - if ( $this->noahKey === null ) { - $attribs = $this->attribs; - ksort( $attribs ); - $this->noahKey = serialize( [ $this->namespaceURI, $this->localName, $attribs ] ); - } - return $this->noahKey; - } -} - -/** - * The "stack of open elements" as defined in the HTML5 tree builder - * spec. This contains methods to ensure that content (start tags, text) - * are inserted at the correct place in the output string, and to - * flatten BalanceElements are they are closed to avoid holding onto - * a complete DOM tree for the document in memory. - * - * The stack defines a PHP iterator to traverse it in "reverse order", - * that is, the most-recently-added element is visited first in a - * foreach loop. - * - * @ingroup Parser - * @since 1.27 - * @see https://html.spec.whatwg.org/multipage/syntax.html#the-stack-of-open-elements - */ -class BalanceStack implements IteratorAggregate { - /** - * Backing storage for the stack. - * @var BalanceElement[] $elements - */ - private $elements = []; - /** - * Foster parent mode determines how nodes are inserted into the - * stack. - * @var bool $fosterParentMode - * @see https://html.spec.whatwg.org/multipage/syntax.html#foster-parent - */ - public $fosterParentMode = false; - /** - * Configuration options governing flattening. - * @var array $config - * @see Balancer::__construct() - */ - private $config; - /** - * Reference to the current element - */ - public $currentNode; - - /** - * Create a new BalanceStack with a single BalanceElement on it, - * representing the root <html> node. - * @param array $config Balancer configuration; see Balancer::_construct(). - */ - public function __construct( array $config ) { - // always a root element on the stack - array_push( - $this->elements, - new BalanceElement( BalanceSets::HTML_NAMESPACE, 'html', [] ) - ); - $this->currentNode = $this->elements[0]; - $this->config = $config; - } - - /** - * Return a string representing the output of the tree builder: - * all the children of the root <html> node. - * @return string - */ - public function getOutput() { - // Don't include the outer '....' - $out = ''; - foreach ( $this->elements[0]->children as $elt ) { - $out .= is_string( $elt ) ? $elt : - $elt->flatten( $this->config ); - } - return $out; - } - - /** - * Insert a comment at the appropriate place for inserting a node. - * @param string $value Content of the comment. - * @return string - * @see https://html.spec.whatwg.org/multipage/syntax.html#insert-a-comment - */ - public function insertComment( $value ) { - // Just another type of text node, except for tidy p-wrapping. - return $this->insertText( '', true ); - } - - /** - * Insert text at the appropriate place for inserting a node. - * @param string $value - * @param bool $isComment - * @return string - * @see https://html.spec.whatwg.org/multipage/syntax.html#appropriate-place-for-inserting-a-node - */ - public function insertText( $value, $isComment = false ) { - if ( - $this->fosterParentMode && - $this->currentNode->isA( BalanceSets::$tableSectionRowSet ) - ) { - $this->fosterParent( $value ); - } elseif ( - $this->config['tidyCompat'] && !$isComment && - $this->currentNode->isA( BalanceSets::$tidyPWrapSet ) - ) { - $this->insertHTMLElement( 'mw:p-wrap', [] ); - return $this->insertText( $value ); - } else { - $this->currentNode->appendChild( $value ); - } - } - - /** - * Insert a BalanceElement at the appropriate place, pushing it - * on to the open elements stack. - * @param string $namespaceURI The element namespace - * @param string $tag The tag name - * @param string $attribs Normalized attributes, as a string. - * @return BalanceElement - * @see https://html.spec.whatwg.org/multipage/syntax.html#insert-a-foreign-element - */ - public function insertForeignElement( $namespaceURI, $tag, $attribs ) { - return $this->insertElement( - new BalanceElement( $namespaceURI, $tag, $attribs ) - ); - } - - /** - * Insert an HTML element at the appropriate place, pushing it on to - * the open elements stack. - * @param string $tag The tag name - * @param string $attribs Normalized attributes, as a string. - * @return BalanceElement - * @see https://html.spec.whatwg.org/multipage/syntax.html#insert-an-html-element - */ - public function insertHTMLElement( $tag, $attribs ) { - return $this->insertForeignElement( - BalanceSets::HTML_NAMESPACE, $tag, $attribs - ); - } - - /** - * Insert an element at the appropriate place and push it on to the - * open elements stack. - * @param BalanceElement $elt - * @return BalanceElement - * @see https://html.spec.whatwg.org/multipage/syntax.html#appropriate-place-for-inserting-a-node - */ - public function insertElement( BalanceElement $elt ) { - if ( - $this->currentNode->isHtmlNamed( 'mw:p-wrap' ) && - !$elt->isA( BalanceSets::$tidyInlineSet ) - ) { - // Tidy compatibility. - $this->pop(); - } - if ( - $this->fosterParentMode && - $this->currentNode->isA( BalanceSets::$tableSectionRowSet ) - ) { - $elt = $this->fosterParent( $elt ); - } else { - $this->currentNode->appendChild( $elt ); - } - Assert::invariant( $elt->parent !== null, "$elt must be in tree" ); - Assert::invariant( $elt->parent !== 'flat', "$elt must not have been previous flattened" ); - array_push( $this->elements, $elt ); - $this->currentNode = $elt; - return $elt; - } - - /** - * Determine if the stack has $tag in scope. - * @param BalanceElement|array|string $tag - * @return bool - * @see https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-scope - */ - public function inScope( $tag ) { - return $this->inSpecificScope( $tag, BalanceSets::$inScopeSet ); - } - - /** - * Determine if the stack has $tag in button scope. - * @param BalanceElement|array|string $tag - * @return bool - * @see https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-button-scope - */ - public function inButtonScope( $tag ) { - return $this->inSpecificScope( $tag, BalanceSets::inButtonScopeSet() ); - } - - /** - * Determine if the stack has $tag in list item scope. - * @param BalanceElement|array|string $tag - * @return bool - * @see https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-list-item-scope - */ - public function inListItemScope( $tag ) { - return $this->inSpecificScope( $tag, BalanceSets::inListItemScopeSet() ); - } - - /** - * Determine if the stack has $tag in table scope. - * @param BalanceElement|array|string $tag - * @return bool - * @see https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-table-scope - */ - public function inTableScope( $tag ) { - return $this->inSpecificScope( $tag, BalanceSets::$inTableScopeSet ); - } - - /** - * Determine if the stack has $tag in select scope. - * @param BalanceElement|array|string $tag - * @return bool - * @see https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-select-scope - */ - public function inSelectScope( $tag ) { - // Can't use inSpecificScope to implement this, since it involves - // *inverting* a set of tags. Implement manually. - foreach ( $this as $elt ) { - if ( $elt->isA( $tag ) ) { - return true; - } - if ( !$elt->isA( BalanceSets::$inInvertedSelectScopeSet ) ) { - return false; - } - } - return false; - } - - /** - * Determine if the stack has $tag in a specific scope, $set. - * @param BalanceElement|array|string $tag - * @param BalanceElement|array|string $set - * @return bool - * @see https://html.spec.whatwg.org/multipage/syntax.html#has-an-element-in-the-specific-scope - */ - public function inSpecificScope( $tag, $set ) { - foreach ( $this as $elt ) { - if ( $elt->isA( $tag ) ) { - return true; - } - if ( $elt->isA( $set ) ) { - return false; - } - } - return false; - } - - /** - * Generate implied end tags. - * @param string $butnot - * @param bool $thorough True if we should generate end tags thoroughly. - * @see https://html.spec.whatwg.org/multipage/syntax.html#generate-implied-end-tags - */ - public function generateImpliedEndTags( $butnot = null, $thorough = false ) { - $endTagSet = $thorough ? - BalanceSets::$thoroughImpliedEndTagsSet : - BalanceSets::$impliedEndTagsSet; - while ( $this->currentNode ) { - if ( $butnot !== null && $this->currentNode->isHtmlNamed( $butnot ) ) { - break; - } - if ( !$this->currentNode->isA( $endTagSet ) ) { - break; - } - $this->pop(); - } - } - - /** - * Return the adjusted current node. - * @param string $fragmentContext - * @return string - */ - public function adjustedCurrentNode( $fragmentContext ) { - return ( $fragmentContext && count( $this->elements ) === 1 ) ? - $fragmentContext : $this->currentNode; - } - - /** - * Return an iterator over this stack which visits the current node - * first, and the root node last. - * @return \Iterator - */ - public function getIterator() { - return new ReverseArrayIterator( $this->elements ); - } - - /** - * Return the BalanceElement at the given position $idx, where - * position 0 represents the root element. - * @param int $idx - * @return BalanceElement - */ - public function node( $idx ) { - return $this->elements[ $idx ]; - } - - /** - * Replace the element at position $idx in the BalanceStack with $elt. - * @param int $idx - * @param BalanceElement $elt - */ - public function replaceAt( $idx, BalanceElement $elt ) { - Assert::precondition( - $this->elements[$idx]->parent !== 'flat', - 'Replaced element should not have already been flattened.' - ); - Assert::precondition( - $elt->parent !== 'flat', - 'New element should not have already been flattened.' - ); - $this->elements[$idx] = $elt; - if ( $idx === count( $this->elements ) - 1 ) { - $this->currentNode = $elt; - } - } - - /** - * Return the position of the given BalanceElement, set, or - * HTML tag name string in the BalanceStack. - * @param BalanceElement|array|string $tag - * @return int - */ - public function indexOf( $tag ) { - for ( $i = count( $this->elements ) - 1; $i >= 0; $i-- ) { - if ( $this->elements[$i]->isA( $tag ) ) { - return $i; - } - } - return -1; - } - - /** - * Return the number of elements currently in the BalanceStack. - * @return int - */ - public function length() { - return count( $this->elements ); - } - - /** - * Remove the current node from the BalanceStack, flattening it - * in the process. - */ - public function pop() { - $elt = array_pop( $this->elements ); - if ( count( $this->elements ) ) { - $this->currentNode = $this->elements[ count( $this->elements ) - 1 ]; - } else { - $this->currentNode = null; - } - if ( !$elt->isHtmlNamed( 'mw:p-wrap' ) ) { - $elt->flatten( $this->config ); - } - } - - /** - * Remove all nodes up to and including position $idx from the - * BalanceStack, flattening them in the process. - * @param int $idx - */ - public function popTo( $idx ) { - for ( $length = count( $this->elements ); $length > $idx; $length-- ) { - $this->pop(); - } - } - - /** - * Pop elements off the stack up to and including the first - * element with the specified HTML tagname (or matching the given - * set). - * @param BalanceElement|array|string $tag - */ - public function popTag( $tag ) { - while ( $this->currentNode ) { - if ( $this->currentNode->isA( $tag ) ) { - $this->pop(); - break; - } - $this->pop(); - } - } - - /** - * Pop elements off the stack *not including* the first element - * in the specified set. - * @param BalanceElement|array|string $set - */ - public function clearToContext( $set ) { - // Note that we don't loop to 0. Never pop the elt off. - for ( $length = count( $this->elements ); $length > 1; $length-- ) { - if ( $this->currentNode->isA( $set ) ) { - break; - } - $this->pop(); - } - } - - /** - * Remove the given $elt from the BalanceStack, optionally - * flattening it in the process. - * @param BalanceElement $elt The element to remove. - * @param bool $flatten Whether to flatten the removed element. - */ - public function removeElement( BalanceElement $elt, $flatten = true ) { - Assert::parameter( - $elt->parent !== 'flat', - '$elt', - '$elt should not already have been flattened.' - ); - Assert::parameter( - $elt->parent->parent !== 'flat', - '$elt', - 'The parent of $elt should not already have been flattened.' - ); - $idx = array_search( $elt, $this->elements, true ); - Assert::parameter( $idx !== false, '$elt', 'must be in stack' ); - array_splice( $this->elements, $idx, 1 ); - if ( $idx === count( $this->elements ) ) { - $this->currentNode = $this->elements[$idx - 1]; - } - if ( $flatten ) { - // serialize $elt into its parent - // otherwise, it will eventually serialize when the parent - // is serialized, we just hold onto the memory for its - // tree of objects a little longer. - $elt->flatten( $this->config ); - } - Assert::postcondition( - array_search( $elt, $this->elements, true ) === false, - '$elt should no longer be in open elements stack' - ); - } - - /** - * Find $a in the BalanceStack and insert $b after it. - * @param BalanceElement $a - * @param BalanceElement $b - */ - public function insertAfter( BalanceElement $a, BalanceElement $b ) { - $idx = $this->indexOf( $a ); - Assert::parameter( $idx !== false, '$a', 'must be in stack' ); - if ( $idx === count( $this->elements ) - 1 ) { - array_push( $this->elements, $b ); - $this->currentNode = $b; - } else { - array_splice( $this->elements, $idx + 1, 0, [ $b ] ); - } - } - - // Fostering and adoption. - - /** - * Foster parent the given $elt in the stack of open elements. - * @param BalanceElement|string $elt - * @return BalanceElement|string - * - * @see https://html.spec.whatwg.org/multipage/syntax.html#foster-parent - */ - private function fosterParent( $elt ) { - $lastTable = $this->indexOf( 'table' ); - $lastTemplate = $this->indexOf( 'template' ); - $parent = null; - $before = null; - - if ( $lastTemplate >= 0 && ( $lastTable < 0 || $lastTemplate > $lastTable ) ) { - $parent = $this->elements[$lastTemplate]; - } elseif ( $lastTable >= 0 ) { - $parent = $this->elements[$lastTable]->parent; - // Assume all tables have parents, since we're not running scripts! - Assert::invariant( - $parent !== null, "All tables should have parents" - ); - $before = $this->elements[$lastTable]; - } else { - $parent = $this->elements[0]; // the `html` element. - } - - if ( $this->config['tidyCompat'] ) { - if ( is_string( $elt ) ) { - // We're fostering text: do we need a p-wrapper? - if ( $parent->isA( BalanceSets::$tidyPWrapSet ) ) { - $this->insertHTMLElement( 'mw:p-wrap', [] ); - $this->insertText( $elt ); - return $elt; - } - } else { - // We're fostering an element; do we need to merge p-wrappers? - if ( $elt->isHtmlNamed( 'mw:p-wrap' ) ) { - $idx = $before ? - array_search( $before, $parent->children, true ) : - count( $parent->children ); - $after = $idx > 0 ? $parent->children[$idx - 1] : ''; - if ( - $after instanceof BalanceElement && - $after->isHtmlNamed( 'mw:p-wrap' ) - ) { - return $after; // Re-use existing p-wrapper. - } - } - } - } - - if ( $before ) { - $parent->insertBefore( $before, $elt ); - } else { - $parent->appendChild( $elt ); - } - return $elt; - } - - /** - * Run the "adoption agency algoritm" (AAA) for the given subject - * tag name. - * @param string $tag The subject tag name. - * @param BalanceActiveFormattingElements $afe The current - * active formatting elements list. - * @return true if the adoption agency algorithm "did something", false - * if more processing is required by the caller. - * @see https://html.spec.whatwg.org/multipage/syntax.html#adoption-agency-algorithm - */ - public function adoptionAgency( $tag, $afe ) { - // If the current node is an HTML element whose tag name is subject, - // and the current node is not in the list of active formatting - // elements, then pop the current node off the stack of open - // elements and abort these steps. - if ( - $this->currentNode->isHtmlNamed( $tag ) && - !$afe->isInList( $this->currentNode ) - ) { - $this->pop(); - return true; // no more handling required - } - - // Outer loop: If outer loop counter is greater than or - // equal to eight, then abort these steps. - for ( $outer = 0; $outer < 8; $outer++ ) { - // Let the formatting element be the last element in the list - // of active formatting elements that: is between the end of - // the list and the last scope marker in the list, if any, or - // the start of the list otherwise, and has the same tag name - // as the token. - $fmtElt = $afe->findElementByTag( $tag ); - - // If there is no such node, then abort these steps and instead - // act as described in the "any other end tag" entry below. - if ( !$fmtElt ) { - return false; // false means handle by the default case - } - - // Otherwise, if there is such a node, but that node is not in - // the stack of open elements, then this is a parse error; - // remove the element from the list, and abort these steps. - $index = $this->indexOf( $fmtElt ); - if ( $index < 0 ) { - $afe->remove( $fmtElt ); - return true; // true means no more handling required - } - - // Otherwise, if there is such a node, and that node is also in - // the stack of open elements, but the element is not in scope, - // then this is a parse error; ignore the token, and abort - // these steps. - if ( !$this->inScope( $fmtElt ) ) { - return true; - } - - // Let the furthest block be the topmost node in the stack of - // open elements that is lower in the stack than the formatting - // element, and is an element in the special category. There - // might not be one. - $furthestBlock = null; - $furthestBlockIndex = -1; - $stackLength = $this->length(); - for ( $i = $index + 1; $i < $stackLength; $i++ ) { - if ( $this->node( $i )->isA( BalanceSets::$specialSet ) ) { - $furthestBlock = $this->node( $i ); - $furthestBlockIndex = $i; - break; - } - } - - // If there is no furthest block, then the UA must skip the - // subsequent steps and instead just pop all the nodes from the - // bottom of the stack of open elements, from the current node - // up to and including the formatting element, and remove the - // formatting element from the list of active formatting - // elements. - if ( !$furthestBlock ) { - $this->popTag( $fmtElt ); - $afe->remove( $fmtElt ); - return true; - } - - // Let the common ancestor be the element immediately above - // the formatting element in the stack of open elements. - $ancestor = $this->node( $index - 1 ); - - // Let a bookmark note the position of the formatting - // element in the list of active formatting elements - // relative to the elements on either side of it in the - // list. - $BOOKMARK = new BalanceElement( '[bookmark]', '[bookmark]', [] ); - $afe->insertAfter( $fmtElt, $BOOKMARK ); - - // Let node and last node be the furthest block. - $node = $furthestBlock; - $lastNode = $furthestBlock; - $nodeIndex = $furthestBlockIndex; - $isAFE = false; - - // Inner loop - for ( $inner = 1; true; $inner++ ) { - // Let node be the element immediately above node in - // the stack of open elements, or if node is no longer - // in the stack of open elements (e.g. because it got - // removed by this algorithm), the element that was - // immediately above node in the stack of open elements - // before node was removed. - $node = $this->node( --$nodeIndex ); - - // If node is the formatting element, then go - // to the next step in the overall algorithm. - if ( $node === $fmtElt ) break; - - // If the inner loop counter is greater than three and node - // is in the list of active formatting elements, then remove - // node from the list of active formatting elements. - $isAFE = $afe->isInList( $node ); - if ( $inner > 3 && $isAFE ) { - $afe->remove( $node ); - $isAFE = false; - } - - // If node is not in the list of active formatting - // elements, then remove node from the stack of open - // elements and then go back to the step labeled inner - // loop. - if ( !$isAFE ) { - // Don't flatten here, since we're about to relocate - // parts of this $node. - $this->removeElement( $node, false ); - continue; - } - - // Create an element for the token for which the - // element node was created with common ancestor as - // the intended parent, replace the entry for node - // in the list of active formatting elements with an - // entry for the new element, replace the entry for - // node in the stack of open elements with an entry for - // the new element, and let node be the new element. - $newElt = new BalanceElement( - $node->namespaceURI, $node->localName, $node->attribs ); - $afe->replace( $node, $newElt ); - $this->replaceAt( $nodeIndex, $newElt ); - $node = $newElt; - - // If last node is the furthest block, then move the - // aforementioned bookmark to be immediately after the - // new node in the list of active formatting elements. - if ( $lastNode === $furthestBlock ) { - $afe->remove( $BOOKMARK ); - $afe->insertAfter( $newElt, $BOOKMARK ); - } - - // Insert last node into node, first removing it from - // its previous parent node if any. - $node->appendChild( $lastNode ); - - // Let last node be node. - $lastNode = $node; - } - - // If the common ancestor node is a table, tbody, tfoot, - // thead, or tr element, then, foster parent whatever last - // node ended up being in the previous step, first removing - // it from its previous parent node if any. - if ( - $this->fosterParentMode && - $ancestor->isA( BalanceSets::$tableSectionRowSet ) - ) { - $this->fosterParent( $lastNode ); - } else { - // Otherwise, append whatever last node ended up being in - // the previous step to the common ancestor node, first - // removing it from its previous parent node if any. - $ancestor->appendChild( $lastNode ); - } - - // Create an element for the token for which the - // formatting element was created, with furthest block - // as the intended parent. - $newElt2 = new BalanceElement( - $fmtElt->namespaceURI, $fmtElt->localName, $fmtElt->attribs ); - - // Take all of the child nodes of the furthest block and - // append them to the element created in the last step. - $newElt2->adoptChildren( $furthestBlock ); - - // Append that new element to the furthest block. - $furthestBlock->appendChild( $newElt2 ); - - // Remove the formatting element from the list of active - // formatting elements, and insert the new element into the - // list of active formatting elements at the position of - // the aforementioned bookmark. - $afe->remove( $fmtElt ); - $afe->replace( $BOOKMARK, $newElt2 ); - - // Remove the formatting element from the stack of open - // elements, and insert the new element into the stack of - // open elements immediately below the position of the - // furthest block in that stack. - $this->removeElement( $fmtElt ); - $this->insertAfter( $furthestBlock, $newElt2 ); - } - - return true; - } - - /** - * Return the contents of the open elements stack as a string for - * debugging. - * @return string - */ - public function __toString() { - $r = []; - foreach ( $this->elements as $elt ) { - array_push( $r, $elt->localName ); - } - return implode( ' ', $r ); - } -} - -/** - * A pseudo-element used as a marker in the list of active formatting elements - * - * @ingroup Parser - * @since 1.27 - */ -class BalanceMarker { - public $nextAFE; - public $prevAFE; -} - -/** - * The list of active formatting elements, which is used to handle - * mis-nested formatting element tags in the HTML5 tree builder - * specification. - * - * @ingroup Parser - * @since 1.27 - * @see https://html.spec.whatwg.org/multipage/syntax.html#list-of-active-formatting-elements - */ -class BalanceActiveFormattingElements { - /** The last (most recent) element in the list */ - private $tail; - - /** The first (least recent) element in the list */ - private $head; - - /** - * An array of arrays representing the population of elements in each bucket - * according to the Noah's Ark clause. The outer array is stack-like, with each - * integer-indexed element representing a segment of the list, bounded by - * markers. The first element represents the segment of the list before the - * first marker. - * - * The inner arrays are indexed by "Noah key", which is a string which uniquely - * identifies each bucket according to the rules in the spec. The value in - * the inner array is the first (least recently inserted) element in the bucket, - * and subsequent members of the bucket can be found by iterating through the - * singly-linked list via $node->nextNoah. - * - * This is optimised for the most common case of inserting into a bucket - * with zero members, and deleting a bucket containing one member. In the - * worst case, iteration through the list is still O(1) in the document - * size, since each bucket can have at most 3 members. - */ - private $noahTableStack = [ [] ]; - - public function __destruct() { - $next = null; - for ( $node = $this->head; $node; $node = $next ) { - $next = $node->nextAFE; - $node->prevAFE = $node->nextAFE = $node->nextNoah = null; - } - $this->head = $this->tail = $this->noahTableStack = null; - } - - public function insertMarker() { - $elt = new BalanceMarker; - if ( $this->tail ) { - $this->tail->nextAFE = $elt; - $elt->prevAFE = $this->tail; - } else { - $this->head = $elt; - } - $this->tail = $elt; - $this->noahTableStack[] = []; - } - - /** - * Follow the steps required when the spec requires us to "push onto the - * list of active formatting elements". - * @param BalanceElement $elt - */ - public function push( BalanceElement $elt ) { - // Must not be in the list already - if ( $elt->prevAFE !== null || $this->head === $elt ) { - throw new ParameterAssertionException( '$elt', - 'Cannot insert a node into the AFE list twice' ); - } - - // "Noah's Ark clause" -- if there are already three copies of - // this element before we encounter a marker, then drop the last - // one. - $noahKey = $elt->getNoahKey(); - $table =& $this->noahTableStack[ count( $this->noahTableStack ) - 1 ]; - if ( !isset( $table[$noahKey] ) ) { - $table[$noahKey] = $elt; - } else { - $count = 1; - $head = $tail = $table[$noahKey]; - while ( $tail->nextNoah ) { - $tail = $tail->nextNoah; - $count++; - } - if ( $count >= 3 ) { - $this->remove( $head ); - } - $tail->nextNoah = $elt; - } - // Add to the main AFE list - if ( $this->tail ) { - $this->tail->nextAFE = $elt; - $elt->prevAFE = $this->tail; - } else { - $this->head = $elt; - } - $this->tail = $elt; - } - - /** - * Follow the steps required when the spec asks us to "clear the list of - * active formatting elements up to the last marker". - */ - public function clearToMarker() { - // Iterate back through the list starting from the tail - $tail = $this->tail; - while ( $tail && !( $tail instanceof BalanceMarker ) ) { - // Unlink the element - $prev = $tail->prevAFE; - $tail->prevAFE = null; - if ( $prev ) { - $prev->nextAFE = null; - } - $tail->nextNoah = null; - $tail = $prev; - } - // If we finished on a marker, unlink it and pop it off the Noah table stack - if ( $tail ) { - $prev = $tail->prevAFE; - if ( $prev ) { - $prev->nextAFE = null; - } - $tail = $prev; - array_pop( $this->noahTableStack ); - } else { - // No marker: wipe the top-level Noah table (which is the only one) - $this->noahTableStack[0] = []; - } - // If we removed all the elements, clear the head pointer - if ( !$tail ) { - $this->head = null; - } - $this->tail = $tail; - } - - /** - * Find and return the last element with the specified tag between the - * end of the list and the last marker on the list. - * Used when parsing <a> "in body mode". - * @param string $tag - * @return null|Node - */ - public function findElementByTag( $tag ) { - $elt = $this->tail; - while ( $elt && !( $elt instanceof BalanceMarker ) ) { - if ( $elt->localName === $tag ) { - return $elt; - } - $elt = $elt->prevAFE; - } - return null; - } - - /** - * Determine whether an element is in the list of formatting elements. - * @param BalanceElement $elt - * @return bool - */ - public function isInList( BalanceElement $elt ) { - return $this->head === $elt || $elt->prevAFE; - } - - /** - * Find the element $elt in the list and remove it. - * Used when parsing <a> in body mode. - * - * @param BalanceElement $elt - */ - public function remove( BalanceElement $elt ) { - if ( $this->head !== $elt && !$elt->prevAFE ) { - throw new ParameterAssertionException( '$elt', - "Attempted to remove an element which is not in the AFE list" ); - } - // Update head and tail pointers - if ( $this->head === $elt ) { - $this->head = $elt->nextAFE; - } - if ( $this->tail === $elt ) { - $this->tail = $elt->prevAFE; - } - // Update previous element - if ( $elt->prevAFE ) { - $elt->prevAFE->nextAFE = $elt->nextAFE; - } - // Update next element - if ( $elt->nextAFE ) { - $elt->nextAFE->prevAFE = $elt->prevAFE; - } - // Clear pointers so that isInList() etc. will work - $elt->prevAFE = $elt->nextAFE = null; - // Update Noah list - $this->removeFromNoahList( $elt ); - } - - private function addToNoahList( BalanceElement $elt ) { - $noahKey = $elt->getNoahKey(); - $table =& $this->noahTableStack[ count( $this->noahTableStack ) - 1 ]; - if ( !isset( $table[$noahKey] ) ) { - $table[$noahKey] = $elt; - } else { - $tail = $table[$noahKey]; - while ( $tail->nextNoah ) { - $tail = $tail->nextNoah; - } - $tail->nextNoah = $elt; - } - } - - private function removeFromNoahList( BalanceElement $elt ) { - $table =& $this->noahTableStack[ count( $this->noahTableStack ) - 1 ]; - $key = $elt->getNoahKey(); - $noahElt = $table[$key]; - if ( $noahElt === $elt ) { - if ( $noahElt->nextNoah ) { - $table[$key] = $noahElt->nextNoah; - $noahElt->nextNoah = null; - } else { - unset( $table[$key] ); - } - } else { - do { - $prevNoahElt = $noahElt; - $noahElt = $prevNoahElt->nextNoah; - if ( $noahElt === $elt ) { - // Found it, unlink - $prevNoahElt->nextNoah = $elt->nextNoah; - $elt->nextNoah = null; - break; - } - } while ( $noahElt ); - } - } - - /** - * Find element $a in the list and replace it with element $b - * - * @param BalanceElement $a - * @param BalanceElement $b - */ - public function replace( BalanceElement $a, BalanceElement $b ) { - if ( $this->head !== $a && !$a->prevAFE ) { - throw new ParameterAssertionException( '$a', - "Attempted to replace an element which is not in the AFE list" ); - } - // Update head and tail pointers - if ( $this->head === $a ) { - $this->head = $b; - } - if ( $this->tail === $a ) { - $this->tail = $b; - } - // Update previous element - if ( $a->prevAFE ) { - $a->prevAFE->nextAFE = $b; - } - // Update next element - if ( $a->nextAFE ) { - $a->nextAFE->prevAFE = $b; - } - $b->prevAFE = $a->prevAFE; - $b->nextAFE = $a->nextAFE; - $a->nextAFE = $a->prevAFE = null; - // Update Noah list - $this->removeFromNoahList( $a ); - $this->addToNoahList( $b ); - } - - /** - * Find $a in the list and insert $b after it. - - * @param BalanceElement $a - * @param BalanceElement $b - */ - public function insertAfter( BalanceElement $a, BalanceElement $b ) { - if ( $this->head !== $a && !$a->prevAFE ) { - throw new ParameterAssertionException( '$a', - "Attempted to insert after an element which is not in the AFE list" ); - } - if ( $this->tail === $a ) { - $this->tail = $b; - } - if ( $a->nextAFE ) { - $a->nextAFE->prevAFE = $b; - } - $b->nextAFE = $a->nextAFE; - $b->prevAFE = $a; - $a->nextAFE = $b; - $this->addToNoahList( $b ); - } - - /** - * Reconstruct the active formatting elements. - * @param BalanceStack $stack The open elements stack - * @see https://html.spec.whatwg.org/multipage/syntax.html#reconstruct-the-active-formatting-elements - */ - public function reconstruct( $stack ) { - $entry = $this->tail; - // If there are no entries in the list of active formatting elements, - // then there is nothing to reconstruct - if ( !$entry ) { - return; - } - // If the last is a marker, do nothing. - if ( $entry instanceof BalanceMarker ) { - return; - } - // Or if it is an open element, do nothing. - if ( $stack->indexOf( $entry ) >= 0 ) { - return; - } - - // Loop backward through the list until we find a marker or an - // open element - $foundIt = false; - while ( $entry->prevAFE ) { - $entry = $entry->prevAFE; - if ( $entry instanceof BalanceMarker || $stack->indexOf( $entry ) >= 0 ) { - $foundIt = true; - break; - } - } - - // Now loop forward, starting from the element after the current one (or - // the first element if we didn't find a marker or open element), - // recreating formatting elements and pushing them back onto the list - // of open elements. - if ( $foundIt ) { - $entry = $entry->nextAFE; - } - do { - $newElement = $stack->insertHTMLElement( - $entry->localName, - $entry->attribs ); - $this->replace( $entry, $newElement ); - $entry = $newElement->nextAFE; - } while ( $entry ); - } - - /** - * Get a string representation of the AFE list, for debugging - */ - public function __toString() { - $prev = null; - $s = ''; - for ( $node = $this->head; $node; $prev = $node, $node = $node->nextAFE ) { - if ( $node instanceof BalanceMarker ) { - $s .= "MARKER\n"; - continue; - } - $s .= $node->localName . '#' . substr( md5( spl_object_hash( $node ) ), 0, 8 ); - if ( $node->nextNoah ) { - $s .= " (noah sibling: {$node->nextNoah->localName}#" . - substr( md5( spl_object_hash( $node->nextNoah ) ), 0, 8 ) . - ')'; - } - if ( $node->nextAFE && $node->nextAFE->prevAFE !== $node ) { - $s .= " (reverse link is wrong!)"; - } - $s .= "\n"; - } - if ( $prev !== $this->tail ) { - $s .= "(tail pointer is wrong!)\n"; - } - return $s; - } -} - -/** - * An implementation of the tree building portion of the HTML5 parsing - * spec. - * - * This is used to balance and tidy output so that the result can - * always be cleanly serialized/deserialized by an HTML5 parser. It - * does *not* guarantee "conforming" output -- the HTML5 spec contains - * a number of constraints which are not enforced by the HTML5 parsing - * process. But the result will be free of gross errors: misnested or - * unclosed tags, for example, and will be unchanged by spec-complient - * parsing followed by serialization. - * - * The tree building stage is structured as a state machine. - * When comparing the implementation to - * https://www.w3.org/TR/html5/syntax.html#tree-construction - * note that each state is implemented as a function with a - * name ending in `Mode` (because the HTML spec refers to them - * as insertion modes). The current insertion mode is held by - * the $parseMode property. - * - * The following simplifications have been made: - * - We handle body content only (ie, we start `in body`.) - * - The document is never in "quirks mode". - * - All occurrences of < and > have been entity escaped, so we - * can parse tags by simply splitting on those two characters. - * (This also simplifies the handling of < inside ", - "
    \n\na
    \n\nb", - true # use the tidy-compatible mode - ]; - - return $tests; - } -} diff --git a/tests/phpunit/includes/upload/UploadFromUrlTest.php b/tests/phpunit/includes/upload/UploadFromUrlTest.php index 62081aa35d..a69a137b72 100644 --- a/tests/phpunit/includes/upload/UploadFromUrlTest.php +++ b/tests/phpunit/includes/upload/UploadFromUrlTest.php @@ -22,7 +22,7 @@ class UploadFromUrlTest extends ApiTestCase { } protected function doApiRequest( array $params, array $unused = null, - $appendModule = false, User $user = null + $appendModule = false, User $user = null, $tokenType = null ) { global $wgRequest; diff --git a/tests/phpunit/includes/utils/BatchRowUpdateTest.php b/tests/phpunit/includes/utils/BatchRowUpdateTest.php index f06a35319e..52b143393f 100644 --- a/tests/phpunit/includes/utils/BatchRowUpdateTest.php +++ b/tests/phpunit/includes/utils/BatchRowUpdateTest.php @@ -12,7 +12,7 @@ class BatchRowUpdateTest extends MediaWikiTestCase { public function testWriterBasicFunctionality() { - $db = $this->mockDb(); + $db = $this->mockDb( [ 'update' ] ); $writer = new BatchRowWriter( $db, 'echo_event' ); $updates = [ @@ -36,17 +36,13 @@ class BatchRowUpdateTest extends MediaWikiTestCase { } public function testReaderBasicIterate() { - $db = $this->mockDb(); $batchSize = 2; - $reader = new BatchRowIterator( $db, 'some_table', 'id_field', $batchSize ); - $response = $this->genSelectResult( $batchSize, /*numRows*/ 5, function () { static $i = 0; return [ 'id_field' => ++$i ]; } ); - $db->expects( $this->exactly( count( $response ) ) ) - ->method( 'select' ) - ->will( $this->consecutivelyReturnFromSelect( $response ) ); + $db = $this->mockDbConsecutiveSelect( $response ); + $reader = new BatchRowIterator( $db, 'some_table', 'id_field', $batchSize ); $pos = 0; foreach ( $reader as $rows ) { @@ -130,7 +126,7 @@ class BatchRowUpdateTest extends MediaWikiTestCase { public function testReaderSetFetchColumns( $message, array $columns, array $primaryKeys, array $fetchColumns ) { - $db = $this->mockDb(); + $db = $this->mockDb( [ 'select' ] ); $db->expects( $this->once() ) ->method( 'select' ) // only testing second parameter of Database::select @@ -202,7 +198,7 @@ class BatchRowUpdateTest extends MediaWikiTestCase { } protected function mockDbConsecutiveSelect( array $retvals ) { - $db = $this->mockDb(); + $db = $this->mockDb( [ 'select', 'addQuotes' ] ); $db->expects( $this->any() ) ->method( 'select' ) ->will( $this->consecutivelyReturnFromSelect( $retvals ) ); @@ -238,11 +234,12 @@ class BatchRowUpdateTest extends MediaWikiTestCase { return $res; } - protected function mockDb() { + protected function mockDb( $methods = [] ) { // @TODO: mock from Database // FIXME: the constructor normally sets mAtomicLevels and mSrvCache $databaseMysql = $this->getMockBuilder( Wikimedia\Rdbms\DatabaseMysqli::class ) ->disableOriginalConstructor() + ->setMethods( array_merge( [ 'isOpen', 'getApproximateLagStatus' ], $methods ) ) ->getMock(); $databaseMysql->expects( $this->any() ) ->method( 'isOpen' ) diff --git a/tests/phpunit/includes/utils/ClassCollectorTest.php b/tests/phpunit/includes/utils/ClassCollectorTest.php index 9e5163f9ce..9c7c50f0f6 100644 --- a/tests/phpunit/includes/utils/ClassCollectorTest.php +++ b/tests/phpunit/includes/utils/ClassCollectorTest.php @@ -43,6 +43,10 @@ class ClassCollectorTest extends PHPUnit\Framework\TestCase { "namespace Example;\nclass Foo {}\nclass_alias( Foo::class, 'Bar' );", [ 'Example\Foo', 'Bar' ], ], + [ + "new class() extends Foo {}", + [] + ] ]; } diff --git a/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php b/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php index be51626a6f..50e6c202f4 100644 --- a/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php +++ b/tests/phpunit/includes/watcheditem/WatchedItemQueryServiceUnitTest.php @@ -776,7 +776,7 @@ class WatchedItemQueryServiceUnitTest extends MediaWikiTestCase { null, [], [], - [ 'rc_patrolled = 0' ], + [ 'rc_patrolled' => 0 ], [], [], ], diff --git a/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php b/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php index 948517067c..26f69088e9 100644 --- a/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php +++ b/tests/phpunit/includes/watcheditem/WatchedItemStoreUnitTest.php @@ -47,6 +47,7 @@ class WatchedItemStoreUnitTest extends MediaWikiTestCase { private function getMockCache() { $mock = $this->getMockBuilder( HashBagOStuff::class ) ->disableOriginalConstructor() + ->setMethods( [ 'get', 'set', 'delete', 'makeKey' ] ) ->getMock(); $mock->expects( $this->any() ) ->method( 'makeKey' ) @@ -2074,12 +2075,11 @@ class WatchedItemStoreUnitTest extends MediaWikiTestCase { ->method( 'selectRow' ); $mockCache = $this->getMockCache(); - $mockDb->expects( $this->never() ) - ->method( 'get' ); - $mockDb->expects( $this->never() ) - ->method( 'set' ); - $mockDb->expects( $this->never() ) - ->method( 'delete' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( '0:SomeDbKey:1' ); $store = $this->newWatchedItemStore( $this->getMockLoadBalancer( $mockDb ), @@ -2168,12 +2168,11 @@ class WatchedItemStoreUnitTest extends MediaWikiTestCase { ->method( 'selectRow' ); $mockCache = $this->getMockCache(); - $mockDb->expects( $this->never() ) - ->method( 'get' ); - $mockDb->expects( $this->never() ) - ->method( 'set' ); - $mockDb->expects( $this->never() ) - ->method( 'delete' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( '0:SomeTitle:1' ); $store = $this->newWatchedItemStore( $this->getMockLoadBalancer( $mockDb ), @@ -2235,12 +2234,13 @@ class WatchedItemStoreUnitTest extends MediaWikiTestCase { ) ); $mockCache = $this->getMockCache(); - $mockDb->expects( $this->never() ) - ->method( 'get' ); - $mockDb->expects( $this->never() ) - ->method( 'set' ); - $mockDb->expects( $this->never() ) - ->method( 'delete' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->once() ) + ->method( 'set' ) + ->with( '0:SomeDbKey:1', $this->isType( 'object' ) ); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( '0:SomeDbKey:1' ); $store = $this->newWatchedItemStore( $this->getMockLoadBalancer( $mockDb ), @@ -2311,12 +2311,11 @@ class WatchedItemStoreUnitTest extends MediaWikiTestCase { ->will( $this->returnValue( false ) ); $mockCache = $this->getMockCache(); - $mockDb->expects( $this->never() ) - ->method( 'get' ); - $mockDb->expects( $this->never() ) - ->method( 'set' ); - $mockDb->expects( $this->never() ) - ->method( 'delete' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->never() )->method( 'set' ); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( '0:SomeDbKey:1' ); $store = $this->newWatchedItemStore( $this->getMockLoadBalancer( $mockDb ), @@ -2378,12 +2377,13 @@ class WatchedItemStoreUnitTest extends MediaWikiTestCase { ) ); $mockCache = $this->getMockCache(); - $mockDb->expects( $this->never() ) - ->method( 'get' ); - $mockDb->expects( $this->never() ) - ->method( 'set' ); - $mockDb->expects( $this->never() ) - ->method( 'delete' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->once() ) + ->method( 'set' ) + ->with( '0:SomeDbKey:1', $this->isType( 'object' ) ); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( '0:SomeDbKey:1' ); $store = $this->newWatchedItemStore( $this->getMockLoadBalancer( $mockDb ), @@ -2456,12 +2456,13 @@ class WatchedItemStoreUnitTest extends MediaWikiTestCase { ) ); $mockCache = $this->getMockCache(); - $mockDb->expects( $this->never() ) - ->method( 'get' ); - $mockDb->expects( $this->never() ) - ->method( 'set' ); - $mockDb->expects( $this->never() ) - ->method( 'delete' ); + $mockCache->expects( $this->never() )->method( 'get' ); + $mockCache->expects( $this->once() ) + ->method( 'set' ) + ->with( '0:SomeDbKey:1', $this->isType( 'object' ) ); + $mockCache->expects( $this->once() ) + ->method( 'delete' ) + ->with( '0:SomeDbKey:1' ); $store = $this->newWatchedItemStore( $this->getMockLoadBalancer( $mockDb ), diff --git a/tests/phpunit/languages/classes/LanguageCrhTest.php b/tests/phpunit/languages/classes/LanguageCrhTest.php index 7c99614e61..5a554a06aa 100644 --- a/tests/phpunit/languages/classes/LanguageCrhTest.php +++ b/tests/phpunit/languages/classes/LanguageCrhTest.php @@ -57,19 +57,59 @@ class LanguageCrhTest extends LanguageClassesTestCase { ], [ // recent problem words, part 1 [ - 'crh' => 'künü куню sürgünligi сюргюнлиги özü озю etti этти', - 'crh-cyrl' => 'куню куню сюргюнлиги сюргюнлиги озю озю этти этти', - 'crh-latn' => 'künü künü sürgünligi sürgünligi özü özü etti etti', + 'crh' => 'künü куню sürgünligi сюргюнлиги özü озю etti этти esas эсас dört дёрт', + 'crh-cyrl' => 'куню куню сюргюнлиги сюргюнлиги озю озю этти этти эсас эсас дёрт дёрт', + 'crh-latn' => 'künü künü sürgünligi sürgünligi özü özü etti etti esas esas dört dört', ], - 'künü куню sürgünligi сюргюнлиги özü озю etti этти' + 'künü куню sürgünligi сюргюнлиги özü озю etti этти esas эсас dört дёрт' ], [ // recent problem words, part 2 [ - 'crh' => 'esas эсас dört дёрт keldi кельди', - 'crh-cyrl' => 'эсас эсас дёрт дёрт кельди кельди', - 'crh-latn' => 'esas esas dört dört keldi keldi', + 'crh' => 'keldi кельди km² км² yüz юзь AQŞ АКъШ ŞSCBnen ШСДжБнен iyül июль', + 'crh-cyrl' => 'кельди кельди км² км² юзь юзь АКъШ АКъШ ШСДжБнен ШСДжБнен июль июль', + 'crh-latn' => 'keldi keldi km² km² yüz yüz AQŞ AQŞ ŞSCBnen ŞSCBnen iyül iyül', ], - 'esas эсас dört дёрт keldi кельди' + 'keldi кельди km² км² yüz юзь AQŞ АКъШ ŞSCBnen ШСДжБнен iyül июль' + ], + [ // recent problem words, part 3 + [ + 'crh' => 'işğal ишгъаль işğalcilerine ишгъальджилерине rayon район üst усть', + 'crh-cyrl' => 'ишгъаль ишгъаль ишгъальджилерине ишгъальджилерине район район усть усть', + 'crh-latn' => 'işğal işğal işğalcilerine işğalcilerine rayon rayon üst üst', + ], + 'işğal ишгъаль işğalcilerine ишгъальджилерине rayon район üst усть' + ], + [ // recent problem words, part 4 + [ + 'crh' => 'rayonınıñ районынынъ Noğay Ногъай Yürtü Юрьтю vatandan ватандан', + 'crh-cyrl' => 'районынынъ районынынъ Ногъай Ногъай Юрьтю Юрьтю ватандан ватандан', + 'crh-latn' => 'rayonınıñ rayonınıñ Noğay Noğay Yürtü Yürtü vatandan vatandan', + ], + 'rayonınıñ районынынъ Noğay Ногъай Yürtü Юрьтю vatandan ватандан' + ], + [ // recent problem words, part 5 + [ + 'crh' => 'ком-кок köm-kök rol роль AQQI АКЪКЪЫ DAĞĞA ДАГЪГЪА 13-ünci 13-юнджи', + 'crh-cyrl' => 'ком-кок ком-кок роль роль АКЪКЪЫ АКЪКЪЫ ДАГЪГЪА ДАГЪГЪА 13-юнджи 13-юнджи', + 'crh-latn' => 'köm-kök köm-kök rol rol AQQI AQQI DAĞĞA DAĞĞA 13-ünci 13-ünci', + ], + 'ком-кок köm-kök rol роль AQQI АКЪКЪЫ DAĞĞA ДАГЪГЪА 13-ünci 13-юнджи' + ], + [ // recent problem words, part 6 + [ + 'crh' => 'ДЖУРЬМЕК CÜRMEK кетсин ketsin джумлеси cümlesi ильи ilyi Ильи İlyi', + 'crh-cyrl' => 'ДЖУРЬМЕК ДЖУРЬМЕК кетсин кетсин джумлеси джумлеси ильи ильи Ильи Ильи', + 'crh-latn' => 'CÜRMEK CÜRMEK ketsin ketsin cümlesi cümlesi ilyi ilyi İlyi İlyi', + ], + 'ДЖУРЬМЕК CÜRMEK кетсин ketsin джумлеси cümlesi ильи ilyi Ильи İlyi' + ], + [ // regex pattern words + [ + 'crh' => 'köyünden коюнден ange аньге', + 'crh-cyrl' => 'коюнден коюнден аньге аньге', + 'crh-latn' => 'köyünden köyünden ange ange', + ], + 'köyünden коюнден ange аньге' ], [ // multi part words [ @@ -79,13 +119,61 @@ class LanguageCrhTest extends LanguageClassesTestCase { ], 'эки юз eki yüz' ], - [ // ALL CAPS, made up acronyms (not 100% sure these are correct) + [ // affix patterns [ - 'crh' => 'ÑAB QIC ĞUK COT НЪАБ КЪЫДж ГЪУК ДЖОТ CA ДЖА', - 'crh-cyrl' => 'НЪАБ КЪЫДж ГЪУК ДЖОТ НЪАБ КЪЫДж ГЪУК ДЖОТ ДЖА ДЖА', + 'crh' => 'köyniñ койнинъ Avcıköyde Авджыкойде ekvatorial экваториаль Canköy Джанкой', + 'crh-cyrl' => 'койнинъ койнинъ Авджыкойде Авджыкойде экваториаль экваториаль Джанкой Джанкой', + 'crh-latn' => 'köyniñ köyniñ Avcıköyde Avcıköyde ekvatorial ekvatorial Canköy Canköy', + ], + 'köyniñ койнинъ Avcıköyde Авджыкойде ekvatorial экваториаль Canköy Джанкой' + ], + [ // Roman numerals and quotes, esp. single-letter Roman numerals at the end of a string + [ + 'crh' => 'VI,VII IX “dört” «дёрт» XI XII I V X L C D M', + 'crh-cyrl' => 'VI,VII IX «дёрт» «дёрт» XI XII I V X L C D M', + 'crh-latn' => 'VI,VII IX “dört” "dört" XI XII I V X L C D M', + ], + 'VI,VII IX “dört” «дёрт» XI XII I V X L C D M' + ], + [ // Roman numerals vs Initials, part 1 - Roman numeral initials without spaces + [ + 'crh' => 'A.B.C.D.M. Qadırova XII, А.Б.Дж.Д.М. Къадырова XII', + 'crh-cyrl' => 'А.Б.Дж.Д.М. Къадырова XII, А.Б.Дж.Д.М. Къадырова XII', + 'crh-latn' => 'A.B.C.D.M. Qadırova XII, A.B.C.D.M. Qadırova XII', + ], + 'A.B.C.D.M. Qadırova XII, А.Б.Дж.Д.М. Къадырова XII' + ], + [ // Roman numerals vs Initials, part 2 - Roman numeral initials with spaces + [ + 'crh' => 'G. H. I. V. X. L. Memetov III, Г. Х. Ы. В. X. Л. Меметов III', + 'crh-cyrl' => 'Г. Х. Ы. В. X. Л. Меметов III, Г. Х. Ы. В. X. Л. Меметов III', + 'crh-latn' => 'G. H. I. V. X. L. Memetov III, G. H. I. V. X. L. Memetov III', + ], + 'G. H. I. V. X. L. Memetov III, Г. Х. Ы. В. X. Л. Меметов III' + ], + [ // ALL CAPS, made up acronyms + [ + 'crh' => 'ÑAB QIC ĞUK COT НЪАБ КЪЫДЖ ГЪУК ДЖОТ CA ДЖА', + 'crh-cyrl' => 'НЪАБ КЪЫДЖ ГЪУК ДЖОТ НЪАБ КЪЫДЖ ГЪУК ДЖОТ ДЖА ДЖА', 'crh-latn' => 'ÑAB QIC ĞUK COT ÑAB QIC ĞUK COT CA CA', ], - 'ÑAB QIC ĞUK COT НЪАБ КЪЫДж ГЪУК ДЖОТ CA ДЖА' + 'ÑAB QIC ĞUK COT НЪАБ КЪЫДЖ ГЪУК ДЖОТ CA ДЖА' + ], + [ // Many-to-one mappings: many Cyrillic to one Latin + [ + 'crh' => 'шофер шофёр şoför корбекул корьбекул корьбекуль körbekül', + 'crh-cyrl' => 'шофер шофёр шофёр корбекул корьбекул корьбекуль корьбекуль', + 'crh-latn' => 'şoför şoför şoför körbekül körbekül körbekül körbekül', + ], + 'шофер шофёр şoför корбекул корьбекул корьбекуль körbekül' + ], + [ // Many-to-one mappings: many Latin to one Cyrillic + [ + 'crh' => 'fevqülade fevqulade февкъульаде beyude beyüde бейуде', + 'crh-cyrl' => 'февкъульаде февкъульаде февкъульаде бейуде бейуде бейуде', + 'crh-latn' => 'fevqülade fevqulade fevqulade beyude beyüde beyüde', + ], + 'fevqülade fevqulade февкъульаде beyude beyüde бейуде' ], ]; } diff --git a/tests/phpunit/maintenance/DumpTestCase.php b/tests/phpunit/maintenance/DumpTestCase.php index c872993adf..9b90bfe671 100644 --- a/tests/phpunit/maintenance/DumpTestCase.php +++ b/tests/phpunit/maintenance/DumpTestCase.php @@ -8,6 +8,7 @@ use MediaWikiLangTestCase; use Page; use User; use XMLReader; +use MWException; /** * Base TestCase for dumps diff --git a/tests/phpunit/maintenance/categoryChangesRdfTest.php b/tests/phpunit/maintenance/categoryChangesRdfTest.php new file mode 100644 index 0000000000..30a56f49d4 --- /dev/null +++ b/tests/phpunit/maintenance/categoryChangesRdfTest.php @@ -0,0 +1,263 @@ +setMwGlobals( [ + 'wgServer' => 'http://acme.test', + 'wgCanonicalServer' => 'http://acme.test', + 'wgArticlePath' => '/wiki/$1', + ] ); + } + + public function provideCategoryData() { + return [ + 'delete category' => [ + __DIR__ . "/../data/categoriesrdf/delete.sparql", + 'getDeletedCatsIterator', + 'handleDeletes', + [ + (object)[ 'rc_title' => 'Test', 'rc_cur_id' => 1, '_processed' => 1 ], + (object)[ 'rc_title' => 'Test 2', 'rc_cur_id' => 2, '_processed' => 2 ], + ], + ], + 'move category' => [ + __DIR__ . "/../data/categoriesrdf/move.sparql", + 'getMovedCatsIterator', + 'handleMoves', + [ + (object)[ + 'rc_title' => 'Test', + 'rc_cur_id' => 4, + 'page_title' => 'MovedTo', + 'page_namespace' => NS_CATEGORY, + '_processed' => 4, + 'pp_propname' => null, + 'cat_pages' => 10, + 'cat_subcats' => 2, + 'cat_files' => 1, + ], + (object)[ + 'rc_title' => 'MovedTo', + 'rc_cur_id' => 4, + 'page_title' => 'MovedAgain', + 'page_namespace' => NS_CATEGORY, + 'pp_propname' => 'hiddencat', + 'cat_pages' => 10, + 'cat_subcats' => 2, + 'cat_files' => 1, + ], + (object)[ + 'rc_title' => 'Test 2', + 'rc_cur_id' => 5, + 'page_title' => 'AlsoMoved', + 'page_namespace' => NS_CATEGORY, + '_processed' => 5, + 'pp_propname' => null, + 'cat_pages' => 10, + 'cat_subcats' => 2, + 'cat_files' => 1, + ], + (object)[ + 'rc_title' => 'Test 3', + 'rc_cur_id' => 6, + 'page_title' => 'MovedOut', + 'page_namespace' => NS_MAIN, + 'pp_propname' => null, + 'cat_pages' => 10, + 'cat_subcats' => 2, + 'cat_files' => 1, + ], + (object)[ + 'rc_title' => 'Test 4', + 'rc_cur_id' => 7, + 'page_title' => 'Already Done', + 'page_namespace' => NS_CATEGORY, + 'pp_propname' => null, + 'cat_pages' => 10, + 'cat_subcats' => 2, + 'cat_files' => 1, + ], + ], + [ 7 => true ], + ], + 'restore deleted category' => [ + __DIR__ . "/../data/categoriesrdf/restore.sparql", + 'getRestoredCatsIterator', + 'handleRestores', + [ + (object)[ + 'rc_title' => 'Restored cat', + 'rc_cur_id' => 10, + '_processed' => 10, + 'pp_propname' => null, + 'cat_pages' => 10, + 'cat_subcats' => 2, + 'cat_files' => 1, + ], + (object)[ + 'rc_title' => 'Restored again', + 'rc_cur_id' => 10, + 'pp_propname' => null, + 'cat_pages' => 10, + 'cat_subcats' => 2, + 'cat_files' => 1, + ], + (object)[ + 'rc_title' => 'Already seen', + 'rc_cur_id' => 11, + 'pp_propname' => null, + 'cat_pages' => 10, + 'cat_subcats' => 2, + 'cat_files' => 1, + ], + ], + [ 11 => true ], + ], + 'new page' => [ + __DIR__ . "/../data/categoriesrdf/new.sparql", + 'getNewCatsIterator', + 'handleAdds', + [ + (object)[ + 'rc_title' => 'New category', + 'rc_cur_id' => 20, + '_processed' => 20, + 'pp_propname' => null, + 'cat_pages' => 10, + 'cat_subcats' => 2, + 'cat_files' => 1, + ], + (object)[ + 'rc_title' => 'Новая категория 😃', + 'rc_cur_id' => 21, + '_processed' => 21, + 'pp_propname' => 'hiddencat', + 'cat_pages' => 10, + 'cat_subcats' => 2, + 'cat_files' => 1, + ], + (object)[ + 'rc_title' => 'Processed already', + 'rc_cur_id' => 22, + ], + ], + [ 22 => true ], + ], + 'change in categories' => [ + __DIR__ . "/../data/categoriesrdf/change.sparql", + 'getChangedCatsIterator', + 'handleChanges', + [ + (object)[ + 'rc_title' => 'Changed category', + 'rc_cur_id' => 30, + '_processed' => 30, + 'pp_propname' => null, + 'cat_pages' => 10, + 'cat_subcats' => 2, + 'cat_files' => 1, + ], + (object)[ + 'rc_title' => 'Changed again', + 'rc_cur_id' => 30, + 'pp_propname' => null, + 'cat_pages' => 10, + 'cat_subcats' => 2, + 'cat_files' => 1, + ], + (object)[ + 'rc_title' => 'Processed already', + 'rc_cur_id' => 31, + 'pp_propname' => null, + 'cat_pages' => 10, + 'cat_subcats' => 2, + 'cat_files' => 1, + ], + ], + [ 31 => true ], + ], + + ]; + } + + /** + * Mock category links iterator. + * @param $dbr + * @param array $ids + * @return array + */ + public function getCategoryLinksIterator( $dbr, array $ids ) { + $res = []; + foreach ( $ids as $pageid ) { + $res[] = (object)[ 'cl_from' => $pageid, 'cl_to' => "Parent of $pageid" ]; + } + return $res; + } + + /** + * @dataProvider provideCategoryData + * @param string $testFileName Name of the test, defines filename with expected results. + * @param string $iterator Iterator method name to mock + * @param string $handler Handler method to call + * @param array $result Result to be returned from mock iterator + * @param array $preProcessed List of pre-processed items + */ + public function testSparqlUpdate( $testFileName, $iterator, $handler, $result, + array $preProcessed = [] ) { + $dumpScript = + $this->getMockBuilder( CategoryChangesAsRdf::class ) + ->setMethods( [ $iterator, 'getCategoryLinksIterator' ] ) + ->getMock(); + + $dumpScript->expects( $this->any() ) + ->method( 'getCategoryLinksIterator' ) + ->willReturnCallback( [ $this, 'getCategoryLinksIterator' ] ); + + $dumpScript->expects( $this->once() ) + ->method( $iterator ) + ->willReturn( [ $result ] ); + + $ref = new ReflectionObject( $dumpScript ); + $processedProperty = $ref->getProperty( 'processed' ); + $processedProperty->setAccessible( true ); + $processedProperty->setValue( $dumpScript, $preProcessed ); + + $output = fopen( "php://memory", "w+b" ); + $dbr = wfGetDB( DB_REPLICA ); + /** @var CategoryChangesAsRdf $dumpScript */ + $dumpScript->initialize(); + $dumpScript->getRdf(); + $dumpScript->$handler( $dbr, $output ); + + rewind( $output ); + $sparql = stream_get_contents( $output ); + $this->assertFileContains( $testFileName, $sparql ); + + $processed = $processedProperty->getValue( $dumpScript ); + $expectedProcessed = $preProcessed; + foreach ( $result as $row ) { + if ( isset( $row->_processed ) ) { + $this->assertArrayHasKey( $row->_processed, $processed, + "ID {$row->_processed} was not processed!" ); + $expectedProcessed[] = $row->_processed; + } + } + $this->assertArrayEquals( $expectedProcessed, array_keys( $processed ), + 'Processed array has wrong items' ); + } + + public function testUpdateTs() { + $dumpScript = new CategoryChangesAsRdf(); + $dumpScript->initialize(); + $update = $dumpScript->updateTS( 1503620949 ); + $outFile = __DIR__ . '/../data/categoriesrdf/updatets.txt'; + $this->assertFileContains( $outFile, $update ); + } + +} diff --git a/tests/phpunit/phpunit.php b/tests/phpunit/phpunit.php index fa249b2244..7cf042d0b8 100755 --- a/tests/phpunit/phpunit.php +++ b/tests/phpunit/phpunit.php @@ -80,7 +80,7 @@ class PHPUnitMaintClass extends Maintenance { [ '--configuration', $IP . '/tests/phpunit/suite.xml' ] ); } - $phpUnitClass = 'PHPUnit_TextUI_Command'; + $phpUnitClass = PHPUnit_TextUI_Command::class; if ( $this->hasOption( 'with-phpunitclass' ) ) { $phpUnitClass = $this->getOption( 'with-phpunitclass' ); diff --git a/tests/phpunit/skins/SideBarTest.php b/tests/phpunit/skins/SideBarTest.php index dceaf418f1..ec85bb0326 100644 --- a/tests/phpunit/skins/SideBarTest.php +++ b/tests/phpunit/skins/SideBarTest.php @@ -104,10 +104,10 @@ class SideBarTest extends MediaWikiLangTestCase { ] ); $this->assertSideBar( [ 'Title' => [ - # ** http://www.mediawiki.org/| Home + # ** https://www.mediawiki.org/| Home [ 'text' => 'Home', - 'href' => 'http://www.mediawiki.org/', + 'href' => 'https://www.mediawiki.org/', 'id' => 'n-Home', 'active' => null, 'rel' => 'nofollow', @@ -116,7 +116,7 @@ class SideBarTest extends MediaWikiLangTestCase { # ... skipped since it is missing a pipe with a description ] ], '* Title -** http://www.mediawiki.org/| Home +** https://www.mediawiki.org/| Home ** http://valid.no.desc.org/ ' ); @@ -160,7 +160,7 @@ class SideBarTest extends MediaWikiLangTestCase { private function getAttribs() { # Sidebar text we will use everytime $text = '* Title -** http://www.mediawiki.org/| Home'; +** https://www.mediawiki.org/| Home'; $bar = []; $this->skin->addToSidebarPlain( $bar, $text ); diff --git a/tests/phpunit/structure/ApiStructureTest.php b/tests/phpunit/structure/ApiStructureTest.php index d0126f2cf9..77d6e74174 100644 --- a/tests/phpunit/structure/ApiStructureTest.php +++ b/tests/phpunit/structure/ApiStructureTest.php @@ -26,6 +26,81 @@ class ApiStructureTest extends MediaWikiTestCase { ], ]; + /** + * Values are an array, where each array value is a permitted type. A type + * can be a string, which is the name of an internal type or a + * class/interface. Or it can be an array, in which case the value must be + * an array whose elements are the types given in the array (e.g., [ + * 'string', integer' ] means an array whose entries are strings and/or + * integers). + */ + private static $paramTypes = [ + // ApiBase::PARAM_DFLT => as appropriate for PARAM_TYPE + ApiBase::PARAM_ISMULTI => [ 'boolean' ], + ApiBase::PARAM_TYPE => [ 'string', [ 'string' ] ], + ApiBase::PARAM_MAX => [ 'integer' ], + ApiBase::PARAM_MAX2 => [ 'integer' ], + ApiBase::PARAM_MIN => [ 'integer' ], + ApiBase::PARAM_ALLOW_DUPLICATES => [ 'boolean' ], + ApiBase::PARAM_DEPRECATED => [ 'boolean' ], + ApiBase::PARAM_REQUIRED => [ 'boolean' ], + ApiBase::PARAM_RANGE_ENFORCE => [ 'boolean' ], + ApiBase::PARAM_HELP_MSG => [ 'string', 'array', Message::class ], + ApiBase::PARAM_HELP_MSG_APPEND => [ [ 'string', 'array', Message::class ] ], + ApiBase::PARAM_HELP_MSG_INFO => [ [ 'array' ] ], + ApiBase::PARAM_VALUE_LINKS => [ [ 'string' ] ], + ApiBase::PARAM_HELP_MSG_PER_VALUE => [ [ 'string', 'array', Message::class ] ], + ApiBase::PARAM_SUBMODULE_MAP => [ [ 'string' ] ], + ApiBase::PARAM_SUBMODULE_PARAM_PREFIX => [ 'string' ], + ApiBase::PARAM_ALL => [ 'boolean', 'string' ], + ApiBase::PARAM_EXTRA_NAMESPACES => [ [ 'integer' ] ], + ApiBase::PARAM_SENSITIVE => [ 'boolean' ], + ApiBase::PARAM_DEPRECATED_VALUES => [ 'array' ], + ApiBase::PARAM_ISMULTI_LIMIT1 => [ 'integer' ], + ApiBase::PARAM_ISMULTI_LIMIT2 => [ 'integer' ], + ApiBase::PARAM_MAX_BYTES => [ 'integer' ], + ApiBase::PARAM_MAX_CHARS => [ 'integer' ], + ]; + + // param => [ other param that must be present => required value or null ] + private static $paramRequirements = [ + ApiBase::PARAM_ALLOW_DUPLICATES => [ ApiBase::PARAM_ISMULTI => true ], + ApiBase::PARAM_ALL => [ ApiBase::PARAM_ISMULTI => true ], + ApiBase::PARAM_ISMULTI_LIMIT1 => [ + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_ISMULTI_LIMIT2 => null, + ], + ApiBase::PARAM_ISMULTI_LIMIT2 => [ + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_ISMULTI_LIMIT1 => null, + ], + ]; + + // param => type(s) allowed for this param ('array' is any array) + private static $paramAllowedTypes = [ + ApiBase::PARAM_MAX => [ 'integer', 'limit' ], + ApiBase::PARAM_MAX2 => 'limit', + ApiBase::PARAM_MIN => [ 'integer', 'limit' ], + ApiBase::PARAM_RANGE_ENFORCE => 'integer', + ApiBase::PARAM_VALUE_LINKS => 'array', + ApiBase::PARAM_HELP_MSG_PER_VALUE => 'array', + ApiBase::PARAM_SUBMODULE_MAP => 'submodule', + ApiBase::PARAM_SUBMODULE_PARAM_PREFIX => 'submodule', + ApiBase::PARAM_ALL => 'array', + ApiBase::PARAM_EXTRA_NAMESPACES => 'namespace', + ApiBase::PARAM_DEPRECATED_VALUES => 'array', + ApiBase::PARAM_MAX_BYTES => [ 'NULL', 'string', 'text', 'password' ], + ApiBase::PARAM_MAX_CHARS => [ 'NULL', 'string', 'text', 'password' ], + ]; + + private static $paramProhibitedTypes = [ + ApiBase::PARAM_ISMULTI => [ 'boolean', 'limit', 'upload' ], + ApiBase::PARAM_ALL => 'namespace', + ApiBase::PARAM_SENSITIVE => 'password', + ]; + + private static $constantNames = null; + /** * Initialize/fetch the ApiMain instance for testing * @return ApiMain @@ -178,34 +253,327 @@ class ApiStructureTest extends MediaWikiTestCase { // avoid warnings about empty tests when no parameter needs to be checked $this->assertTrue( true ); + if ( self::$constantNames === null ) { + self::$constantNames = []; + + foreach ( ( new ReflectionClass( 'ApiBase' ) )->getConstants() as $key => $val ) { + if ( substr( $key, 0, 6 ) === 'PARAM_' ) { + self::$constantNames[$val] = $key; + } + } + } + foreach ( [ $paramsPlain, $paramsForHelp ] as $params ) { foreach ( $params as $param => $config ) { - if ( isset( $config[ApiBase::PARAM_ISMULTI_LIMIT1] ) - || isset( $config[ApiBase::PARAM_ISMULTI_LIMIT2] ) + if ( !is_array( $config ) ) { + $config = [ ApiBase::PARAM_DFLT => $config ]; + } + if ( !isset( $config[ApiBase::PARAM_TYPE] ) ) { + $config[ApiBase::PARAM_TYPE] = isset( $config[ApiBase::PARAM_DFLT] ) + ? gettype( $config[ApiBase::PARAM_DFLT] ) + : 'NULL'; + } + + foreach ( self::$paramTypes as $key => $types ) { + if ( !isset( $config[$key] ) ) { + continue; + } + $keyName = self::$constantNames[$key]; + $this->validateType( $types, $config[$key], $param, $keyName ); + } + + foreach ( self::$paramRequirements as $key => $required ) { + if ( !isset( $config[$key] ) ) { + continue; + } + foreach ( $required as $requireKey => $requireVal ) { + $this->assertArrayHasKey( $requireKey, $config, + "$param: When " . self::$constantNames[$key] . " is set, " . + self::$constantNames[$requireKey] . " must also be set" ); + if ( $requireVal !== null ) { + $this->assertSame( $requireVal, $config[$requireKey], + "$param: When " . self::$constantNames[$key] . " is set, " . + self::$constantNames[$requireKey] . " must equal " . + var_export( $requireVal, true ) ); + } + } + } + + foreach ( self::$paramAllowedTypes as $key => $allowedTypes ) { + if ( !isset( $config[$key] ) ) { + continue; + } + + $actualType = is_array( $config[ApiBase::PARAM_TYPE] ) + ? 'array' : $config[ApiBase::PARAM_TYPE]; + + $this->assertContains( + $actualType, + (array)$allowedTypes, + "$param: " . self::$constantNames[$key] . + " can only be used with PARAM_TYPE " . + implode( ', ', (array)$allowedTypes ) + ); + } + + foreach ( self::$paramProhibitedTypes as $key => $prohibitedTypes ) { + if ( !isset( $config[$key] ) ) { + continue; + } + + $actualType = is_array( $config[ApiBase::PARAM_TYPE] ) + ? 'array' : $config[ApiBase::PARAM_TYPE]; + + $this->assertNotContains( + $actualType, + (array)$prohibitedTypes, + "$param: " . self::$constantNames[$key] . + " cannot be used with PARAM_TYPE " . + implode( ', ', (array)$prohibitedTypes ) + ); + } + + if ( isset( $config[ApiBase::PARAM_DFLT] ) ) { + $this->assertFalse( + isset( $config[ApiBase::PARAM_REQUIRED] ) && + $config[ApiBase::PARAM_REQUIRED], + "$param: A required parameter cannot have a default" ); + + $this->validateDefault( $param, $config ); + } + + if ( $config[ApiBase::PARAM_TYPE] === 'limit' ) { + $this->assertTrue( + isset( $config[ApiBase::PARAM_MAX] ) && + isset( $config[ApiBase::PARAM_MAX2] ), + "$param: PARAM_MAX and PARAM_MAX2 are required for limits" + ); + $this->assertGreaterThanOrEqual( + $config[ApiBase::PARAM_MAX], + $config[ApiBase::PARAM_MAX2], + "$param: PARAM_MAX cannot be greater than PARAM_MAX2" + ); + } + + if ( + isset( $config[ApiBase::PARAM_MIN] ) && + isset( $config[ApiBase::PARAM_MAX] ) ) { - $this->assertTrue( !empty( $config[ApiBase::PARAM_ISMULTI] ), $param - . ': PARAM_ISMULTI_LIMIT* only makes sense when PARAM_ISMULTI is true' ); - $this->assertTrue( isset( $config[ApiBase::PARAM_ISMULTI_LIMIT1] ) - && isset( $config[ApiBase::PARAM_ISMULTI_LIMIT2] ), $param - . ': PARAM_ISMULTI_LIMIT1 and PARAM_ISMULTI_LIMIT2 must be used together' ); - $this->assertType( 'int', $config[ApiBase::PARAM_ISMULTI_LIMIT1], $param - . 'PARAM_ISMULTI_LIMIT1 must be an integer' ); - $this->assertType( 'int', $config[ApiBase::PARAM_ISMULTI_LIMIT2], $param - . 'PARAM_ISMULTI_LIMIT2 must be an integer' ); - $this->assertGreaterThanOrEqual( $config[ApiBase::PARAM_ISMULTI_LIMIT1], - $config[ApiBase::PARAM_ISMULTI_LIMIT2], $param - . 'PARAM_ISMULTI limit cannot be smaller for users with apihighlimits rights' ); - } - if ( isset( $config[ApiBase::PARAM_MAX_BYTES] ) - || isset( $config[ApiBase::PARAM_MAX_CHARS] ) + $this->assertGreaterThanOrEqual( + $config[ApiBase::PARAM_MIN], + $config[ApiBase::PARAM_MAX], + "$param: PARAM_MIN cannot be greater than PARAM_MAX" + ); + } + + if ( isset( $config[ApiBase::PARAM_RANGE_ENFORCE] ) ) { + $this->assertTrue( + isset( $config[ApiBase::PARAM_MIN] ) || + isset( $config[ApiBase::PARAM_MAX] ), + "$param: PARAM_RANGE_ENFORCE can only be set together with " . + "PARAM_MIN or PARAM_MAX" + ); + } + + if ( isset( $config[ApiBase::PARAM_DEPRECATED_VALUES] ) ) { + foreach ( $config[ApiBase::PARAM_DEPRECATED_VALUES] as $key => $unused ) { + $this->assertContains( $key, $config[ApiBase::PARAM_TYPE], + "$param: Deprecated value \"$key\" is not allowed, " . + "how can it be deprecated?" ); + } + } + + if ( + isset( $config[ApiBase::PARAM_ISMULTI_LIMIT1] ) || + isset( $config[ApiBase::PARAM_ISMULTI_LIMIT2] ) + ) { + $this->assertGreaterThanOrEqual( 0, $config[ApiBase::PARAM_ISMULTI_LIMIT1], + "$param: PARAM_ISMULTI_LIMIT1 cannot be negative" ); + // Zero for both doesn't make sense, but you could have + // zero for non-bots + $this->assertGreaterThanOrEqual( 1, $config[ApiBase::PARAM_ISMULTI_LIMIT2], + "$param: PARAM_ISMULTI_LIMIT2 cannot be negative or zero" ); + $this->assertGreaterThanOrEqual( + $config[ApiBase::PARAM_ISMULTI_LIMIT1], + $config[ApiBase::PARAM_ISMULTI_LIMIT2], + "$param: PARAM_ISMULTI limit cannot be smaller for users with " . + "apihighlimits rights" ); + } + + if ( isset( $config[ApiBase::PARAM_MAX_BYTES] ) ) { + $this->assertGreaterThanOrEqual( 1, $config[ApiBase::PARAM_MAX_BYTES], + "$param: PARAM_MAX_BYTES cannot be negative or zero" ); + } + + if ( isset( $config[ApiBase::PARAM_MAX_CHARS] ) ) { + $this->assertGreaterThanOrEqual( 1, $config[ApiBase::PARAM_MAX_CHARS], + "$param: PARAM_MAX_CHARS cannot be negative or zero" ); + } + + if ( + isset( $config[ApiBase::PARAM_MAX_BYTES] ) && + isset( $config[ApiBase::PARAM_MAX_CHARS] ) ) { - $default = isset( $config[ApiBase::PARAM_DFLT] ) ? $config[ApiBase::PARAM_DFLT] : null; - $type = isset( $config[ApiBase::PARAM_TYPE] ) ? $config[ApiBase::PARAM_TYPE] - : gettype( $default ); - $this->assertContains( $type, [ 'NULL', 'string', 'text', 'password' ], - 'PARAM_MAX_BYTES/CHARS is only supported for string-like types' ); + // Length of a string in chars is always <= length in bytes, + // so PARAM_MAX_CHARS is pointless if > PARAM_MAX_BYTES + $this->assertGreaterThanOrEqual( + $config[ApiBase::PARAM_MAX_CHARS], + $config[ApiBase::PARAM_MAX_BYTES], + "$param: PARAM_MAX_BYTES cannot be less than PARAM_MAX_CHARS" + ); + } + } + } + } + + /** + * Throws if $value does not match one of the types specified in $types. + * + * @param array $types From self::$paramTypes array + * @param mixed $value Value to check + * @param string $param Name of param we're checking, for error messages + * @param string $desc Description for error messages + */ + private function validateType( $types, $value, $param, $desc ) { + if ( count( $types ) === 1 ) { + // Only one type allowed + if ( is_string( $types[0] ) ) { + $this->assertType( $types[0], $value, "$param: $desc type" ); + } else { + // Array whose values have specified types, recurse + $this->assertInternalType( 'array', $value, "$param: $desc type" ); + foreach ( $value as $subvalue ) { + $this->validateType( $types[0], $subvalue, $param, "$desc value" ); + } + } + } else { + // Multiple options + foreach ( $types as $type ) { + if ( is_string( $type ) ) { + if ( class_exists( $type ) || interface_exists( $type ) ) { + if ( $value instanceof $type ) { + return; + } + } else { + if ( gettype( $value ) === $type ) { + return; + } + } + } else { + // Array whose values have specified types, recurse + try { + $this->validateType( [ $type ], $value, $param, "$desc type" ); + // Didn't throw, so we're good + return; + } catch ( Exception $unused ) { + } } } + // Doesn't match any of them + $this->fail( "$param: $desc has incorrect type" ); + } + } + + /** + * Asserts that $default is a valid default for $type. + * + * @param string $param Name of param, for error messages + * @param array $config Array of configuration options for this parameter + */ + private function validateDefault( $param, $config ) { + $type = $config[ApiBase::PARAM_TYPE]; + $default = $config[ApiBase::PARAM_DFLT]; + + if ( !empty( $config[ApiBase::PARAM_ISMULTI] ) ) { + if ( $default === '' ) { + // The empty array is fine + return; + } + $defaults = explode( '|', $default ); + $config[ApiBase::PARAM_ISMULTI] = false; + foreach ( $defaults as $defaultValue ) { + // Only allow integers in their simplest form with no leading + // or trailing characters etc. + if ( $type === 'integer' && $defaultValue === (string)(int)$defaultValue ) { + $defaultValue = (int)$defaultValue; + } + $config[ApiBase::PARAM_DFLT] = $defaultValue; + $this->validateDefault( $param, $config ); + } + return; + } + switch ( $type ) { + case 'boolean': + $this->assertFalse( $default, + "$param: Boolean params may only default to false" ); + break; + + case 'integer': + $this->assertInternalType( 'integer', $default, + "$param: Default $default is not an integer" ); + break; + + case 'limit': + if ( $default === 'max' ) { + break; + } + $this->assertInternalType( 'integer', $default, + "$param: Default $default is neither an integer nor \"max\"" ); + break; + + case 'namespace': + $validValues = MWNamespace::getValidNamespaces(); + if ( + isset( $config[ApiBase::PARAM_EXTRA_NAMESPACES] ) && + is_array( $config[ApiBase::PARAM_EXTRA_NAMESPACES] ) + ) { + $validValues = array_merge( + $validValues, + $config[ApiBase::PARAM_EXTRA_NAMESPACES] + ); + } + $this->assertContains( $default, $validValues, + "$param: Default $default is not a valid namespace" ); + break; + + case 'NULL': + case 'password': + case 'string': + case 'submodule': + case 'tags': + case 'text': + $this->assertInternalType( 'string', $default, + "$param: Default $default is not a string" ); + break; + + case 'timestamp': + if ( $default === 'now' ) { + return; + } + $this->assertNotFalse( wfTimestamp( TS_MW, $default ), + "$param: Default $default is not a valid timestamp" ); + break; + + case 'user': + // @todo Should we make user validation a public static method + // in ApiBase() or something so we don't have to resort to + // this? Or in User for that matter. + $wrapper = TestingAccessWrapper::newFromObject( new ApiMain() ); + try { + $wrapper->validateUser( $default, '' ); + } catch ( ApiUsageException $e ) { + $this->fail( "$param: Default $default is not a valid username/IP address" ); + } + break; + + default: + if ( is_array( $type ) ) { + $this->assertContains( $default, $type, + "$param: Default $default is not any of " . + implode( ', ', $type ) ); + } else { + $this->fail( "Unrecognized type $type" ); + } } } diff --git a/tests/qunit/data/generateJqueryMsgData.php b/tests/qunit/data/generateJqueryMsgData.php index 1c79f6d12e..e4f87f81c7 100644 --- a/tests/qunit/data/generateJqueryMsgData.php +++ b/tests/qunit/data/generateJqueryMsgData.php @@ -21,7 +21,7 @@ $.each( mw.libs.phpParserData.tests, function ( i, test ) { QUnit.stop(); getMwLanguage( test.lang, function ( langClass ) { - var parser = new mw.jqueryMsg.parser( { language: langClass } ); + var parser = new mw.jqueryMsg.Parser( { language: langClass } ); assert.equal( parser.parse( test.key, test.args ).html(), test.result, @@ -50,7 +50,7 @@ }, 'Language class should be loaded', 1000 ); runs( function () { console.log( test.lang, 'running tests' ); - var parser = new mw.jqueryMsg.parser( { language: langClass } ); + var parser = new mw.jqueryMsg.Parser( { language: langClass } ); expect( parser.parse( test.key, test.args ).html() ).toEqual( test.result ); diff --git a/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js b/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js index 23ef26f6f6..74caf5ca3a 100644 --- a/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js +++ b/tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js @@ -1494,5 +1494,33 @@ 'detectParserForColumn() detect parser.id "number" for second column' ); } ); + QUnit.test( 'T29745 - References ignored in sortkey', function ( assert ) { + var $table, parsers; + $table = $( + '' + + '' + + '' + + '' + + '
    A
    10
    2[1]
    ' + ); + $table.tablesorter(); + $table.find( '.headerSort:eq(0)' ).click(); + + assert.deepEqual( + tableExtract( $table ), + [ + [ '2[1]' ], + [ '10' ] + ], + 'References ignored in sortkey' + ); + + parsers = $table.data( 'tablesorter' ).config.parsers; + assert.equal( + parsers[ 0 ].id, + 'number', + 'detectParserForColumn() detect parser.id "number"' + ); + } ); }( jQuery, mediaWiki ) ); diff --git a/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.test.js b/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.test.js index 417ad3d81c..7431b294ac 100644 --- a/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.test.js +++ b/tests/qunit/suites/resources/mediawiki.api/mediawiki.api.test.js @@ -213,6 +213,29 @@ } ); } ); + QUnit.test( 'getToken() - no query', function ( assert ) { + var api = new mw.Api(), + // Same-origin warning and missing query in response. + serverRsp = { + warnings: { + tokens: { + '*': 'Tokens may not be obtained when the same-origin policy is not applied.' + } + } + }; + + this.server.respondWith( /type=testnoquery/, [ 200, { 'Content-Type': 'application/json' }, + JSON.stringify( serverRsp ) + ] ); + + return api.getToken( 'testnoquery' ) + .then( function () { assert.fail( 'Expected response missing a query to be rejected' ); } ) + .catch( function ( err, rsp ) { + assert.equal( err, 'query-missing', 'Expected no query error code' ); + assert.deepEqual( rsp, serverRsp ); + } ); + } ); + QUnit.test( 'getToken() - deprecated', function ( assert ) { // Cache API endpoint from default to avoid cachehit in mw.user.tokens var api = new mw.Api( { ajax: { url: '/postWithToken/api.php' } } ), diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js index 2a563c8a41..71362fd0d1 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js @@ -374,8 +374,7 @@ .then( function ( langClass ) { var parser; mw.config.set( 'wgUserLanguage', test.lang ); - // eslint-disable-next-line new-cap - parser = new mw.jqueryMsg.parser( { language: langClass } ); + parser = new mw.jqueryMsg.Parser( { language: langClass } ); assert.equal( parser.parse( test.key, test.args ).html(), test.result, @@ -905,8 +904,7 @@ .then( function ( langClass ) { var parser; mw.config.set( 'wgUserLanguage', test.lang ); - // eslint-disable-next-line new-cap - parser = new mw.jqueryMsg.parser( { language: langClass } ); + parser = new mw.jqueryMsg.Parser( { language: langClass } ); assert.equal( parser.parse( test.integer ? 'formatnum-msg-int' : 'formatnum-msg', [ test.number ] ).html(), @@ -1156,21 +1154,11 @@ } ); QUnit.test( 'Integration', function ( assert ) { - var expected, logSpy, msg; + var expected, msg; expected = 'Bold!'; mw.messages.set( 'integration-test', '[[Bold]]!' ); - this.suppressWarnings(); - logSpy = this.sandbox.spy( mw.log, 'warn' ); - assert.equal( - window.gM( 'integration-test' ), - expected, - 'Global function gM() works correctly' - ); - assert.equal( logSpy.callCount, 1, 'mw.log.warn called' ); - this.restoreWarnings(); - assert.equal( mw.message( 'integration-test' ).parse(), expected, diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js index 0b05ac1244..42bc0a76fc 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.loader.test.js @@ -1,5 +1,5 @@ ( function ( mw, $ ) { - QUnit.module( 'mediawiki (mw.loader)', QUnit.newMwEnvironment( { + QUnit.module( 'mediawiki.loader', QUnit.newMwEnvironment( { setup: function ( assert ) { mw.loader.store.enabled = false; @@ -17,12 +17,8 @@ } // Remove any remaining temporary statics // exposed for cross-file mocks. - if ( 'testCallback' in mw.loader ) { - delete mw.loader.testCallback; - } - if ( 'testFail' in mw.loader ) { - delete mw.loader.testFail; - } + delete mw.loader.testCallback; + delete mw.loader.testFail; } } ) ); @@ -95,61 +91,35 @@ ); } - QUnit.test( 'Basic', function ( assert ) { - var isAwesomeDone; - + QUnit.test( '.using( .., Function callback ) Promise', function ( assert ) { + var script = 0, callback = 0; mw.loader.testCallback = function () { - assert.strictEqual( isAwesomeDone, undefined, 'Implementing module is.awesome: isAwesomeDone should still be undefined' ); - isAwesomeDone = true; + script++; }; + mw.loader.implement( 'test.promise', [ QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/mwLoaderTestCallback.js' ) ] ); - mw.loader.implement( 'test.callback', [ QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/mwLoaderTestCallback.js' ) ] ); - - return mw.loader.using( 'test.callback', function () { - assert.strictEqual( isAwesomeDone, true, 'test.callback module should\'ve caused isAwesomeDone to be true' ); - }, function () { - assert.ok( false, 'Error callback fired while loader.using "test.callback" module' ); + return mw.loader.using( 'test.promise', function () { + callback++; + } ).then( function () { + assert.strictEqual( script, 1, 'module script ran' ); + assert.strictEqual( callback, 1, 'using() callback ran' ); } ); } ); - QUnit.test( 'Object method as module name', function ( assert ) { - var isAwesomeDone; - + QUnit.test( 'Prototype method as module name', function ( assert ) { + var call = 0; mw.loader.testCallback = function () { - assert.strictEqual( isAwesomeDone, undefined, 'Implementing module hasOwnProperty: isAwesomeDone should still be undefined' ); - isAwesomeDone = true; + call++; }; - mw.loader.implement( 'hasOwnProperty', [ QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/mwLoaderTestCallback.js' ) ], {}, {} ); return mw.loader.using( 'hasOwnProperty', function () { - assert.strictEqual( isAwesomeDone, true, 'hasOwnProperty module should\'ve caused isAwesomeDone to be true' ); - }, function () { - assert.ok( false, 'Error callback fired while loader.using "hasOwnProperty" module' ); + assert.strictEqual( call, 1, 'module script ran' ); } ); } ); - QUnit.test( '.using( .. ) Promise', function ( assert ) { - var isAwesomeDone; - - mw.loader.testCallback = function () { - assert.strictEqual( isAwesomeDone, undefined, 'Implementing module is.awesome: isAwesomeDone should still be undefined' ); - isAwesomeDone = true; - }; - - mw.loader.implement( 'test.promise', [ QUnit.fixurl( mw.config.get( 'wgScriptPath' ) + '/tests/qunit/data/mwLoaderTestCallback.js' ) ] ); - - return mw.loader.using( 'test.promise' ) - .done( function () { - assert.strictEqual( isAwesomeDone, true, 'test.promise module should\'ve caused isAwesomeDone to be true' ); - } ) - .fail( function () { - assert.ok( false, 'Error callback fired while loader.using "test.promise" module' ); - } ); - } ); - // Covers mw.loader#sortDependencies (with native Set if available) - QUnit.test( '.using() Error: Circular dependency [StringSet default]', function ( assert ) { + QUnit.test( '.using() - Error: Circular dependency [StringSet default]', function ( assert ) { var done = assert.async(); mw.loader.register( [ @@ -169,7 +139,7 @@ } ); // @covers mw.loader#sortDependencies (with fallback shim) - QUnit.test( '.using() Error: Circular dependency [StringSet shim]', function ( assert ) { + QUnit.test( '.using() - Error: Circular dependency [StringSet shim]', function ( assert ) { var done = assert.async(); if ( !window.Set ) { @@ -433,23 +403,34 @@ mw.loader.load( 'test.implement.d' ); } ); + QUnit.test( '.implement( messages before script )', function ( assert ) { + mw.loader.implement( + 'test.implement.order', + function () { + assert.equal( mw.loader.getState( 'test.implement.order' ), 'executing', 'state during script execution' ); + assert.equal( mw.msg( 'test-foobar' ), 'Hello Foobar, $1!', 'messages load before script execution' ); + }, + {}, + { + 'test-foobar': 'Hello Foobar, $1!' + } + ); + + return mw.loader.using( 'test.implement.order' ).then( function () { + assert.equal( mw.loader.getState( 'test.implement.order' ), 'ready', 'final success state' ); + } ); + } ); + // @import (T33676) - QUnit.test( '.implement( styles has @import )', function ( assert ) { - var isJsExecuted, $element, + QUnit.test( '.implement( styles with @import )', function ( assert ) { + var $element, done = assert.async(); mw.loader.implement( 'test.implement.import', function () { - assert.strictEqual( isJsExecuted, undefined, 'script not executed multiple times' ); - isJsExecuted = true; - - assert.equal( mw.loader.getState( 'test.implement.import' ), 'executing', 'module state during implement() script execution' ); - $element = $( '
    Foo bar
    ' ).appendTo( '#qunit-fixture' ); - assert.equal( mw.msg( 'test-foobar' ), 'Hello Foobar, $1!', 'messages load before script execution' ); - assertStyleAsync( assert, $element, 'float', 'right', function () { assert.equal( $element.css( 'text-align' ), 'center', 'CSS styles after the @import rule are working' @@ -465,16 +446,10 @@ + '\');\n' + '.mw-test-implement-import { text-align: center; }' ] - }, - { - 'test-foobar': 'Hello Foobar, $1!' } ); - mw.loader.using( 'test.implement.import' ).always( function () { - assert.strictEqual( isJsExecuted, true, 'script executed' ); - assert.equal( mw.loader.getState( 'test.implement.import' ), 'ready', 'module state after script execution' ); - } ); + return mw.loader.using( 'test.implement.import' ); } ); QUnit.test( '.implement( dependency with styles )', function ( assert ) { @@ -540,8 +515,6 @@ return mw.loader.using( 'test.implement.msgs', function () { assert.ok( mw.messages.exists( 'T31107' ), 'T31107: messages-only module should implement ok' ); - }, function () { - assert.ok( false, 'Error callback fired while implementing "test.implement.msgs" module' ); } ); } ); @@ -713,9 +686,9 @@ ] ); function verifyModuleStates() { - assert.equal( mw.loader.getState( 'testMissing' ), 'missing', 'Module not known to server must have state "missing"' ); - assert.equal( mw.loader.getState( 'testUsesMissing' ), 'error', 'Module with missing dependency must have state "error"' ); - assert.equal( mw.loader.getState( 'testUsesNestedMissing' ), 'error', 'Module with indirect missing dependency must have state "error"' ); + assert.equal( mw.loader.getState( 'testMissing' ), 'missing', 'Module "testMissing" state' ); + assert.equal( mw.loader.getState( 'testUsesMissing' ), 'error', 'Module "testUsesMissing" state' ); + assert.equal( mw.loader.getState( 'testUsesNestedMissing' ), 'error', 'Module "testUsesNestedMissing" state' ); } mw.loader.using( [ 'testUsesNestedMissing' ], @@ -749,24 +722,16 @@ [ 'testUsesSkippable', '1', [ 'testSkipped', 'testNotSkipped' ], null, 'testloader' ] ] ); - function verifyModuleStates() { - assert.equal( mw.loader.getState( 'testSkipped' ), 'ready', 'Module is ready when skipped' ); - assert.equal( mw.loader.getState( 'testNotSkipped' ), 'ready', 'Module is ready when not skipped but loaded' ); - assert.equal( mw.loader.getState( 'testUsesSkippable' ), 'ready', 'Module is ready when skippable dependencies are ready' ); - } - - return mw.loader.using( [ 'testUsesSkippable' ], + return mw.loader.using( [ 'testUsesSkippable' ] ).then( function () { - assert.ok( true, 'Success handler should be invoked.' ); - assert.ok( true ); // Dummy to match error handler and reach QUnit expect() - - verifyModuleStates(); + assert.equal( mw.loader.getState( 'testSkipped' ), 'ready', 'Skipped module' ); + assert.equal( mw.loader.getState( 'testNotSkipped' ), 'ready', 'Regular module' ); + assert.equal( mw.loader.getState( 'testUsesSkippable' ), 'ready', 'Regular module with skippable dependency' ); }, function ( e, badmodules ) { - assert.ok( false, 'Error handler should not be invoked.' ); - assert.deepEqual( badmodules, [], 'Bad modules as expected.' ); - - verifyModuleStates(); + // Should not fail and QUnit would already catch this, + // but add a handler anyway to report details from 'badmodules + assert.deepEqual( badmodules, [], 'Bad modules' ); } ); } ); @@ -889,18 +854,18 @@ } ); QUnit.test( 'Stale response caching - backcompat', function ( assert ) { - var count = 0; + var script = 0; mw.loader.store.enabled = true; mw.loader.register( 'test.stalebc', 'v2' ); assert.strictEqual( mw.loader.store.get( 'test.stalebc' ), false, 'Not in store' ); mw.loader.implement( 'test.stalebc', function () { - count++; + script++; } ); return mw.loader.using( 'test.stalebc' ) .then( function () { - assert.strictEqual( count, 1 ); + assert.strictEqual( script, 1, 'module script ran' ); assert.strictEqual( mw.loader.getState( 'test.stalebc' ), 'ready' ); assert.ok( mw.loader.store.get( 'test.stalebc' ), 'In store' ); } ) @@ -979,37 +944,33 @@ } catch ( e ) { assert.equal( null, String( e ), 'require works asynchrously in debug mode' ); } - }, function () { - assert.ok( false, 'Error callback fired while loader.using "test.require.callback" module' ); } ); } ); QUnit.test( 'Implicit dependencies', function ( assert ) { - var ranUser = false, - userSeesSite = false, - ranSite = false; + var user = 0, + site = 0, + siteFromUser = 0; mw.loader.implement( 'site', function () { - ranSite = true; + site++; } ); mw.loader.implement( 'user', function () { - userSeesSite = ranSite; - ranUser = true; + user++; + siteFromUser = site; } ); - assert.strictEqual( ranSite, false, 'verify site module not yet loaded' ); - assert.strictEqual( ranUser, false, 'verify user module not yet loaded' ); return mw.loader.using( 'user', function () { - assert.strictEqual( ranSite, true, 'ran site module' ); - assert.strictEqual( ranUser, true, 'ran user module' ); - assert.strictEqual( userSeesSite, true, 'ran site before user module' ); - + assert.strictEqual( site, 1, 'site module' ); + assert.strictEqual( user, 1, 'user module' ); + assert.strictEqual( siteFromUser, 1, 'site ran before user' ); + } ).always( function () { // Reset mw.loader.moduleRegistry[ 'site' ].state = 'registered'; mw.loader.moduleRegistry[ 'user' ].state = 'registered'; diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.test.js index 119222a61e..75dc66511e 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.test.js @@ -113,6 +113,8 @@ assert.strictEqual( conf.set( funky, 'Funky' ), false, 'Map.set returns boolean false if key was invalid (Function)' ); assert.strictEqual( conf.set( arry, 'Arry' ), false, 'Map.set returns boolean false if key was invalid (Array)' ); assert.strictEqual( conf.set( nummy, 'Nummy' ), false, 'Map.set returns boolean false if key was invalid (Number)' ); + assert.strictEqual( conf.set( null, 'Null' ), false, 'Map.set returns false if key is invalid (null)' ); + assert.strictEqual( conf.set( {}, 'Object' ), false, 'Map.set returns false if key is invalid (plain object)' ); conf.set( String( nummy ), 'I used to be a number' ); diff --git a/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js b/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js index b8464e9907..f776d41673 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js @@ -92,44 +92,16 @@ assert.equal( util.rawurlencode( 'Test:A & B/Here' ), 'Test%3AA%20%26%20B%2FHere' ); } ); - QUnit.test( 'escapeId', function ( assert ) { - mw.config.set( 'wgFragmentMode', [ 'legacy' ] ); - $.each( { - '+': '.2B', - '&': '.26', - '=': '.3D', - ':': ':', - ';': '.3B', - '@': '.40', - $: '.24', - '-_.': '-_.', - '!': '.21', - '*': '.2A', - '/': '.2F', - '[]': '.5B.5D', - '<>': '.3C.3E', - '\'': '.27', - '§': '.C2.A7', - 'Test:A & B/Here': 'Test:A_.26_B.2FHere', - 'A&B&C&amp;D&amp;amp;E': 'A.26B.26amp.3BC.26amp.3Bamp.3BD.26amp.3Bamp.3Bamp.3BE' - }, function ( input, output ) { - assert.equal( util.escapeId( input ), output ); - } ); - } ); - QUnit.test( 'escapeIdForAttribute', function ( assert ) { // Test cases are kept in sync with SanitizerTest.php var text = 'foo тест_#%!\'()[]:<>', legacyEncoded = 'foo_.D1.82.D0.B5.D1.81.D1.82_.23.25.21.27.28.29.5B.5D:.3C.3E', html5Encoded = 'foo_тест_#%!\'()[]:<>', - html5Experimental = 'foo_тест_!_()[]:<>', // Settings: this is $wgFragmentMode legacy = [ 'legacy' ], legacyNew = [ 'legacy', 'html5' ], newLegacy = [ 'html5', 'legacy' ], - allNew = [ 'html5' ], - experimentalLegacy = [ 'html5-legacy', 'legacy' ], - newExperimental = [ 'html5', 'html5-legacy' ]; + allNew = [ 'html5' ]; // Test cases are kept in sync with SanitizerTest.php [ @@ -140,11 +112,7 @@ // New world: HTML5 links, legacy fallbacks [ newLegacy, text, html5Encoded ], // Distant future: no legacy fallbacks - [ allNew, text, html5Encoded ], - // Someone flipped $wgExperimentalHtmlIds on - [ experimentalLegacy, text, html5Experimental ], - // Migration from $wgExperimentalHtmlIds to modern HTML5 - [ newExperimental, text, html5Encoded ] + [ allNew, text, html5Encoded ] ].forEach( function ( testCase ) { mw.config.set( 'wgFragmentMode', testCase[ 0 ] ); @@ -157,14 +125,11 @@ var text = 'foo тест_#%!\'()[]:<>', legacyEncoded = 'foo_.D1.82.D0.B5.D1.81.D1.82_.23.25.21.27.28.29.5B.5D:.3C.3E', html5Encoded = 'foo_тест_#%!\'()[]:<>', - html5Experimental = 'foo_тест_!_()[]:<>', // Settings: this is wgFragmentMode legacy = [ 'legacy' ], legacyNew = [ 'legacy', 'html5' ], newLegacy = [ 'html5', 'legacy' ], - allNew = [ 'html5' ], - experimentalLegacy = [ 'html5-legacy', 'legacy' ], - newExperimental = [ 'html5', 'html5-legacy' ]; + allNew = [ 'html5' ]; [ // Pure legacy: how MW worked before 2017 @@ -174,11 +139,7 @@ // New world: HTML5 links, legacy fallbacks [ newLegacy, text, html5Encoded ], // Distant future: no legacy fallbacks - [ allNew, text, html5Encoded ], - // Someone flipped wgExperimentalHtmlIds on - [ experimentalLegacy, text, html5Experimental ], - // Migration from wgExperimentalHtmlIds to modern HTML5 - [ newExperimental, text, html5Encoded ] + [ allNew, text, html5Encoded ] ].forEach( function ( testCase ) { mw.config.set( 'wgFragmentMode', testCase[ 0 ] ); diff --git a/tests/selenium/.eslintrc.json b/tests/selenium/.eslintrc.json index 85fc310708..e39226c4ac 100644 --- a/tests/selenium/.eslintrc.json +++ b/tests/selenium/.eslintrc.json @@ -9,6 +9,6 @@ "browser": false }, "rules":{ - "no-console":0 + "no-console": 0 } } diff --git a/tests/selenium/README.md b/tests/selenium/README.md index 2dbf27140a..a7c9aa6c3c 100644 --- a/tests/selenium/README.md +++ b/tests/selenium/README.md @@ -5,9 +5,8 @@ - [Chrome](https://www.google.com/chrome/) - [ChromeDriver](https://sites.google.com/a/chromium.org/chromedriver/) - [Node.js](https://nodejs.org/en/) -- [MediaWiki-Vagrant](https://www.mediawiki.org/wiki/MediaWiki-Vagrant) -Set up MediaWiki-Vagrant: +If using MediaWiki-Vagrant: cd mediawiki/vagrant vagrant up @@ -21,36 +20,37 @@ Set up MediaWiki-Vagrant: npm run selenium -To run only one file (for example page.js), you first need to spawn the chromedriver: +By default, Chrome will run in headless mode. If you want to see Chrome, set DISPLAY +environment variable to any value: - chromedriver --url-base=wd/hub --port=4444 + DISPLAY=1 npm run selenium + +To run only one test (for example specs/page.js), you first need to start Chromedriver: -Then in another terminal: + chromedriver --url-base=wd/hub --port=4444 - cd tests/selenium - ../../node_modules/.bin/wdio --spec specs/page.js +Then, in another terminal: -To run only one test (name contains string 'preferences'): + npm run selenium-test -- --spec tests/selenium/specs/page.js - ../../node_modules/.bin/wdio --spec specs/user.js --mochaOpts.grep preferences +You can also filter specific cases, for ones that contain the string 'preferences': -The runner reads the config file `wdio.conf.js` and runs the spec listed in -`page.js`. + npm run selenium-test -- tests/selenium/specs/user.js --mochaOpts.grep preferences -The defaults in the configuration files aim are targeting a MediaWiki-Vagrant -installation on http://127.0.0.1:8080 with a user Admin and -password 'vagrant'. Those settings can be overridden using environment +The runner reads the configuration from `wdio.conf.js`. The defaults target +a MediaWiki-Vagrant installation on `http://127.0.0.1:8080` with a user "Admin" +and password "vagrant". Those settings can be overridden using environment variables: -`MW_SERVER`: to be set to the value of your $wgServer -`MW_SCRIPT_PATH`: ditto with $wgScriptPath -`MEDIAWIKI_USER`: username of an account that can create users on the wiki -`MEDIAWIKI_PASSWORD`: password for above user +- `MW_SERVER`: to be set to the value of your $wgServer +- `MW_SCRIPT_PATH`: ditto with $wgScriptPath +- `MEDIAWIKI_USER`: username of an account that can create users on the wiki +- `MEDIAWIKI_PASSWORD`: password for above user Example: MW_SERVER=http://example.org MW_SCRIPT_PATH=/dev/w npm run selenium -## Links +## Further reading - [Selenium/Node.js](https://www.mediawiki.org/wiki/Selenium/Node.js) diff --git a/tests/selenium/pageobjects/createaccount.page.js b/tests/selenium/pageobjects/createaccount.page.js index 105f40924e..2bcef13a73 100644 --- a/tests/selenium/pageobjects/createaccount.page.js +++ b/tests/selenium/pageobjects/createaccount.page.js @@ -1,8 +1,7 @@ -'use strict'; -const Page = require( './page' ); +const Page = require( 'wdio-mediawiki/Page' ), + Api = require( 'wdio-mediawiki/Api' ); class CreateAccountPage extends Page { - get username() { return browser.element( '#wpName2' ); } get password() { return browser.element( '#wpPassword2' ); } get confirmPassword() { return browser.element( '#wpRetype' ); } @@ -10,7 +9,7 @@ class CreateAccountPage extends Page { get heading() { return browser.element( '#firstHeading' ); } open() { - super.open( 'Special:CreateAccount' ); + super.openTitle( 'Special:CreateAccount' ); } createAccount( username, password ) { @@ -21,29 +20,10 @@ class CreateAccountPage extends Page { this.create.click(); } + // @deprecated Use wdio-mediawiki/Api#createAccount() instead. apiCreateAccount( username, password ) { - - const MWBot = require( 'mwbot' ), // https://github.com/Fannon/mwbot - Promise = require( 'bluebird' ); - let bot = new MWBot(); - - return Promise.coroutine( function* () { - yield bot.loginGetCreateaccountToken( { - apiUrl: `${browser.options.baseUrl}/api.php`, - username: browser.options.username, - password: browser.options.password - } ); - yield bot.request( { - action: 'createaccount', - createreturnurl: browser.options.baseUrl, - createtoken: bot.createaccountToken, - username: username, - password: password, - retype: password - } ); - } ).call( this ); - + return Api.createAccount( username, password ); } - } + module.exports = new CreateAccountPage(); diff --git a/tests/selenium/pageobjects/delete.page.js b/tests/selenium/pageobjects/delete.page.js index d43cb9f612..1218818008 100644 --- a/tests/selenium/pageobjects/delete.page.js +++ b/tests/selenium/pageobjects/delete.page.js @@ -1,39 +1,26 @@ -'use strict'; -const Page = require( './page' ); +const Page = require( 'wdio-mediawiki/Page' ), + Api = require( 'wdio-mediawiki/Api' ); class DeletePage extends Page { - get reason() { return browser.element( '#wpReason' ); } get watch() { return browser.element( '#wpWatch' ); } get submit() { return browser.element( '#wpConfirmB' ); } get displayedContent() { return browser.element( '#mw-content-text' ); } - open( name ) { - super.open( name + '&action=delete' ); + open( title ) { + super.openTitle( title, { action: 'delete' } ); } - delete( name, reason ) { - this.open( name ); + delete( title, reason ) { + this.open( title ); this.reason.setValue( reason ); this.submit.click(); } + // @deprecated Use wdio-mediawiki/Api#delete() instead. apiDelete( name, reason ) { - - const MWBot = require( 'mwbot' ), // https://github.com/Fannon/mwbot - Promise = require( 'bluebird' ); - let bot = new MWBot(); - - return Promise.coroutine( function* () { - yield bot.loginGetEditToken( { - apiUrl: `${browser.options.baseUrl}/api.php`, - username: browser.options.username, - password: browser.options.password - } ); - yield bot.delete( name, reason ); - } ).call( this ); - + return Api.delete( name, reason ); } - } + module.exports = new DeletePage(); diff --git a/tests/selenium/pageobjects/edit.page.js b/tests/selenium/pageobjects/edit.page.js index 33a27f0f8c..8bc7dc635a 100644 --- a/tests/selenium/pageobjects/edit.page.js +++ b/tests/selenium/pageobjects/edit.page.js @@ -1,15 +1,14 @@ -'use strict'; -const Page = require( './page' ); +const Page = require( 'wdio-mediawiki/Page' ), + Api = require( 'wdio-mediawiki/Api' ); class EditPage extends Page { - get content() { return browser.element( '#wpTextbox1' ); } get displayedContent() { return browser.element( '#mw-content-text' ); } get heading() { return browser.element( '#firstHeading' ); } get save() { return browser.element( '#wpSave' ); } - openForEditing( name ) { - super.open( name + '&action=edit' ); + openForEditing( title ) { + super.openTitle( title, { action: 'edit' } ); } edit( name, content ) { @@ -18,22 +17,10 @@ class EditPage extends Page { this.save.click(); } + // @deprecated Use wdio-mediawiki/Api#edit() instead. apiEdit( name, content ) { - - const MWBot = require( 'mwbot' ), // https://github.com/Fannon/mwbot - Promise = require( 'bluebird' ); - let bot = new MWBot(); - - return Promise.coroutine( function* () { - yield bot.loginGetEditToken( { - apiUrl: `${browser.options.baseUrl}/api.php`, - username: browser.options.username, - password: browser.options.password - } ); - yield bot.edit( name, content, `Created page with "${content}"` ); - } ).call( this ); - + return Api.edit( name, content ); } - } + module.exports = new EditPage(); diff --git a/tests/selenium/pageobjects/history.page.js b/tests/selenium/pageobjects/history.page.js index 869484e627..acaf3ea0fa 100644 --- a/tests/selenium/pageobjects/history.page.js +++ b/tests/selenium/pageobjects/history.page.js @@ -1,13 +1,11 @@ -'use strict'; -const Page = require( './page' ); +const Page = require( 'wdio-mediawiki/Page' ); class HistoryPage extends Page { - get comment() { return browser.element( '#pagehistory .comment' ); } - open( name ) { - super.open( name + '&action=history' ); + open( title ) { + super.openTitle( title, { action: 'history' } ); } - } + module.exports = new HistoryPage(); diff --git a/tests/selenium/pageobjects/page.js b/tests/selenium/pageobjects/page.js index 77bb1f4ec7..f159990eae 100644 --- a/tests/selenium/pageobjects/page.js +++ b/tests/selenium/pageobjects/page.js @@ -1,8 +1,12 @@ -// From http://webdriver.io/guide/testrunner/pageobjects.html -'use strict'; -class Page { +const Page = require( 'wdio-mediawiki/Page' ); + +/** + * @deprecated Use wdio-mediawiki/Page and openTitle() instead. + */ +class LegacyPage extends Page { open( path ) { browser.url( browser.options.baseUrl + '/index.php?title=' + path ); } } -module.exports = Page; + +module.exports = LegacyPage; diff --git a/tests/selenium/pageobjects/preferences.page.js b/tests/selenium/pageobjects/preferences.page.js index 98b87fe9cb..64fd58207d 100644 --- a/tests/selenium/pageobjects/preferences.page.js +++ b/tests/selenium/pageobjects/preferences.page.js @@ -1,13 +1,11 @@ -'use strict'; -const Page = require( './page' ); +const Page = require( 'wdio-mediawiki/Page' ); class PreferencesPage extends Page { - get realName() { return browser.element( '#mw-input-wprealname' ); } get save() { return browser.element( '#prefcontrol' ); } open() { - super.open( 'Special:Preferences' ); + super.openTitle( 'Special:Preferences' ); } changeRealName( realName ) { @@ -15,6 +13,6 @@ class PreferencesPage extends Page { this.realName.setValue( realName ); this.save.click(); } - } + module.exports = new PreferencesPage(); diff --git a/tests/selenium/pageobjects/restore.page.js b/tests/selenium/pageobjects/restore.page.js index 071f7f9883..47ad145f65 100644 --- a/tests/selenium/pageobjects/restore.page.js +++ b/tests/selenium/pageobjects/restore.page.js @@ -1,21 +1,19 @@ -'use strict'; -const Page = require( './page' ); +const Page = require( 'wdio-mediawiki/Page' ); class RestorePage extends Page { - get reason() { return browser.element( '#wpComment' ); } get submit() { return browser.element( '#mw-undelete-submit' ); } get displayedContent() { return browser.element( '#mw-content-text' ); } - open( name ) { - super.open( 'Special:Undelete/' + name ); + open( subject ) { + super.openTitle( 'Special:Undelete/' + subject ); } - restore( name, reason ) { - this.open( name ); + restore( subject, reason ) { + this.open( subject ); this.reason.setValue( reason ); this.submit.click(); } - } + module.exports = new RestorePage(); diff --git a/tests/selenium/pageobjects/userlogin.page.js b/tests/selenium/pageobjects/userlogin.page.js index 0061d0c258..971e21bd4e 100644 --- a/tests/selenium/pageobjects/userlogin.page.js +++ b/tests/selenium/pageobjects/userlogin.page.js @@ -1,27 +1,6 @@ -'use strict'; -const Page = require( './page' ); +const LoginPage = require( 'wdio-mediawiki/LoginPage' ); -class UserLoginPage extends Page { - - get username() { return browser.element( '#wpName1' ); } - get password() { return browser.element( '#wpPassword1' ); } - get loginButton() { return browser.element( '#wpLoginAttempt' ); } - get userPage() { return browser.element( '#pt-userpage' ); } - - open() { - super.open( 'Special:UserLogin' ); - } - - login( username, password ) { - this.open(); - this.username.setValue( username ); - this.password.setValue( password ); - this.loginButton.click(); - } - - loginAdmin() { - this.login( browser.options.username, browser.options.password ); - } - -} -module.exports = new UserLoginPage(); +/** + * @deprecated Use wdio-mediawiki/LoginPage instead. + */ +module.exports = LoginPage; diff --git a/tests/selenium/selenium.sh b/tests/selenium/selenium.sh new file mode 100755 index 0000000000..4a5c254839 --- /dev/null +++ b/tests/selenium/selenium.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +# 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 & +# Make sure it is killed to prevent file descriptors leak +function kill_chromedriver() { + killall chromedriver > /dev/null +} +trap kill_chromedriver EXIT +npm run selenium-test diff --git a/tests/selenium/specs/page.js b/tests/selenium/specs/page.js index 376dce5975..a1fd4806b7 100644 --- a/tests/selenium/specs/page.js +++ b/tests/selenium/specs/page.js @@ -1,5 +1,5 @@ -'use strict'; const assert = require( 'assert' ), + Api = require( 'wdio-mediawiki/Api' ), DeletePage = require( '../pageobjects/delete.page' ), RestorePage = require( '../pageobjects/restore.page' ), EditPage = require( '../pageobjects/edit.page' ), @@ -7,7 +7,6 @@ const assert = require( 'assert' ), UserLoginPage = require( '../pageobjects/userlogin.page' ); describe( 'Page', function () { - var content, name; @@ -28,14 +27,12 @@ describe( 'Page', function () { } ); it( 'should be creatable', function () { - // create EditPage.edit( name, content ); // check assert.equal( EditPage.heading.getText(), name ); assert.equal( EditPage.displayedContent.getText(), content ); - } ); it( 'should be re-creatable', function () { @@ -43,12 +40,12 @@ describe( 'Page', function () { // create browser.call( function () { - return EditPage.apiEdit( name, initialContent ); + return Api.edit( name, initialContent ); } ); // delete browser.call( function () { - return DeletePage.apiDelete( name, 'delete prior to recreate' ); + return Api.delete( name, 'delete prior to recreate' ); } ); // create @@ -57,14 +54,12 @@ describe( 'Page', function () { // check assert.equal( EditPage.heading.getText(), name ); assert.equal( EditPage.displayedContent.getText(), content ); - } ); it( 'should be editable', function () { - // create browser.call( function () { - return EditPage.apiEdit( name, content ); + return Api.edit( name, content ); } ); // edit @@ -73,30 +68,26 @@ describe( 'Page', function () { // check assert.equal( EditPage.heading.getText(), name ); assert.equal( EditPage.displayedContent.getText(), content ); - } ); it( 'should have history', function () { - // create browser.call( function () { - return EditPage.apiEdit( name, content ); + return Api.edit( name, content ); } ); // check HistoryPage.open( name ); assert.equal( HistoryPage.comment.getText(), `(Created page with "${content}")` ); - } ); it( 'should be deletable', function () { - // login UserLoginPage.loginAdmin(); // create browser.call( function () { - return EditPage.apiEdit( name, content ); + return Api.edit( name, content ); } ); // delete @@ -107,22 +98,20 @@ describe( 'Page', function () { DeletePage.displayedContent.getText(), '"' + name + '" has been deleted. See deletion log for a record of recent deletions.\nReturn to Main Page.' ); - } ); it( 'should be restorable', function () { - // login UserLoginPage.loginAdmin(); // create browser.call( function () { - return EditPage.apiEdit( name, content ); + return Api.edit( name, content ); } ); // delete browser.call( function () { - return DeletePage.apiDelete( name, content + '-deletereason' ); + return Api.delete( name, content + '-deletereason' ); } ); // restore @@ -130,7 +119,5 @@ describe( 'Page', function () { // check assert.equal( RestorePage.displayedContent.getText(), name + ' has been restored\nConsult the deletion log for a record of recent deletions and restorations.' ); - } ); - } ); diff --git a/tests/selenium/specs/user.js b/tests/selenium/specs/user.js index 3f3872dc7d..10bf05d381 100644 --- a/tests/selenium/specs/user.js +++ b/tests/selenium/specs/user.js @@ -1,11 +1,10 @@ -'use strict'; const assert = require( 'assert' ), CreateAccountPage = require( '../pageobjects/createaccount.page' ), PreferencesPage = require( '../pageobjects/preferences.page' ), - UserLoginPage = require( '../pageobjects/userlogin.page' ); + UserLoginPage = require( 'wdio-mediawiki/LoginPage' ), + Api = require( 'wdio-mediawiki/Api' ); describe( 'User', function () { - var password, username; @@ -22,20 +21,17 @@ describe( 'User', function () { } ); it( 'should be able to create account', function () { - // create CreateAccountPage.createAccount( username, password ); // check assert.equal( CreateAccountPage.heading.getText(), `Welcome, ${username}!` ); - } ); it( 'should be able to log in', function () { - // create browser.call( function () { - return CreateAccountPage.apiCreateAccount( username, password ); + return Api.createAccount( username, password ); } ); // log in @@ -43,16 +39,14 @@ describe( 'User', function () { // check assert.equal( UserLoginPage.userPage.getText(), username ); - } ); it( 'should be able to change preferences', function () { - var realName = Math.random().toString(); // create browser.call( function () { - return CreateAccountPage.apiCreateAccount( username, password ); + return Api.createAccount( username, password ); } ); // log in @@ -63,7 +57,5 @@ describe( 'User', function () { // check assert.equal( PreferencesPage.realName.getValue(), realName ); - } ); - } ); diff --git a/tests/selenium/wdio-mediawiki/.eslintrc.json b/tests/selenium/wdio-mediawiki/.eslintrc.json new file mode 100644 index 0000000000..a49d09603c --- /dev/null +++ b/tests/selenium/wdio-mediawiki/.eslintrc.json @@ -0,0 +1,10 @@ +{ + "extends": "wikimedia", + "env": { + "es6": true, + "node": true + }, + "globals": { + "browser": false + } +} diff --git a/tests/selenium/wdio-mediawiki/Api.js b/tests/selenium/wdio-mediawiki/Api.js new file mode 100644 index 0000000000..40bce32edc --- /dev/null +++ b/tests/selenium/wdio-mediawiki/Api.js @@ -0,0 +1,77 @@ +const MWBot = require( 'mwbot' ); + +// TODO: Once we require Node 7 or later, we can use async-await. + +module.exports = { + /** + * Shortcut for `MWBot#edit( .. )`. + * + * @since 1.0.0 + * @see + * @param {string} title + * @param {string} content + * @return {Object} Promise for API action=edit response data. + */ + edit( title, content ) { + let bot = new MWBot(); + + return bot.loginGetEditToken( { + apiUrl: `${browser.options.baseUrl}/api.php`, + username: browser.options.username, + password: browser.options.password + } ).then( function () { + return bot.edit( title, content, `Created page with "${content}"` ); + } ); + }, + + /** + * Shortcut for `MWBot#delete( .. )`. + * + * @since 1.0.0 + * @see + * @param {string} title + * @param {string} reason + * @return {Object} Promise for API action=delete response data. + */ + delete( title, reason ) { + let bot = new MWBot(); + + return bot.loginGetEditToken( { + apiUrl: `${browser.options.baseUrl}/api.php`, + username: browser.options.username, + password: browser.options.password + } ).then( function () { + return bot.delete( title, reason ); + } ); + }, + + /** + * Shortcut for `MWBot#request( { acount: 'createaccount', .. } )`. + * + * @since 1.0.0 + * @see + * @param {string} username + * @param {string} password + * @return {Object} Promise for API action=createaccount response data. + */ + createAccount( username, password ) { + let bot = new MWBot(); + + // Log in as admin + return bot.loginGetCreateaccountToken( { + apiUrl: `${browser.options.baseUrl}/api.php`, + username: browser.options.username, + password: browser.options.password + } ).then( function () { + // Create the new account + return bot.request( { + action: 'createaccount', + createreturnurl: browser.options.baseUrl, + createtoken: bot.createaccountToken, + username: username, + password: password, + retype: password + } ); + } ); + } +}; diff --git a/tests/selenium/wdio-mediawiki/BlankPage.js b/tests/selenium/wdio-mediawiki/BlankPage.js new file mode 100644 index 0000000000..ed99bd4fdf --- /dev/null +++ b/tests/selenium/wdio-mediawiki/BlankPage.js @@ -0,0 +1,11 @@ +const Page = require( 'wdio-mediawiki/Page' ); + +class BlankPage extends Page { + get heading() { return browser.element( '#firstHeading' ); } + + open() { + super.openTitle( 'Special:BlankPage', { uselang: 'en' } ); + } +} + +module.exports = new BlankPage(); diff --git a/tests/selenium/wdio-mediawiki/CHANGELOG.md b/tests/selenium/wdio-mediawiki/CHANGELOG.md new file mode 100644 index 0000000000..bfce387b6b --- /dev/null +++ b/tests/selenium/wdio-mediawiki/CHANGELOG.md @@ -0,0 +1,8 @@ +# Notable changes + +## [Unreleased] + +* Api: Added initial version. +* Page: Added initial version. +* BlankPage: Added initial version. +* LoginPage: Added initial version. diff --git a/tests/selenium/wdio-mediawiki/LICENSE b/tests/selenium/wdio-mediawiki/LICENSE new file mode 100644 index 0000000000..ad55501c82 --- /dev/null +++ b/tests/selenium/wdio-mediawiki/LICENSE @@ -0,0 +1,21 @@ +Copyright 2018 Željko Filipin +Copyright 2018 Timo Tijhof + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/tests/selenium/wdio-mediawiki/LoginPage.js b/tests/selenium/wdio-mediawiki/LoginPage.js new file mode 100644 index 0000000000..d07934b6fe --- /dev/null +++ b/tests/selenium/wdio-mediawiki/LoginPage.js @@ -0,0 +1,25 @@ +const Page = require( 'wdio-mediawiki/Page' ); + +class LoginPage extends Page { + get username() { return browser.element( '#wpName1' ); } + get password() { return browser.element( '#wpPassword1' ); } + get loginButton() { return browser.element( '#wpLoginAttempt' ); } + get userPage() { return browser.element( '#pt-userpage' ); } + + open() { + super.openTitle( 'Special:UserLogin' ); + } + + login( username, password ) { + this.open(); + this.username.setValue( username ); + this.password.setValue( password ); + this.loginButton.click(); + } + + loginAdmin() { + this.login( browser.options.username, browser.options.password ); + } +} + +module.exports = new LoginPage(); diff --git a/tests/selenium/wdio-mediawiki/Page.js b/tests/selenium/wdio-mediawiki/Page.js new file mode 100644 index 0000000000..48620e6816 --- /dev/null +++ b/tests/selenium/wdio-mediawiki/Page.js @@ -0,0 +1,23 @@ +const querystring = require( 'querystring' ); + +/** + * Based on http://webdriver.io/guide/testrunner/pageobjects.html + */ +class Page { + + /** + * Navigate the browser to a given page. + * + * @since 1.0.0 + * @see + * @param {string} title Page title + * @param {Object} [query] Query parameter + * @return {void} This method runs a browser command. + */ + openTitle( title, query = {} ) { + query.title = title; + browser.url( browser.options.baseUrl + '/index.php?' + querystring.stringify( query ) ); + } +} + +module.exports = Page; diff --git a/tests/selenium/wdio-mediawiki/README.md b/tests/selenium/wdio-mediawiki/README.md new file mode 100644 index 0000000000..260dc77667 --- /dev/null +++ b/tests/selenium/wdio-mediawiki/README.md @@ -0,0 +1,53 @@ +# wdio-mediawiki + +A plugin for [WebdriverIO](http://webdriver.io/) providing utilities to simplify testing of MediaWiki features. + +## Getting Started + +### Page + +The `Page` class is a base class for following the [Page Objects Pattern](http://webdriver.io/guide/testrunner/pageobjects.html). + +* `openTitle( title [, Object query ] )` + +The convention is for implementations to extend this class and provide an `open()` method +that calls `super.openTitle()`, as well as add various getters for elements on the page. + +See [BlankPage](./BlankPage.js) and [specs/BlankPage](./specs/BlankPage.js) for an example. + +### Api + +Utilities to interact with the MediaWiki API. Uses the [mwbot](https://github.com/Fannon/mwbot) library. + +Actions are performed logged-in using `browser.options.username` and `browser.options.password`, +which typically come from `MEDIAWIKI_USER` and `MEDIAWIKI_PASSWORD` environment variables. + +* `edit(title, content)` +* `delete(title, reason)` +* `createAccount(username, password)` + +## Versioning + +This package follows [Semantic Versioning guidelines](https://semver.org/) for its releases. In +particular, its major version must be bumped when compatibility is removed for a previous of +MediaWiki. + +It is the expectation that this module will only support a single version of MediaWiki at any +given time, and that tests in older branches of MediaWiki-related projects naturally use the older +release line of this package. + +In order to allow for smooth and decentralised upgrades, it is recommended that the only type of +breaking change made to this package is a change that removes something. Thus, in order to change +something, it must either be backwards-compatible, or must be introduced as a new method that +co-exists with its deprecated equivalent for at least one release. + +## Issue tracker + +Please report issues to [Phabricator](https://phabricator.wikimedia.org/tag/mediawiki-core-tests/). + +## Contributing + +This module is maintained in the MediaWiki core repository and published from there as a +package to npmjs.org. To simplify development and to ensure changes are verified +automatically, MediaWiki core itself uses this module directly from the working copy +using [npm Local Paths](https://docs.npmjs.com/files/package.json#local-paths). diff --git a/tests/selenium/wdio-mediawiki/index.js b/tests/selenium/wdio-mediawiki/index.js new file mode 100644 index 0000000000..d3171be1d7 --- /dev/null +++ b/tests/selenium/wdio-mediawiki/index.js @@ -0,0 +1,26 @@ +const fs = require( 'fs' ); + +module.exports = { + /** + * Based on + * + * @since 1.0.0 + * @param {string} title Description (will be sanitised and used as file name) + * @return {string} File path + */ + saveScreenshot( title ) { + var filename, filePath; + // Create sane file name for current test title + filename = encodeURIComponent( title.replace( /\s+/g, '-' ) ); + filePath = `${browser.options.screenshotPath}/${filename}.png`; + // Ensure directory exists, based on WebDriverIO#saveScreenshotSync() + try { + fs.statSync( browser.options.screenshotPath ); + } catch ( err ) { + fs.mkdirSync( browser.options.screenshotPath ); + } + // Create and save screenshot + browser.saveScreenshot( filePath ); + return filePath; + } +}; diff --git a/tests/selenium/wdio-mediawiki/package.json b/tests/selenium/wdio-mediawiki/package.json new file mode 100644 index 0000000000..be7ed33ca7 --- /dev/null +++ b/tests/selenium/wdio-mediawiki/package.json @@ -0,0 +1,21 @@ +{ + "name": "wdio-mediawiki", + "version": "0.1.0", + "description": "WebdriverIO plugin for testing a MediaWiki site.", + "homepage": "https://gerrit.wikimedia.org/g/mediawiki/core/+/master/tests/selenium/wdio-mediawiki/", + "license": "MIT", + "keywords": [ + "mediawiki", + "wdio-plugin" + ], + "files": [ + "*.js", + "specs/" + ], + "engines": { + "node" : ">=6.0" + }, + "dependencies": { + "mwbot": "1.0.10" + } +} diff --git a/tests/selenium/wdio-mediawiki/specs/BlankPage.js b/tests/selenium/wdio-mediawiki/specs/BlankPage.js new file mode 100644 index 0000000000..f84ae90443 --- /dev/null +++ b/tests/selenium/wdio-mediawiki/specs/BlankPage.js @@ -0,0 +1,11 @@ +const assert = require( 'assert' ), + BlankPage = require( 'wdio-mediawiki/BlankPage' ); + +describe( 'BlankPage', function () { + it( 'should have its title', function () { + BlankPage.open(); + + // check + assert.equal( BlankPage.heading.getText(), 'Blank page' ); + } ); +} ); diff --git a/tests/selenium/wdio.conf.jenkins.js b/tests/selenium/wdio.conf.jenkins.js deleted file mode 100644 index de2b738e28..0000000000 --- a/tests/selenium/wdio.conf.jenkins.js +++ /dev/null @@ -1,26 +0,0 @@ -'use strict'; -const merge = require( 'deepmerge' ), - password = 'testpass', - username = 'WikiAdmin', - wdioConf = require( './wdio.conf.js' ); - -// Overwrite default settings -exports.config = merge( wdioConf.config, { - username: process.env.MEDIAWIKI_USER === undefined ? - username : - process.env.MEDIAWIKI_USER, - password: process.env.MEDIAWIKI_PASSWORD === undefined ? - password : - process.env.MEDIAWIKI_PASSWORD, - screenshotPath: '../log/', - baseUrl: process.env.MW_SERVER + process.env.MW_SCRIPT_PATH, - exclude: [ - './extensions/CirrusSearch/tests/selenium/specs/**/*.js' - ], - reporters: [ 'spec', 'junit' ], - reporterOptions: { - junit: { - outputDir: '../log/' - } - } -} ); diff --git a/tests/selenium/wdio.conf.js b/tests/selenium/wdio.conf.js index 73e6bb9f0c..f785d36bdb 100644 --- a/tests/selenium/wdio.conf.js +++ b/tests/selenium/wdio.conf.js @@ -1,8 +1,7 @@ -'use strict'; - -const password = 'vagrant', +const fs = require( 'fs' ), path = require( 'path' ), - username = 'Admin'; + saveScreenshot = require( 'wdio-mediawiki' ).saveScreenshot, + logPath = process.env.LOG_DIR || __dirname + '/log'; function relPath( foo ) { return path.resolve( __dirname, '../..', foo ); @@ -10,295 +9,141 @@ function relPath( foo ) { exports.config = { // ====== - // Custom + // Custom WDIO config specific to MediaWiki // ====== - // Define any custom variables. - // Example: - // username: 'Admin', - // Use if from tests with: - // browser.options.username - username: process.env.MEDIAWIKI_USER === undefined ? - username : - process.env.MEDIAWIKI_USER, - password: process.env.MEDIAWIKI_PASSWORD === undefined ? - password : - process.env.MEDIAWIKI_PASSWORD, - // + // Use in a test as `browser.options.`. + // Defaults are for convenience with MediaWiki-Vagrant + + // Wiki admin + username: process.env.MEDIAWIKI_USER || 'Admin', + password: process.env.MEDIAWIKI_PASSWORD || 'vagrant', + + // Base for browser.url() and Page#openTitle() + baseUrl: ( process.env.MW_SERVER || 'http://127.0.0.1:8080' ) + ( + process.env.MW_SCRIPT_PATH || '/w' + ), + // ====== // Sauce Labs // ====== - // + // See http://webdriver.io/guide/services/sauce.html + // and https://docs.saucelabs.com/reference/platforms-configurator services: [ 'sauce' ], user: process.env.SAUCE_USERNAME, key: process.env.SAUCE_ACCESS_KEY, - // + + // Default timeout in milliseconds for Selenium Grid requests + connectionRetryTimeout: 90 * 1000, + + // Default request retries count + connectionRetryCount: 3, + // ================== - // Specify Test Files + // Test Files // ================== - // Define which test specs should run. The pattern is relative to the directory - // from which `wdio` was called. Notice that, if you are calling `wdio` from an - // NPM script (see https://docs.npmjs.com/cli/run-script) then the current working - // directory is where your package.json resides, so `wdio` will be called from there. - // specs: [ + relPath( './tests/selenium/wdio-mediawiki/specs/*.js' ), relPath( './tests/selenium/specs/**/*.js' ), relPath( './extensions/*/tests/selenium/specs/**/*.js' ), relPath( './extensions/VisualEditor/modules/ve-mw/tests/selenium/specs/**/*.js' ), relPath( './skins/*/tests/selenium/specs/**/*.js' ) ], - // Patterns to exclude. + // Patterns to exclude exclude: [ - // 'path/to/excluded/files' + relPath( './extensions/CirrusSearch/tests/selenium/specs/**/*.js' ) ], - // + // ============ // Capabilities // ============ - // Define your capabilities here. WebdriverIO can run multiple capabilities at the same - // time. Depending on the number of capabilities, WebdriverIO launches several test - // sessions. Within your capabilities you can overwrite the spec and exclude options in - // order to group specific specs to a specific capability. - // - // First, you can define how many instances should be started at the same time. Let's - // say you have 3 different capabilities (Chrome, Firefox, and Safari) and you have - // set maxInstances to 1; wdio will spawn 3 processes. Therefore, if you have 10 spec - // files and you set maxInstances to 10, all spec files will get tested at the same time - // and 30 processes will get spawned. The property handles how many capabilities - // from the same test should run tests. - // + + // How many instances of the same capability (browser) may be started at the same time. maxInstances: 1, - // - // If you have trouble getting all important capabilities together, check out the - // Sauce Labs platform configurator - a great tool to configure your capabilities: - // https://docs.saucelabs.com/reference/platforms-configurator - // - // For Chrome/Chromium https://sites.google.com/a/chromium.org/chromedriver/capabilities + capabilities: [ { - // maxInstances can get overwritten per capability. So if you have an in-house Selenium - // grid with only 5 firefox instances available you can make sure that not more than - // 5 instances get started at a time. - maxInstances: 1, - // + // For Chrome/Chromium https://sites.google.com/a/chromium.org/chromedriver/capabilities browserName: 'chrome', - // Since Chrome v57 https://bugs.chromium.org/p/chromedriver/issues/detail?id=1625 + maxInstances: 1, chromeOptions: { - args: [ '--enable-automation' ] + // If DISPLAY is set, assume developer asked non-headless or CI with Xvfb. + // Otherwise, use --headless (added in Chrome 59) + // https://chromium.googlesource.com/chromium/src/+/59.0.3030.0/headless/README.md + args: [ + ...( process.env.DISPLAY ? [] : [ '--headless' ] ), + // Chrome sandbox does not work in Docker + ...( fs.existsSync( '/.dockerenv' ) ? [ '--no-sandbox' ] : [] ) + ] } } ], - // + // =================== // Test Configurations // =================== - // Define all options that are relevant for the WebdriverIO instance here + + // Enabling synchronous mode (via the wdio-sync package), means specs don't have to + // use Promise#then() or await for browser commands, such as like `brower.element()`. + // Instead, it will automatically pause JavaScript execution until th command finishes. // - // By default WebdriverIO commands are executed in a synchronous way using - // the wdio-sync package. If you still want to run your tests in an async way - // e.g. using promises you can set the sync option to false. + // For non-browser commands (such as MWBot and other promises), this means you + // have to use `browser.call()` to make sure WDIO waits for it before the next + // browser command. sync: true, - // + // Level of logging verbosity: silent | verbose | command | data | result | error logLevel: 'error', - // + // Enables colors for log output. coloredLogs: true, - // + // Warns when a deprecated command is used deprecationWarnings: true, - // - // If you only want to run your tests until a specific amount of tests have failed use - // bail (default is 0 - don't bail, run all tests). + + // Stop the tests once a certain number of failed tests have been recorded. + // Default is 0 - don't bail, run all tests. bail: 0, - // - // Saves a screenshot to a given path if a command fails. - screenshotPath: './log/', - // - // Set a base URL in order to shorten url command calls. If your `url` parameter starts - // with `/`, the base url gets prepended, not including the path portion of your baseUrl. - // If your `url` parameter starts without a scheme or `/` (like `some/path`), the base url - // gets prepended directly. - baseUrl: ( - process.env.MW_SERVER === undefined ? - 'http://127.0.0.1:8080' : - process.env.MW_SERVER - ) + ( - process.env.MW_SCRIPT_PATH === undefined ? - '/w' : - process.env.MW_SCRIPT_PATH - ), - // - // Default timeout for all waitFor* commands. - waitforTimeout: 20000, - // - // Default timeout in milliseconds for request - // if Selenium Grid doesn't send response - connectionRetryTimeout: 90000, - // - // Default request retries count - connectionRetryCount: 3, - // - // Initialize the browser instance with a WebdriverIO plugin. The object should have the - // plugin name as key and the desired plugin options as properties. Make sure you have - // the plugin installed before running any tests. The following plugins are currently - // available: - // WebdriverCSS: https://github.com/webdriverio/webdrivercss - // WebdriverRTC: https://github.com/webdriverio/webdriverrtc - // Browserevent: https://github.com/webdriverio/browserevent - // plugins: { - // webdrivercss: { - // screenshotRoot: 'my-shots', - // failedComparisonsRoot: 'diffs', - // misMatchTolerance: 0.05, - // screenWidth: [320,480,640,1024] - // }, - // webdriverrtc: {}, - // browserevent: {} - // }, - // - // Test runner services - // Services take over a specific job you don't want to take care of. They enhance - // your test setup with almost no effort. Unlike plugins, they don't add new - // commands. Instead, they hook themselves up into the test process. - // services: [],// + + // Setting this enables automatic screenshots for when a browser command fails + // It is also used by afterTest for capturig failed assertions. + screenshotPath: logPath, + + // Default timeout for each waitFor* command. + waitforTimeout: 10 * 1000, + // Framework you want to run your specs with. - // The following are supported: Mocha, Jasmine, and Cucumber - // see also: http://webdriver.io/guide/testrunner/frameworks.html - // - // Make sure you have the wdio adapter package for the specific framework installed - // before running any tests. + // See also: http://webdriver.io/guide/testrunner/frameworks.html framework: 'mocha', - // + // Test reporter for stdout. - // The only one supported by default is 'dot' - // see also: http://webdriver.io/guide/testrunner/reporters.html - reporters: [ 'spec' ], - // + // See also: http://webdriver.io/guide/testrunner/reporters.html + reporters: [ 'spec', 'junit' ], + reporterOptions: { + junit: { + outputDir: logPath + } + }, + // Options to be passed to Mocha. // See the full list at http://mochajs.org/ mochaOpts: { ui: 'bdd', - timeout: 20000 + timeout: 60 * 1000 }, - // + // ===== // Hooks // ===== - // WebdriverIO provides several hooks you can use to interfere with the test process in order to enhance - // it and to build services around it. You can either apply a single function or an array of - // methods to it. If one of them returns with a promise, WebdriverIO will wait until that promise got - // resolved to continue. - /** - * Gets executed once before all workers get launched. - * @param {Object} config wdio configuration object - * @param {Array.} capabilities list of capabilities details - */ - // onPrepare: function (config, capabilities) { - // }, - /** - * Gets executed just before initialising the webdriver session and test framework. It allows you - * to manipulate configurations depending on the capability or spec. - * @param {Object} config wdio configuration object - * @param {Array.} capabilities list of capabilities details - * @param {Array.} specs List of spec file paths that are to be run - */ - // beforeSession: function (config, capabilities, specs) { - // }, - /** - * Gets executed before test execution begins. At this point you can access to all global - * variables like `browser`. It is the perfect place to define custom commands. - * @param {Array.} capabilities list of capabilities details - * @param {Array.} specs List of spec file paths that are to be run - */ - // before: function (capabilities, specs) { - // }, - /** - * Runs before a WebdriverIO command gets executed. - * @param {String} commandName hook command name - * @param {Array} args arguments that command would receive - */ - // beforeCommand: function (commandName, args) { - // }, - /** - * Hook that gets executed before the suite starts - * @param {Object} suite suite details - */ - // beforeSuite: function (suite) { - // }, - /** - * Function to be executed before a test (in Mocha/Jasmine) or a step (in Cucumber) starts. - * @param {Object} test test details - */ - // beforeTest: function (test) { - // }, - /** - * Hook that gets executed _before_ a hook within the suite starts (e.g. runs before calling - * beforeEach in Mocha) - */ - // beforeHook: function () { - // }, - /** - * Hook that gets executed _after_ a hook within the suite ends (e.g. runs after calling - * afterEach in Mocha) - */ - // afterHook: function () { - // }, + // See also: http://webdriver.io/guide/testrunner/configurationfile.html + /** - * Function to be executed after a test (in Mocha/Jasmine) or a step (in Cucumber) ends. - * @param {Object} test test details - */ - // from https://github.com/webdriverio/webdriverio/issues/269#issuecomment-306342170 + * Save a screenshot when test fails. + * + * @param {Object} test Mocha Test object + */ afterTest: function ( test ) { - var filename, filePath; - // if test passed, ignore, else take and save screenshot - if ( test.passed ) { - return; + var filePath; + if ( !test.passed ) { + filePath = saveScreenshot( test.title ); + console.log( '\n\tScreenshot: ' + filePath + '\n' ); } - // get current test title and clean it, to use it as file name - filename = encodeURIComponent( test.title.replace( /\s+/g, '-' ) ); - // build file path - filePath = this.screenshotPath + filename + '.png'; - // save screenshot - browser.saveScreenshot( filePath ); - console.log( '\n\tScreenshot location:', filePath, '\n' ); } - // - /** - * Hook that gets executed after the suite has ended - * @param {Object} suite suite details - */ - // afterSuite: function (suite) { - // }, - /** - * Runs after a WebdriverIO command gets executed - * @param {String} commandName hook command name - * @param {Array} args arguments that command would receive - * @param {Number} result 0 - command success, 1 - command error - * @param {Object} error error object if any - */ - // afterCommand: function (commandName, args, result, error) { - // }, - /** - * Gets executed after all tests are done. You still have access to all global variables from - * the test. - * @param {Number} result 0 - test pass, 1 - test fail - * @param {Array.} capabilities list of capabilities details - * @param {Array.} specs List of spec file paths that ran - */ - // after: function (result, capabilities, specs) { - // }, - /** - * Gets executed right after terminating the webdriver session. - * @param {Object} config wdio configuration object - * @param {Array.} capabilities list of capabilities details - * @param {Array.} specs List of spec file paths that ran - */ - // afterSession: function (config, capabilities, specs) { - // }, - /** - * Gets executed after all workers got shut down and the process is about to exit. - * @param {Object} exitCode 0 - success, 1 - fail - * @param {Object} config wdio configuration object - * @param {Array.} capabilities list of capabilities details - */ - // onComplete: function(exitCode, config, capabilities) { - // } };