From: jenkins-bot Date: Thu, 17 May 2018 18:50:45 +0000 (+0000) Subject: Merge "resourceloader: Make various CSSMin performance optimizations and cleanups" X-Git-Tag: 1.34.0-rc.0~5408 X-Git-Url: http://git.cyclocoop.org/data/Luca_Pacioli_%28Gemaelde%29.jpeg?a=commitdiff_plain;h=fd3f586dbbbb556aa19c0a53c7e8cb1aa93fa164;hp=ff84fbe8a0d7ddfd535bb38d11ede04b9fad039e;p=lhc%2Fweb%2Fwiklou.git Merge "resourceloader: Make various CSSMin performance optimizations and cleanups" --- 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/.phpcs.xml b/.phpcs.xml index 31e6eebc71..d43a2814cf 100644 --- a/.phpcs.xml +++ b/.phpcs.xml @@ -17,7 +17,6 @@ - diff --git a/Gruntfile.js b/Gruntfile.js index 69a123cc9f..3687d2805e 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -13,7 +13,6 @@ 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, @@ -29,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/**', @@ -37,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: { @@ -104,15 +103,7 @@ module.exports = function ( grunt ) { return require( 'path' ).join( dest, src.replace( 'resources/', '' ) ); } } - }, - - // Configure WebdriverIO task - webdriver: { - test: { - configFile: './tests/selenium/wdio.conf.js' - } } - } ); grunt.registerTask( 'assert-mw-env', function () { 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 9bdc32dd6c..a702451321 100644 --- a/RELEASE-NOTES-1.31 +++ b/RELEASE-NOTES-1.31 @@ -9,45 +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'. -* (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 also set $wgUseTidy to true. +* (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 was removed (deprecated and ignored since 1.25). - See 1.25 release notes for more information. +* $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 @@ -73,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(), @@ -86,21 +91,21 @@ production. 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 - +* 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. @@ -108,11 +113,10 @@ production. * Updated mediawiki/at-ease from 1.1.0 to 1.2.0. * Updated wikimedia/php-session-serializer from 1.0.4 to 1.0.6. * Updated wikimedia/remex-html from 1.0.2 to 1.0.3. -* … +* Updated wikimedia/html-formatter from 1.0.1 to 1.0.2. ==== New external libraries ==== * Added wikimedia/object-factory 1.0.0 -* … ==== Removed and replaced external libraries ==== * (T17845) The deprecated 'jquery.badge' module was removed. @@ -121,51 +125,54 @@ 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 +* 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 was removed (deprecated since 1.25) -* ApiBase::getModuleProfileName was removed (deprecated since 1.25) -* ApiBase::getProfileTime was removed (deprecated since 1.25) +* 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). === 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. +* 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 @@ -173,8 +180,8 @@ changes to languages because of Phabricator reports. * 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. +* The following methods and constants from the WatchedItem class, which were + deprecated in 1.27, have been removed: * WatchedItem::getTitle() * WatchedItem::fromUserTitle() * WatchedItem::addWatch() @@ -185,22 +192,24 @@ changes to languages because of Phabricator reports. * 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. + $wgResourceLoaderMinifierStatementsOnOwnLine, the corresponding configuration + variable, 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 + $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: +* The following properties of PreparedEdit were deprecated in 1.21 and have + been removed: * PreparedEdit->newText * PreparedEdit->oldText * PreparedEdit->pst -* ParserOutput objects generated using a non-default value for - ParserOptions::setWrapOutputClass() can no longer be added to the parser cache. +* 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 @@ -208,69 +217,78 @@ changes to languages because of Phabricator reports. * 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. + * 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: +* 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. -* 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: + * $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 +* 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. +* 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. +* 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 + 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. +* 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 its callee TidyDriverBase::validate() are removed, as are - MediaWikiTestCase::assertValidHtmlSnippet() and ::assertValidHtmlDocument(). The - $wgValidateAllHtml configuration option is removed and will be ignored. -* Execution of external programs using MediaWiki\Shell\Command now applies RESTRICT_DEFAULT - Firejail restriction by default. +* 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. +* 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. @@ -299,9 +317,9 @@ changes to languages because of Phabricator reports. * Use of Maintenance::error( $err, $die ) to exit script was deprecated. Use Maintenance::fatalError() instead. * Passing a ParserOptions object to OutputPage::parserOptions() is deprecated. -* The RevisionInsertComplete hook is now deprecated, use 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. @@ -313,32 +331,39 @@ 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 public ParserOutput state fields $mTOCEnabled and $mEditSectionTokens + are also deprecated. * License::getLicenses has been deprecated; use License::getLines instead. * QuickTemplate::setRef() was deprecated in favour of QuickTemplate::set(). - Setting template variables by reference allowed violating the principle of data being - immutable once added to the skin template. In practice, this method was not being - used for that. Rather, setRef() existed as memory optimisation for PHP 4. -* QuickTemplate::setTranslator() 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. -* \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 methods related to caching of half-parsed HTML were deprecated: @@ -355,22 +380,23 @@ changes to languages because of Phabricator reports. 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 removed. Opera 15+ continues at Grade A. -* Introducing multi-content-revision capability into the storage layer. For details, - 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". +* 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. + 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 diff --git a/RELEASE-NOTES-1.32 b/RELEASE-NOTES-1.32 index 7971bccdf1..c06ba9143a 100644 --- a/RELEASE-NOTES-1.32 +++ b/RELEASE-NOTES-1.32 @@ -6,19 +6,32 @@ 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 is now formally deprecated, and MediaWiki will act as if it is always set. +* (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. +* (T135963) You can now define a Content Security Policy for your wiki. This + adds a defense-in-depth feature to stop an attacker who has found a bug in + the parser allowing them to insert malicious attributes. Disabled by default, + you can configure this via $wgCSPHeader and $wgCSPReportOnlyHeader. === 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 ==== * … @@ -30,37 +43,80 @@ production. * … === Action API changes in 1.32 === -* … +* Added templated parameters. + * A module can define a templated parameter like "{fruit}-quantity", where + the actual parameters recognized correspond to the values of a multi-valued + parameter. Then clients can make requests like + "fruits=apples|bananas&apples-quantity=1&bananas-quantity=5". + * action=paraminfo will return templated parameter definitions separately + from normal parameters. All parameter definitions now include an "index" + key to allow clients to maintain parameter ordering when merging normal and + templated parameters. === Action API internal changes in 1.32 === -* … +* Added 'ApiParseMakeOutputPage' hook. +* Parameter names may no longer contain '{' or '}', as these are now used for + templated parameters. === 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. +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 was removed (deprecated in 1.25). - Use $_SERVER['REQUEST_TIME_FLOAT'] or WebRequest::getElapsedTime() instead. -* The MediaWikiI18N class was removed (deprecated in 1.31). -* QuickTemplate::setTranslator() was removed (deprecated in 1.31). - Use Skin::msg() instead. -* wfInitShellLocale() was removed (deprecated in 1.30). -* wfShellExecDisabled() was removed (deprecated in 1.30). +* $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. +* Overriding SearchEngine::{searchText,searchTitle,searchArchiveTitle} + in extending classes is deprecated. Extend related doSearch* methods + 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. +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 diff --git a/autoload.php b/autoload.php index bc0e69e718..c55b931235 100644 --- a/autoload.php +++ b/autoload.php @@ -201,15 +201,15 @@ $wgAutoloadLocalClasses = [ 'BenchmarkSanitizer' => __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', @@ -303,6 +304,7 @@ $wgAutoloadLocalClasses = [ 'Content' => __DIR__ . '/includes/content/Content.php', 'ContentHandler' => __DIR__ . '/includes/content/ContentHandler.php', 'ContentModelLogFormatter' => __DIR__ . '/includes/logging/ContentModelLogFormatter.php', + 'ContentSecurityPolicy' => __DIR__ . '/includes/ContentSecurityPolicy.php', 'ContextSource' => __DIR__ . '/includes/context/ContextSource.php', 'ContribsPager' => __DIR__ . '/includes/specials/pagers/ContribsPager.php', 'ConvertExtensionToRegistration' => __DIR__ . '/maintenance/convertExtensionToRegistration.php', @@ -359,6 +361,7 @@ $wgAutoloadLocalClasses = [ 'DateFormats' => __DIR__ . '/maintenance/language/date-formats.php', 'DateFormatter' => __DIR__ . '/includes/parser/DateFormatter.php', 'DeadendPagesPage' => __DIR__ . '/includes/specials/SpecialDeadendpages.php', + 'DeduplicateArchiveRevId' => __DIR__ . '/maintenance/deduplicateArchiveRevId.php', 'DeferrableCallback' => __DIR__ . '/includes/deferred/DeferrableCallback.php', 'DeferrableUpdate' => __DIR__ . '/includes/deferred/DeferrableUpdate.php', 'DeferredStringifier' => __DIR__ . '/includes/libs/DeferredStringifier.php', @@ -396,7 +399,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,7 +455,7 @@ $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', @@ -537,7 +540,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', @@ -566,6 +569,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', @@ -698,7 +702,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', @@ -964,19 +968,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 +990,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 +1094,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', @@ -1164,6 +1162,7 @@ $wgAutoloadLocalClasses = [ 'PopulateBacklinkNamespace' => __DIR__ . '/maintenance/populateBacklinkNamespace.php', 'PopulateCategory' => __DIR__ . '/maintenance/populateCategory.php', 'PopulateContentModel' => __DIR__ . '/maintenance/populateContentModel.php', + 'PopulateExternallinksIndex60' => __DIR__ . '/maintenance/populateExternallinksIndex60.php', 'PopulateFilearchiveSha1' => __DIR__ . '/maintenance/populateFilearchiveSha1.php', 'PopulateImageSha1' => __DIR__ . '/maintenance/populateImageSha1.php', 'PopulateInterwiki' => __DIR__ . '/maintenance/populateInterwiki.php', @@ -1180,6 +1179,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 +1283,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 +1481,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 +1504,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 +1530,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 +1660,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', diff --git a/composer.json b/composer.json index 98e4d49940..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,7 +25,7 @@ "ext-mbstring": "*", "ext-xml": "*", "liuggio/statsd-php-client": "1.0.18", - "oojs/oojs-ui": "v0.26.4", + "oojs/oojs-ui": "0.27.0", "oyejorge/less.php": "1.7.0.14", "php": ">=5.5.9", "psr/log": "1.0.2", @@ -34,7 +35,7 @@ "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.6", 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/hooks.txt b/docs/hooks.txt index d932148e4d..9404e1448d 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 @@ -1180,6 +1186,31 @@ $lossy: boolean indicating whether lossy conversion is allowed. converted Content object. Note that $result->getContentModel() must return $toModel. +'ContentSecurityPolicyDefaultSource': Modify the allowed CSP load sources. This affects all +directives except for the script directive. If you want to add a script +source, see ContentSecurityPolicyScriptSource hook. +&$defaultSrc: Array of Content-Security-Policy allowed sources +$policyConfig: Current configuration for the Content-Security-Policy header +$mode: ContentSecurityPolicy::REPORT_ONLY_MODE or ContentSecurityPolicy::FULL_MODE + depending on type of header + +'ContentSecurityPolicyDirectives': Modify the content security policy directives. +Use this only if ContentSecurityPolicyDefaultSource and +ContentSecurityPolicyScriptSource do not meet your needs. +&$directives: Array of CSP directives +$policyConfig: Current configuration for the CSP header +$mode: ContentSecurityPolicy::REPORT_ONLY_MODE or + ContentSecurityPolicy::FULL_MODE depending on type of header + +'ContentSecurityPolicyScriptSource': Modify the allowed CSP script sources. +Note that you also have to use ContentSecurityPolicyDefaultSource if you +want non-script sources to be loaded from +whatever you add. +&$scriptSrc: Array of CSP directives +$policyConfig: Current configuration for the CSP header +$mode: ContentSecurityPolicy::REPORT_ONLY_MODE or ContentSecurityPolicy::FULL_MODE + depending on type of header + 'CustomEditor': When invoking the page editor Return true to allow the normal editor to be used, or false if implementing a custom editor, e.g. for a special namespace, etc. 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/ContentSecurityPolicy.php b/includes/ContentSecurityPolicy.php new file mode 100644 index 0000000000..21d7d57dcd --- /dev/null +++ b/includes/ContentSecurityPolicy.php @@ -0,0 +1,527 @@ +nonce = $nonce; + $this->response = $response; + $this->mwConfig = $mwConfig; + } + + /** + * Send a single CSP header based on a given policy config. + * + * @note Most callers will probably want ContentSecurityPolicy::sendHeaders() instead. + * @param array $csp ContentSecurityPolicy configuration + * @param int $reportOnly self::*_MODE constant + */ + public function sendCSPHeader( $csp, $reportOnly ) { + $policy = $this->makeCSPDirectives( $csp, $reportOnly ); + $headerName = $this->getHeaderName( $reportOnly ); + if ( $policy ) { + $this->response->header( + "$headerName: $policy" + ); + } + } + + /** + * Return the meta header to use for after load restricted mode + * + * This should restrict browsers that don't support nonce-sources. + * Idea stolen from + * https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/ + * + * @param array $csp CSP configuration + * @return string Content for meta tag + */ + public function getMetaHeader( $csp ) { + return $this->makeCSPDirectives( $csp, self::FULL_MODE_RESTRICTED ); + } + + /** + * Send CSP headers based on wiki config + * + * Main method that callers are expected to use + * @param IContextSource $context A context object, the associated OutputPage + * object must be the one that the page in question was generated with. + */ + public static function sendHeaders( IContextSource $context ) { + $out = $context->getOutput(); + $csp = new ContentSecurityPolicy( + $out->getCSPNonce(), + $context->getRequest()->response(), + $context->getConfig() + ); + + $cspConfig = $context->getConfig()->get( 'CSPHeader' ); + $cspConfigReportOnly = $context->getConfig()->get( 'CSPReportOnlyHeader' ); + + $csp->sendCSPHeader( $cspConfig, self::FULL_MODE ); + $csp->sendCSPHeader( $cspConfigReportOnly, self::REPORT_ONLY_MODE ); + + // Include header which increases security level after initial load. + // This helps mitigate attacks on browsers not supporting CSP2. It also + // helps mitigate attacks due to the shared nonce that non-logged in users + // get due to varnish cache. + // Unclear if this is the best place to insert the meta tag, or if + // it should be in a RL module. I figure its best to do this as early + // as possible. + // FIXME: Needs testing to see if this actually works properly + $metaHeader = $csp->getMetaHeader( $cspConfig ); + if ( $metaHeader ) { + $context->getOutput()->addScript( + ResourceLoader::makeInlineScript( + $csp->makeMetaInsertScript( + $metaHeader + ), + $out->getCSPNonce() + ) + ); + } + } + + /** + * Makes javascript to insert a meta CSP header after page load + * + * @see https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/ + * @param string $metaContents content of meta tag + * @return string JS for including in page + */ + private function makeMetaInsertScript( $metaContents ) { + return "$('\\x3Cmeta http-equiv=\"Content-Security-Policy\"\\x3E')" . + '.attr("content",' . + Xml::encodeJsVar( $metaContents ) . + ').prependTo($("head"))'; + } + + /** + * Get the name of the HTTP header to use. + * + * @param int $reportOnly Either self::REPORT_ONLY_MODE or self::FULL_MODE + * @return string Name of http header + * @throws UnexpectedValueException if you feed it self::FULL_MODE_RESTRICTED. + */ + private function getHeaderName( $reportOnly ) { + if ( $reportOnly === self::REPORT_ONLY_MODE ) { + return 'Content-Security-Policy-Report-Only'; + } elseif ( $reportOnly === self::FULL_MODE ) { + return 'Content-Security-Policy'; + } + throw new UnexpectedValueException( $reportOnly ); + } + + /** + * Determine what CSP policies to set for this page + * + * @param array|bool $config Policy configuration (Either $wgCSPHeader or $wgCSPReportOnlyHeader) + * @param int $mode self::REPORT_ONLY_MODE, self::FULL_MODE or Self::FULL_MODE_RESTRICTED + * @return string Policy directives, or empty string for no policy. + */ + private function makeCSPDirectives( $policyConfig, $mode ) { + if ( $policyConfig === false ) { + // CSP is disabled + return ''; + } + if ( $policyConfig === true ) { + $policyConfig = []; + } + + $mwConfig = $this->mwConfig; + + $additionalSelfUrls = $this->getAdditionalSelfUrls(); + $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript(); + $nonceSrc = "'nonce-" . $this->nonce . "'"; + + // If no default-src is sent at all, it + // seems browsers (or at least some), interpret + // that as allow anything, but the spec seems + // to imply that data: and blob: should be + // blocked. + $defaultSrc = [ '*', 'data:', 'blob:' ]; + + $cssSrc = false; + $imgSrc = false; + $scriptSrc = [ "'unsafe-eval'", "'self'" ]; + if ( $mode !== self::FULL_MODE_RESTRICTED ) { + $scriptSrc[] = $nonceSrc; + } + $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript ); + if ( isset( $policyConfig['script-src'] ) + && is_array( $policyConfig['script-src'] ) + ) { + foreach ( $policyConfig['script-src'] as $src ) { + $scriptSrc[] = $this->escapeUrlForCSP( $src ); + } + } + // Note: default on if unspecified. + if ( ( !isset( $policyConfig['unsafeFallback'] ) + || $policyConfig['unsafeFallback'] ) + && $mode !== self::FULL_MODE_RESTRICTED + ) { + // unsafe-inline should be ignored on browsers + // that support 'nonce-foo' sources. + // Some older versions of firefox don't follow this + // rule, but new browsers do. (Should be for at least + // firefox 40+). + $scriptSrc[] = "'unsafe-inline'"; + } + // If default source option set to true or + // an array of urls, set a restrictive default-src. + // If set to false, we send a lenient default-src, + // see the code above where $defaultSrc is set initially. + if ( isset( $policyConfig['default-src'] ) + && $policyConfig['default-src'] !== false + ) { + $defaultSrc = array_merge( + [ "'self'", 'data:', 'blob:' ], + $additionalSelfUrls + ); + if ( is_array( $policyConfig['default-src'] ) ) { + foreach ( $policyConfig['default-src'] as $src ) { + $defaultSrc[] = $this->escapeUrlForCSP( $src ); + } + } + } + + if ( !isset( $policyConfig['includeCORS'] ) || $policyConfig['includeCORS'] ) { + $CORSUrls = $this->getCORSSources(); + if ( !in_array( '*', $defaultSrc ) ) { + $defaultSrc = array_merge( $defaultSrc, $CORSUrls ); + } + // Unlikely to have * in scriptSrc, but doesn't + // hurt to check. + if ( !in_array( '*', $scriptSrc ) ) { + $scriptSrc = array_merge( $scriptSrc, $CORSUrls ); + } + } + + Hooks::run( 'ContentSecurityPolicyDefaultSource', [ &$defaultSrc, $policyConfig, $mode ] ); + Hooks::run( 'ContentSecurityPolicyScriptSource', [ &$scriptSrc, $policyConfig, $mode ] ); + + // Check if array just in case the hook made it false + if ( is_array( $defaultSrc ) ) { + $cssSrc = array_merge( $defaultSrc, [ "'unsafe-inline'" ] ); + } + + if ( $mode === self::FULL_MODE_RESTRICTED ) { + // report-uri disallowed in tags. + $reportUri = false; + } elseif ( isset( $policyConfig['report-uri'] ) && $policyConfig['report-uri'] !== true ) { + if ( $policyConfig['report-uri'] === false ) { + $reportUri = false; + } else { + $reportUri = $this->escapeUrlForCSP( $policyConfig['report-uri'] ); + } + } else { + $reportUri = $this->getReportUri( $mode ); + } + + // Only send an img-src, if we're sending a restricitve default. + if ( !is_array( $defaultSrc ) + || !in_array( '*', $defaultSrc ) + || !in_array( 'data:', $defaultSrc ) + || !in_array( 'blob:', $defaultSrc ) + ) { + // A future todo might be to make the whitelist options only + // add all the whitelisted sites to the header, instead of + // allowing all (Assuming there is a small number of sites). + // For now, the external image feature disables the limits + // CSP puts on external images. + if ( $mwConfig->get( 'AllowExternalImages' ) + || $mwConfig->get( 'AllowExternalImagesFrom' ) + || $mwConfig->get( 'AllowImageTag' ) + ) { + $imgSrc = [ '*', 'data:', 'blob:' ]; + } elseif ( $mwConfig->get( 'EnableImageWhitelist' ) ) { + $whitelist = wfMessage( 'external_image_whitelist' ) + ->inContentLanguage() + ->plain(); + if ( preg_match( '/^\s*[^\s#]/m', $whitelist ) ) { + $imgSrc = [ '*', 'data:', 'blob:' ]; + } + } + } + + $directives = []; + if ( $scriptSrc ) { + $directives[] = 'script-src ' . implode( ' ', $scriptSrc ); + } + if ( $defaultSrc ) { + $directives[] = 'default-src ' . implode( ' ', $defaultSrc ); + } + if ( $cssSrc ) { + $directives[] = 'style-src ' . implode( ' ', $cssSrc ); + } + if ( $imgSrc ) { + $directives[] = 'img-src ' . implode( ' ', $imgSrc ); + } + if ( $reportUri ) { + $directives[] = 'report-uri ' . $reportUri; + } + + Hooks::run( 'ContentSecurityPolicyDirectives', [ &$directives, $policyConfig, $mode ] ); + + return implode( '; ', $directives ); + } + + /** + * Get the default report uri. + * + * @param int $mode self::*_MODE constant. Do not use with self::FULL_MODE_RESTRICTED + * @return string The URI to send reports to. + * @throws UnexpectedValueException if given invalid mode. + */ + private function getReportUri( $mode ) { + if ( $mode === self::FULL_MODE_RESTRICTED ) { + throw new UnexpectedValueException( $mode ); + } + $apiArguments = [ + 'action' => 'cspreport', + 'format' => 'json' + ]; + if ( $mode === self::REPORT_ONLY_MODE ) { + $apiArguments['reportonly'] = '1'; + } + $reportUri = wfAppendQuery( wfScript( 'api' ), $apiArguments ); + + // Per spec, ';' and ',' must be hex-escaped in report uri + $reportUri = $this->escapeUrlForCSP( $reportUri ); + return $reportUri; + } + + /** + * Given a url, convert to form needed for CSP. + * + * Currently this does either scheme + host, or + * if protocol relative, just the host. Future versions + * could potentially preserve some of the path, if its determined + * that that would be a good idea. + * + * @note This does the extra escaping for CSP, but assumes the url + * has already had normal url escaping applied. + * @note This discards urls same as server name, as 'self' directive + * takes care of that. + * @param string $url + * @return string|bool Converted url or false on failure + */ + private function prepareUrlForCSP( $url ) { + $result = false; + if ( preg_match( '/^[a-z][a-z0-9+.-]*:$/i', $url ) ) { + // A schema source (e.g. blob: or data:) + return $url; + } + $bits = wfParseUrl( $url ); + if ( !$bits && strpos( $url, '/' ) === false ) { + // probably something like example.com. + // try again protocol-relative. + $url = '//' . $url; + $bits = wfParseUrl( $url ); + } + if ( $bits && isset( $bits['host'] ) + && $bits['host'] !== $this->mwConfig->get( 'ServerName' ) + ) { + $result = $bits['host']; + if ( $bits['scheme'] !== '' ) { + $result = $bits['scheme'] . $bits['delimiter'] . $result; + } + if ( isset( $bits['port'] ) ) { + $result .= ':' . $bits['port']; + } + $result = $this->escapeUrlForCSP( $result ); + } + return $result; + } + + /** + * Get additional script sources + * + * @return array Additional sources for loading scripts from + */ + private function getAdditionalSelfUrlsScript() { + $additionalUrls = []; + // wgExtensionAssetsPath for ?debug=true mode + $pathVars = [ 'LoadScript', 'ExtensionAssetsPath', 'ResourceBasePath' ]; + + foreach ( $pathVars as $path ) { + $url = $this->mwConfig->get( $path ); + $preparedUrl = $this->prepareUrlForCSP( $url ); + if ( $preparedUrl ) { + $additionalUrls[] = $preparedUrl; + } + } + $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' ); + foreach ( $RLSources as $wiki => $sources ) { + foreach ( $sources as $id => $value ) { + $url = $this->prepareUrlForCSP( $value ); + if ( $url ) { + $additionalUrls[] = $url; + } + } + } + + return array_unique( $additionalUrls ); + } + + /** + * Get additional host names for the wiki (e.g. if static content loaded elsewhere) + * + * @note These are general load sources, not script sources + * @return array Array of other urls for wiki (for use in default-src) + */ + private function getAdditionalSelfUrls() { + // XXX on a foreign repo, the included description page can have anything on it, + // including inline scripts. But nobody sane does that. + + // In principle, you can have even more complex configs... (e.g. The urlsByExt option) + $pathUrls = []; + $additionalSelfUrls = []; + + // Future todo: The zone urls should never go into + // style-src. They should either be only in img-src, or if + // img-src unspecified they should be in default-src. Similarly, + // the DescriptionStylesheetUrl only needs to be in style-src + // (or default-src if style-src unspecified). + $callback = function ( $repo, &$urls ) { + $urls[] = $repo->getZoneUrl( 'public' ); + $urls[] = $repo->getZoneUrl( 'transcoded' ); + $urls[] = $repo->getZoneUrl( 'thumb' ); + $urls[] = $repo->getDescriptionStylesheetUrl(); + }; + $localRepo = RepoGroup::singleton()->getRepo( 'local' ); + $callback( $localRepo, $pathUrls ); + RepoGroup::singleton()->forEachForeignRepo( $callback, [ &$pathUrls ] ); + + // Globals that might point to a different domain + $pathGlobals = [ 'LoadScript', 'ExtensionAssetsPath', 'StylePath', 'ResourceBasePath' ]; + foreach ( $pathGlobals as $path ) { + $pathUrls[] = $this->mwConfig->get( $path ); + } + foreach ( $pathUrls as $path ) { + $preparedUrl = $this->prepareUrlForCSP( $path ); + if ( $preparedUrl !== false ) { + $additionalSelfUrls[] = $preparedUrl; + } + } + $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' ); + + foreach ( $RLSources as $wiki => $sources ) { + foreach ( $sources as $id => $value ) { + $url = $this->prepareUrlForCSP( $value ); + if ( $url ) { + $additionalSelfUrls[] = $url; + } + } + } + + return array_unique( $additionalSelfUrls ); + } + + /** + * include domains that are allowed to send us CORS requests. + * + * Technically, $wgCrossSiteAJAXdomains lists things that are allowed to talk to us + * not things that we are allowed to talk to - but if something is allowed to talk to us, + * then there is a good chance that we should probably be allowed to talk to it. + * + * This is configurable with the 'includeCORS' key in the CSP config, and enabled + * by default. + * @note CORS domains with single character ('?') wildcards, are not included. + * @return array Additional hosts + */ + private function getCORSSources() { + $additionalUrls = []; + $CORSSources = $this->mwConfig->get( 'CrossSiteAJAXdomains' ); + foreach ( $CORSSources as $source ) { + if ( strpos( $source, '?' ) !== false ) { + // CSP doesn't support single char wildcard + continue; + } + $url = $this->prepareUrlForCSP( $source ); + if ( $url ) { + $additionalUrls[] = $url; + } + } + return $additionalUrls; + } + + /** + * CSP spec says ',' and ';' are not allowed to appear in urls. + * + * @note This assumes that normal escaping has been applied to the url + * @param string $url URL (or possibly just part of one) + * @return string + */ + private function escapeUrlForCSP( $url ) { + return str_replace( + [ ';', ',' ], + [ '%3B', '%2C' ], + $url + ); + } + + /** + * Does this browser give false positive reports? + * + * Some versions of firefox (40-42) incorrectly report a csp + * violation for nonce sources, despite allowing them. + * + * @see https://bugzilla.mozilla.org/show_bug.cgi?id=1026520 + * @param string $ua User-agent header + * @return bool + */ + public static function falsePositiveBrowser( $ua ) { + return (bool)preg_match( '!Firefox/4[0-2]\.!', $ua ); + } + + /** + * Is CSP currently enabled (i.e. Should we set nonce attribute) + * + * @param Config $config Configuration object + * @return bool + */ + public static function isEnabled( Config $config ) { + return $config->get( 'CSPHeader' ) !== false + || $config->get( 'CSPReportOnlyHeader' ) !== false; + } +} diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 1ea9a420d8..87ca0168bd 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -1085,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. @@ -1564,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; @@ -1587,7 +1597,7 @@ $wgPasswordSenderName = 'MediaWiki Mail'; /** * Reply-To address for e-mail notifications. * - * Defaults to $wgPasswordSender. + * Defaults to $wgPasswordSender (in Setup.php). */ $wgNoReplyAddress = false; @@ -1681,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; @@ -1714,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; @@ -1892,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. */ @@ -3210,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 @@ -3345,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 @@ -4257,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 @@ -5647,6 +5669,7 @@ $wgRateLimits = [ 'edit' => [ 'ip' => [ 8, 60 ], 'newbie' => [ 8, 60 ], + 'user' => [ 90, 60 ], ], // Page moves 'move' => [ @@ -7841,10 +7864,6 @@ $wgActionFilteredLogs = [ 'autocreate' => [ 'autocreate' ], 'byemail' => [ 'byemail' ], ], - 'patrol' => [ - 'patrol' => [ 'patrol' ], - 'autopatrol' => [ 'autopatrol' ], - ], 'protect' => [ 'protect' => [ 'protect' ], 'modify' => [ 'modify' ], @@ -8162,7 +8181,7 @@ $wgAPIUselessQueryPages = [ /** * Enable AJAX framework * - * @deprecated since MediaWiki 1.32 and ignored + * @deprecated (officially) since MediaWiki 1.31 and ignored since 1.32 */ $wgUseAjax = true; @@ -8729,6 +8748,34 @@ $wgMaxUserDBWriteDuration = false; */ $wgMaxJobDBWriteDuration = false; +/** + * Controls Content-Security-Policy header [Experimental] + * + * @see https://www.w3.org/TR/CSP2/ + * @since 1.32 + * @var bool|array true to send default version, false to not send. + * If an array, can have parameters: + * 'default-src' If true or array (of additional urls) will set a default-src + * directive, which limits what places things can load from. If false or not + * set, will send a default-src directive allowing all sources. + * 'includeCORS' If true or not set, will include urls from + * $wgCrossSiteAJAXdomains as an allowed load sources. + * 'unsafeFallback' Add unsafe-inline as a script source, as a fallback for + * browsers that do not understand nonce-sources [default on]. + * 'script-src' Array of additional places that are allowed to have JS be loaded from. + * 'report-uri' true to use MW api [default], false to disable, string for alternate uri + * @warning May cause slowness on windows due to slow random number generator. + */ +$wgCSPHeader = false; + +/** + * Controls Content-Security-Policy-Report-Only header + * + * @since 1.32 + * @var bool|array Same as $wgCSPHeader + */ +$wgCSPReportOnlyHeader = false; + /** * Mapping of event channels (or channel categories) to EventRelayer configuration. * @@ -8854,6 +8901,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..6d39e3a03d 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() ); @@ -4118,12 +4111,15 @@ ERROR; $script .= '});'; + $nonce = $wgOut->getCSPNonce(); + $wgOut->addScript( ResourceLoader::makeInlineScript( $script, $nonce ) ); + $toolbar = '
'; if ( Hooks::run( 'EditPageBeforeEditToolbar', [ &$toolbar ] ) ) { // Only add the old toolbar cruft to the page payload if the toolbar has not // been over-written by a hook caller - $wgOut->addScript( ResourceLoader::makeInlineScript( $script ) ); + $wgOut->addScript( ResourceLoader::makeInlineScript( $script, $nonce ) ); }; return $toolbar; 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 1a3f50ac43..7e8df7ee48 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 */ @@ -1511,9 +1513,10 @@ function wfHostname() { * If $wgShowHostnames is true, the script will also set 'wgHostname' to the * hostname of the server handling the request. * + * @param string $nonce Value from OutputPage::getCSPNonce * @return string */ -function wfReportTime() { +function wfReportTime( $nonce = null ) { global $wgShowHostnames; $elapsed = ( microtime( true ) - $_SERVER['REQUEST_TIME_FLOAT'] ); @@ -1523,7 +1526,7 @@ function wfReportTime() { if ( $wgShowHostnames ) { $reportVars['wgHostname'] = wfHostname(); } - return Skin::makeVariablesScript( $reportVars ); + return Skin::makeVariablesScript( $reportVars, $nonce ); } /** @@ -2559,6 +2562,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, '<' ) ) { @@ -3035,6 +3039,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/Html.php b/includes/Html.php index 3bcf13132f..019e0785f9 100644 --- a/includes/Html.php +++ b/includes/Html.php @@ -557,10 +557,18 @@ class Html { * literal "" or (for XML) literal "]]>". * * @param string $contents JavaScript + * @param string $nonce Nonce for CSP header, from OutputPage::getCSPNonce() * @return string Raw HTML */ - public static function inlineScript( $contents ) { + public static function inlineScript( $contents, $nonce = null ) { $attrs = []; + if ( $nonce !== null ) { + $attrs['nonce'] = $nonce; + } else { + if ( ContentSecurityPolicy::isEnabled( RequestContext::getMain()->getConfig() ) ) { + wfWarn( "no nonce set on script. CSP will break it" ); + } + } if ( preg_match( '/[<&]/', $contents ) ) { $contents = "/**/"; @@ -574,10 +582,18 @@ class Html { * "". * * @param string $url + * @param string $nonce Nonce for CSP header, from OutputPage::getCSPNonce() * @return string Raw HTML */ - public static function linkedScript( $url ) { + public static function linkedScript( $url, $nonce = null ) { $attrs = [ 'src' => $url ]; + if ( $nonce !== null ) { + $attrs['nonce'] = $nonce; + } else { + if ( ContentSecurityPolicy::isEnabled( RequestContext::getMain()->getConfig() ) ) { + wfWarn( "no nonce set on script. CSP will break it" ); + } + } return self::element( 'script', $attrs ); } diff --git a/includes/MediaWiki.php b/includes/MediaWiki.php index 82ffcfb71e..e6dc0fecd3 100644 --- a/includes/MediaWiki.php +++ b/includes/MediaWiki.php @@ -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/OutputPage.php b/includes/OutputPage.php index dd64413fd4..52dfc1164e 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,11 +293,22 @@ 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 */ private $mLinkHeader = []; + /** + * @var string The nonce for Content-Security-Policy + */ + private $CSPNonce; + /** * Constructor for OutputPage. This should not be called directly. * Instead a new RequestContext should be created and it will implicitly create @@ -471,7 +480,7 @@ class OutputPage extends ContextSource { if ( is_null( $version ) ) { $version = $this->getConfig()->get( 'StyleVersion' ); } - $this->addScript( Html::linkedScript( wfAppendQuery( $path, $version ) ) ); + $this->addScript( Html::linkedScript( wfAppendQuery( $path, $version ), $this->getCSPNonce() ) ); } /** @@ -481,7 +490,7 @@ class OutputPage extends ContextSource { * @param string $script JavaScript text, no script tags */ public function addInlineScript( $script ) { - $this->mScripts .= Html::inlineScript( $script ); + $this->mScripts .= Html::inlineScript( "\n$script\n", $this->getCSPNonce() ) . "\n"; } /** @@ -546,9 +555,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 +564,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 +577,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 +591,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 +604,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 +632,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 +795,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 +860,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 +2337,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: @@ -2363,6 +2438,8 @@ class OutputPage extends ContextSource { $response->header( "X-Frame-Options: $frameOptions" ); } + ContentSecurityPolicy::sendHeaders( $this ); + if ( $this->mArticleBodyOnly ) { echo $this->mBodytext; } else { @@ -2372,9 +2449,7 @@ class OutputPage extends ContextSource { } $sk = $this->getSkin(); - foreach ( $sk->getDefaultModules() as $group ) { - $this->addModules( $group ); - } + $this->loadSkinModules( $sk ); MWDebug::addModules( $this ); @@ -2708,6 +2783,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 +2815,7 @@ class OutputPage extends ContextSource { $context = $this->getRlClientContext(); $rl = $this->getResourceLoader(); $this->addModules( [ + 'user', 'user.options', 'user.tokens', ] ); @@ -2756,11 +2844,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 +2859,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(), ] ); @@ -2836,7 +2907,7 @@ class OutputPage extends ContextSource { } $pieces[] = Html::element( 'title', null, $this->getHTMLTitle() ); - $pieces[] = $this->getRlClient()->getHeadHtml(); + $pieces[] = $this->getRlClient()->getHeadHtml( $this->getCSPNonce() ); $pieces[] = $this->buildExemptModules(); $pieces = array_merge( $pieces, array_values( $this->getHeadLinksArray() ) ); $pieces = array_merge( $pieces, array_values( $this->mHeadItems ) ); @@ -2847,7 +2918,8 @@ class OutputPage extends ContextSource { ResourceLoaderContext::newDummyContext(), [ 'html5shiv' ], ResourceLoaderModule::TYPE_SCRIPTS, - [ 'sync' => true ] + [ 'sync' => true ], + $this->getCSPNonce() ) . ''; @@ -2928,7 +3000,8 @@ class OutputPage extends ContextSource { $this->getRlClientContext(), $modules, $only, - $extraQuery + $extraQuery, + $this->getCSPNonce() ); } @@ -2944,20 +3017,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,45 +3030,12 @@ 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( [ 'wgPageParseReport' => $this->limitReportJSData ] - ) + ), + $this->getCSPNonce() ); } @@ -3178,7 +3204,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 +3215,6 @@ class OutputPage extends ContextSource { $request = $this->getRequest(); if ( $request->getVal( 'action' ) !== 'submit' || - !$request->getCheck( 'wpPreview' ) || !$request->wasPosted() ) { return false; @@ -3206,17 +3231,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; @@ -3555,29 +3569,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: @@ -4007,4 +4002,26 @@ class OutputPage extends ContextSource { ); } } + + /** + * Get (and set if not yet set) the CSP nonce. + * + * This value needs to be included in any ' ); } diff --git a/includes/resourceloader/ResourceLoaderClientHtml.php b/includes/resourceloader/ResourceLoaderClientHtml.php index 545fd3bda7..d0a9c4248a 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'; @@ -241,9 +248,10 @@ class ResourceLoaderClientHtml { * - Inline scripts can't be asynchronous. * - For styles, earlier is better. * + * @param string $nonce From OutputPage::getCSPNonce() * @return string|WrappedStringList HTML */ - public function getHeadHtml() { + public function getHeadHtml( $nonce ) { $data = $this->getData(); $chunks = []; @@ -252,13 +260,15 @@ class ResourceLoaderClientHtml { // See also #getDocumentAttributes() and /resources/src/startup.js. $chunks[] = Html::inlineScript( 'document.documentElement.className = document.documentElement.className' - . '.replace( /(^|\s)client-nojs(\s|$)/, "$1client-js$2" );' + . '.replace( /(^|\s)client-nojs(\s|$)/, "$1client-js$2" );', + $nonce ); // Inline RLQ: Set page variables if ( $this->config ) { $chunks[] = ResourceLoader::makeInlineScript( - ResourceLoader::makeConfigSetScript( $this->config ) + ResourceLoader::makeConfigSetScript( $this->config ), + $nonce ); } @@ -266,7 +276,8 @@ class ResourceLoaderClientHtml { $states = array_merge( $this->exemptStates, $data['states'] ); if ( $states ) { $chunks[] = ResourceLoader::makeInlineScript( - ResourceLoader::makeLoaderStateScript( $states ) + ResourceLoader::makeLoaderStateScript( $states ), + $nonce ); } @@ -274,14 +285,16 @@ class ResourceLoaderClientHtml { if ( $data['embed']['general'] ) { $chunks[] = $this->getLoad( $data['embed']['general'], - ResourceLoaderModule::TYPE_COMBINED + ResourceLoaderModule::TYPE_COMBINED, + $nonce ); } // Inline RLQ: Load general modules if ( $data['general'] ) { $chunks[] = ResourceLoader::makeInlineScript( - Xml::encodeJsCall( 'mw.loader.load', [ $data['general'] ] ) + Xml::encodeJsCall( 'mw.loader.load', [ $data['general'] ] ), + $nonce ); } @@ -289,7 +302,8 @@ class ResourceLoaderClientHtml { if ( $data['scripts'] ) { $chunks[] = $this->getLoad( $data['scripts'], - ResourceLoaderModule::TYPE_SCRIPTS + ResourceLoaderModule::TYPE_SCRIPTS, + $nonce ); } @@ -297,7 +311,8 @@ class ResourceLoaderClientHtml { if ( $data['styles'] ) { $chunks[] = $this->getLoad( $data['styles'], - ResourceLoaderModule::TYPE_STYLES + ResourceLoaderModule::TYPE_STYLES, + $nonce ); } @@ -305,7 +320,8 @@ class ResourceLoaderClientHtml { if ( $data['embed']['styles'] ) { $chunks[] = $this->getLoad( $data['embed']['styles'], - ResourceLoaderModule::TYPE_STYLES + ResourceLoaderModule::TYPE_STYLES, + $nonce ); } @@ -317,6 +333,7 @@ class ResourceLoaderClientHtml { $chunks[] = $this->getLoad( 'startup', ResourceLoaderModule::TYPE_SCRIPTS, + $nonce, $startupQuery ); @@ -334,8 +351,8 @@ class ResourceLoaderClientHtml { return self::makeContext( $this->context, $group, $type ); } - private function getLoad( $modules, $only, array $extraQuery = [] ) { - return self::makeLoad( $this->context, (array)$modules, $only, $extraQuery ); + private function getLoad( $modules, $only, $nonce, array $extraQuery = [] ) { + return self::makeLoad( $this->context, (array)$modules, $only, $extraQuery, $nonce ); } private static function makeContext( ResourceLoaderContext $mainContext, $group, $type, @@ -351,7 +368,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; } /** @@ -360,11 +379,12 @@ class ResourceLoaderClientHtml { * @param ResourceLoaderContext $mainContext * @param array $modules One or more module names * @param string $only ResourceLoaderModule TYPE_ class constant - * @param array $extraQuery [optional] Array with extra query parameters for the request + * @param array $extraQuery Array with extra query parameters for the request + * @param string $nonce See OutputPage::getCSPNonce() [Since 1.32] * @return string|WrappedStringList HTML */ public static function makeLoad( ResourceLoaderContext $mainContext, array $modules, $only, - array $extraQuery = [] + array $extraQuery, $nonce ) { $rl = $mainContext->getResourceLoader(); $chunks = []; @@ -376,7 +396,7 @@ class ResourceLoaderClientHtml { $chunks = []; // Recursively call us for every item foreach ( $modules as $name ) { - $chunks[] = self::makeLoad( $mainContext, [ $name ], $only, $extraQuery ); + $chunks[] = self::makeLoad( $mainContext, [ $name ], $only, $extraQuery, $nonce ); } return new WrappedStringList( "\n", $chunks ); } @@ -418,7 +438,8 @@ class ResourceLoaderClientHtml { ); } else { $chunks[] = ResourceLoader::makeInlineScript( - $rl->makeModuleResponse( $context, $moduleSet ) + $rl->makeModuleResponse( $context, $moduleSet ), + $nonce ); } } else { @@ -452,7 +473,8 @@ class ResourceLoaderClientHtml { ] ); } else { $chunk = ResourceLoader::makeInlineScript( - Xml::encodeJsCall( 'mw.loader.load', [ $url ] ) + Xml::encodeJsCall( 'mw.loader.load', [ $url ] ), + $nonce ); } } 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/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/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/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/search/SearchEngine.php b/includes/search/SearchEngine.php index 7e6e8e6d0d..bd48e21364 100644 --- a/includes/search/SearchEngine.php +++ b/includes/search/SearchEngine.php @@ -69,12 +69,25 @@ abstract class SearchEngine { /** * Perform a full text search query and return a result set. * If full text searches are not supported or disabled, return null. - * STUB + * + * As of 1.32 overriding this function is deprecated. It will + * be converted to final in 1.34. Override self::doSearchText(). * * @param string $term Raw search term * @return SearchResultSet|Status|null */ - function searchText( $term ) { + public function searchText( $term ) { + return $this->doSearchText( $term ); + } + + /** + * Perform a full text search query and return a result set. + * + * @param string $term Raw search term + * @return SearchResultSet|Status|null + * @since 1.32 + */ + protected function doSearchText( $term ) { return null; } @@ -85,11 +98,25 @@ abstract class SearchEngine { * The results returned by this methods are only sugegstions and * may not end up being shown to the user. * + * As of 1.32 overriding this function is deprecated. It will + * be converted to final in 1.34. Override self::doSearchArchiveTitle(). + * * @param string $term Raw search term * @return Status * @since 1.29 */ - function searchArchiveTitle( $term ) { + public function searchArchiveTitle( $term ) { + return $this->doSearchArchiveTitle( $term ); + } + + /** + * Perform a title search in the article archive. + * + * @param string $term Raw search term + * @return Status + * @since 1.32 + */ + protected function doSearchArchiveTitle( $term ) { return Status::newGood( [] ); } @@ -98,10 +125,24 @@ abstract class SearchEngine { * If title searches are not supported or disabled, return null. * STUB * + * As of 1.32 overriding this function is deprecated. It will + * be converted to final in 1.34. Override self::doSearchTitle(). + * + * @param string $term Raw search term + * @return SearchResultSet|null + */ + public function searchTitle( $term ) { + return $this->doSearchTitle( $term ); + } + + /** + * Perform a title-only search query and return a result set. + * * @param string $term Raw search term * @return SearchResultSet|null + * @since 1.32 */ - function searchTitle( $term ) { + protected function doSearchTitle( $term ) { return null; } @@ -335,12 +376,25 @@ abstract class SearchEngine { return false; } $extractedNamespace = null; + $allkeywords = []; + + $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; + } + } - $allkeyword = wfMessage( 'searchall' )->inContentLanguage()->text() . ":"; - if ( strncmp( $query, $allkeyword, strlen( $allkeyword ) ) == 0 ) { - $extractedNamespace = null; - $parsed = substr( $query, strlen( $allkeyword ) ); - } elseif ( strpos( $query, ':' ) !== false ) { + 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/SearchMssql.php b/includes/search/SearchMssql.php index 57ca06e39f..43bd3bed27 100644 --- a/includes/search/SearchMssql.php +++ b/includes/search/SearchMssql.php @@ -31,9 +31,8 @@ class SearchMssql extends SearchDatabase { * * @param string $term Raw search term * @return SqlSearchResultSet - * @access public */ - function searchText( $term ) { + protected function doSearchText( $term ) { $resultSet = $this->db->query( $this->getQuery( $this->filter( $term ), true ) ); return new SqlSearchResultSet( $resultSet, $this->searchTerms ); } @@ -43,9 +42,8 @@ class SearchMssql extends SearchDatabase { * * @param string $term Raw search term * @return SqlSearchResultSet - * @access public */ - function searchTitle( $term ) { + protected function doSearchTitle( $term ) { $resultSet = $this->db->query( $this->getQuery( $this->filter( $term ), false ) ); return new SqlSearchResultSet( $resultSet, $this->searchTerms ); } @@ -54,9 +52,8 @@ class SearchMssql extends SearchDatabase { * Return a partial WHERE clause to limit the search to the given namespaces * * @return string - * @private */ - function queryNamespaces() { + private function queryNamespaces() { $namespaces = implode( ',', $this->namespaces ); if ( $namespaces == '' ) { $namespaces = '0'; @@ -71,7 +68,7 @@ class SearchMssql extends SearchDatabase { * * @return string */ - function queryLimit( $sql ) { + private function queryLimit( $sql ) { return $this->db->limitResult( $sql, $this->limit, $this->offset ); } @@ -95,7 +92,7 @@ class SearchMssql extends SearchDatabase { * @param bool $fulltext * @return string */ - function getQuery( $filteredTerm, $fulltext ) { + private function getQuery( $filteredTerm, $fulltext ) { return $this->queryLimit( $this->queryMain( $filteredTerm, $fulltext ) . ' ' . $this->queryNamespaces() . ' ' . $this->queryRanking( $filteredTerm, $fulltext ) . ' ' ); @@ -117,9 +114,8 @@ class SearchMssql extends SearchDatabase { * @param string $filteredTerm * @param bool $fulltext * @return string - * @private */ - function queryMain( $filteredTerm, $fulltext ) { + private function queryMain( $filteredTerm, $fulltext ) { $match = $this->parseQuery( $filteredTerm, $fulltext ); $page = $this->db->tableName( 'page' ); $searchindex = $this->db->tableName( 'searchindex' ); @@ -134,7 +130,7 @@ class SearchMssql extends SearchDatabase { * @param bool $fulltext * @return string */ - function parseQuery( $filteredText, $fulltext ) { + private function parseQuery( $filteredText, $fulltext ) { global $wgContLang; $lc = $this->legalSearchChars( self::CHARS_NO_SYNTAX ); $this->searchTerms = []; diff --git a/includes/search/SearchMySQL.php b/includes/search/SearchMySQL.php index 8e705c1fe8..9a03ebe950 100644 --- a/includes/search/SearchMySQL.php +++ b/includes/search/SearchMySQL.php @@ -34,15 +34,15 @@ 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 ) { + private function parseQuery( $filteredText, $fulltext ) { global $wgContLang; $lc = $this->legalSearchChars( self::CHARS_NO_SYNTAX ); // Minus syntax chars (" and *) @@ -127,10 +127,13 @@ 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 ) { + private function regexTerm( $string, $wildcard ) { global $wgContLang; $regex = preg_quote( $string, '/' ); @@ -164,7 +167,7 @@ class SearchMySQL extends SearchDatabase { * @param string $term Raw search term * @return SqlSearchResultSet */ - function searchText( $term ) { + protected function doSearchText( $term ) { return $this->searchInternal( $term, true ); } @@ -174,7 +177,7 @@ class SearchMySQL extends SearchDatabase { * @param string $term Raw search term * @return SqlSearchResultSet */ - function searchTitle( $term ) { + protected function doSearchTitle( $term ) { return $this->searchInternal( $term, false ); } @@ -261,7 +264,7 @@ class SearchMySQL extends SearchDatabase { * @return array * @since 1.18 (changed) */ - function getQuery( $filteredTerm, $fulltext ) { + private function getQuery( $filteredTerm, $fulltext ) { $query = [ 'tables' => [], 'fields' => [], @@ -283,7 +286,7 @@ class SearchMySQL extends SearchDatabase { * @param bool $fulltext * @return string */ - function getIndexField( $fulltext ) { + private function getIndexField( $fulltext ) { return $fulltext ? 'si_text' : 'si_title'; } @@ -295,7 +298,7 @@ class SearchMySQL extends SearchDatabase { * @param bool $fulltext * @since 1.18 (changed) */ - function queryMain( &$query, $filteredTerm, $fulltext ) { + private function queryMain( &$query, $filteredTerm, $fulltext ) { $match = $this->parseQuery( $filteredTerm, $fulltext ); $query['tables'][] = 'page'; $query['tables'][] = 'searchindex'; @@ -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]; } /** @@ -312,13 +316,13 @@ class SearchMySQL extends SearchDatabase { * @param bool $fulltext * @return array */ - function getCountQuery( $filteredTerm, $fulltext ) { + private function getCountQuery( $filteredTerm, $fulltext ) { $match = $this->parseQuery( $filteredTerm, $fulltext ); $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/search/SearchOracle.php b/includes/search/SearchOracle.php index 8bcd78fa66..7fe5b53ca6 100644 --- a/includes/search/SearchOracle.php +++ b/includes/search/SearchOracle.php @@ -64,7 +64,7 @@ class SearchOracle extends SearchDatabase { * @param string $term Raw search term * @return SqlSearchResultSet */ - function searchText( $term ) { + protected function doSearchText( $term ) { if ( $term == '' ) { return new SqlSearchResultSet( false, '' ); } @@ -79,7 +79,7 @@ class SearchOracle extends SearchDatabase { * @param string $term Raw search term * @return SqlSearchResultSet */ - function searchTitle( $term ) { + protected function doSearchTitle( $term ) { if ( $term == '' ) { return new SqlSearchResultSet( false, '' ); } @@ -92,7 +92,7 @@ class SearchOracle extends SearchDatabase { * Return a partial WHERE clause to limit the search to the given namespaces * @return string */ - function queryNamespaces() { + private function queryNamespaces() { if ( is_null( $this->namespaces ) ) { return ''; } @@ -111,7 +111,7 @@ class SearchOracle extends SearchDatabase { * * @return string */ - function queryLimit( $sql ) { + private function queryLimit( $sql ) { return $this->db->limitResult( $sql, $this->limit, $this->offset ); } @@ -134,7 +134,7 @@ class SearchOracle extends SearchDatabase { * @param bool $fulltext * @return string */ - function getQuery( $filteredTerm, $fulltext ) { + private function getQuery( $filteredTerm, $fulltext ) { return $this->queryLimit( $this->queryMain( $filteredTerm, $fulltext ) . ' ' . $this->queryNamespaces() . ' ' . $this->queryRanking( $filteredTerm, $fulltext ) . ' ' ); @@ -145,7 +145,7 @@ class SearchOracle extends SearchDatabase { * @param bool $fulltext * @return string */ - function getIndexField( $fulltext ) { + private function getIndexField( $fulltext ) { return $fulltext ? 'si_text' : 'si_title'; } @@ -172,7 +172,7 @@ class SearchOracle extends SearchDatabase { * @param bool $fulltext * @return string */ - function parseQuery( $filteredText, $fulltext ) { + private function parseQuery( $filteredText, $fulltext ) { global $wgContLang; $lc = $this->legalSearchChars( self::CHARS_NO_SYNTAX ); $this->searchTerms = []; diff --git a/includes/search/SearchPostgres.php b/includes/search/SearchPostgres.php index 5a50b176e9..729e528fd6 100644 --- a/includes/search/SearchPostgres.php +++ b/includes/search/SearchPostgres.php @@ -37,7 +37,7 @@ class SearchPostgres extends SearchDatabase { * @param string $term Raw search term * @return SqlSearchResultSet */ - function searchTitle( $term ) { + protected function doSearchTitle( $term ) { $q = $this->searchQuery( $term, 'titlevector', 'page_title' ); $olderror = error_reporting( E_ERROR ); $resultSet = $this->db->query( $q, 'SearchPostgres', true ); @@ -45,7 +45,7 @@ class SearchPostgres extends SearchDatabase { return new SqlSearchResultSet( $resultSet, $this->searchTerms ); } - function searchText( $term ) { + protected function doSearchText( $term ) { $q = $this->searchQuery( $term, 'textvector', 'old_text' ); $olderror = error_reporting( E_ERROR ); $resultSet = $this->db->query( $q, 'SearchPostgres', true ); @@ -61,7 +61,7 @@ class SearchPostgres extends SearchDatabase { * * @return string */ - function parseQuery( $term ) { + private function parseQuery( $term ) { wfDebug( "parseQuery received: $term \n" ); # # No backslashes allowed @@ -123,7 +123,7 @@ class SearchPostgres extends SearchDatabase { * @param string $colname * @return string */ - function searchQuery( $term, $fulltext, $colname ) { + private function searchQuery( $term, $fulltext, $colname ) { # Get the SQL fragment for the given term $searchstring = $this->parseQuery( $term ); diff --git a/includes/search/SearchResultSet.php b/includes/search/SearchResultSet.php index f25c7283eb..e3eb4c250e 100644 --- a/includes/search/SearchResultSet.php +++ b/includes/search/SearchResultSet.php @@ -173,6 +173,7 @@ class SearchResultSet { * Fetches next search result, or false. * STUB * FIXME: refactor as iterator, so we could use nicer interfaces. + * @deprecated since 1.32; Use self::extractResults() * @return SearchResult|false */ function next() { @@ -181,6 +182,7 @@ class SearchResultSet { /** * Rewind result set back to beginning + * @deprecated since 1.32; Use self::extractResults() */ function rewind() { } diff --git a/includes/search/SearchSqlite.php b/includes/search/SearchSqlite.php index af29212ba1..1dc37d2560 100644 --- a/includes/search/SearchSqlite.php +++ b/includes/search/SearchSqlite.php @@ -42,7 +42,7 @@ class SearchSqlite extends SearchDatabase { * @param bool $fulltext * @return string */ - function parseQuery( $filteredText, $fulltext ) { + private function parseQuery( $filteredText, $fulltext ) { global $wgContLang; $lc = $this->legalSearchChars( self::CHARS_NO_SYNTAX ); // Minus syntax chars (" and *) $searchon = ''; @@ -122,7 +122,7 @@ class SearchSqlite extends SearchDatabase { return " $field MATCH $searchon "; } - function regexTerm( $string, $wildcard ) { + private function regexTerm( $string, $wildcard ) { global $wgContLang; $regex = preg_quote( $string, '/' ); @@ -156,7 +156,7 @@ class SearchSqlite extends SearchDatabase { * @param string $term Raw search term * @return SqlSearchResultSet */ - function searchText( $term ) { + protected function doSearchText( $term ) { return $this->searchInternal( $term, true ); } @@ -166,7 +166,7 @@ class SearchSqlite extends SearchDatabase { * @param string $term Raw search term * @return SqlSearchResultSet */ - function searchTitle( $term ) { + protected function doSearchTitle( $term ) { return $this->searchInternal( $term, false ); } @@ -195,7 +195,7 @@ class SearchSqlite extends SearchDatabase { * Return a partial WHERE clause to limit the search to the given namespaces * @return string */ - function queryNamespaces() { + private function queryNamespaces() { if ( is_null( $this->namespaces ) ) { return ''; # search all } @@ -212,7 +212,7 @@ class SearchSqlite extends SearchDatabase { * @param string $sql * @return string */ - function limitResult( $sql ) { + private function limitResult( $sql ) { return $this->db->limitResult( $sql, $this->limit, $this->offset ); } @@ -223,7 +223,7 @@ class SearchSqlite extends SearchDatabase { * @param bool $fulltext * @return string */ - function getQuery( $filteredTerm, $fulltext ) { + private function getQuery( $filteredTerm, $fulltext ) { return $this->limitResult( $this->queryMain( $filteredTerm, $fulltext ) . ' ' . $this->queryNamespaces() @@ -235,7 +235,7 @@ class SearchSqlite extends SearchDatabase { * @param bool $fulltext * @return string */ - function getIndexField( $fulltext ) { + private function getIndexField( $fulltext ) { return $fulltext ? 'si_text' : 'si_title'; } @@ -246,7 +246,7 @@ class SearchSqlite extends SearchDatabase { * @param bool $fulltext * @return string */ - function queryMain( $filteredTerm, $fulltext ) { + private function queryMain( $filteredTerm, $fulltext ) { $match = $this->parseQuery( $filteredTerm, $fulltext ); $page = $this->db->tableName( 'page' ); $searchindex = $this->db->tableName( 'searchindex' ); @@ -255,7 +255,7 @@ class SearchSqlite extends SearchDatabase { "WHERE page_id=$searchindex.rowid AND $match"; } - function getCountQuery( $filteredTerm, $fulltext ) { + private function getCountQuery( $filteredTerm, $fulltext ) { $match = $this->parseQuery( $filteredTerm, $fulltext ); $page = $this->db->tableName( 'page' ); $searchindex = $this->db->tableName( 'searchindex' ); diff --git a/includes/skins/Skin.php b/includes/skins/Skin.php index f3276e87ec..5dfa7e36c3 100644 --- a/includes/skins/Skin.php +++ b/includes/skins/Skin.php @@ -166,24 +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() { $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', ], @@ -193,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 @@ -203,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() ) { @@ -232,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; } @@ -373,12 +401,14 @@ abstract class Skin extends ContextSource { /** * @param array $data + * @param string $nonce OutputPage::getCSPNonce() * @return string */ - static function makeVariablesScript( $data ) { + static function makeVariablesScript( $data, $nonce = null ) { if ( $data ) { return ResourceLoader::makeInlineScript( - ResourceLoader::makeConfigSetScript( $data ) + ResourceLoader::makeConfigSetScript( $data ), + $nonce ); } else { return ''; @@ -403,14 +433,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 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..507688dfcc 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 @@ -489,7 +465,7 @@ class SkinTemplate extends Skin { $tpl->set( 'debug', '' ); $tpl->set( 'debughtml', $this->generateDebugHTML() ); - $tpl->set( 'reporttime', wfReportTime() ); + $tpl->set( 'reporttime', wfReportTime( $out->getCSPNonce() ) ); // Avoid PHP 7.1 warning of passing $this by reference $skinTemplate = $this; diff --git a/includes/specialpage/ChangesListSpecialPage.php b/includes/specialpage/ChangesListSpecialPage.php index ac13f113b2..9e61ef738b 100644 --- a/includes/specialpage/ChangesListSpecialPage.php +++ b/includes/specialpage/ChangesListSpecialPage.php @@ -785,7 +785,8 @@ abstract class ChangesListSpecialPage extends SpecialPage { $out->addHTML( ResourceLoader::makeInlineScript( - ResourceLoader::makeMessageSetScript( $messages ) + ResourceLoader::makeMessageSetScript( $messages ), + $out->getCSPNonce() ) ); 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 c000d546d1..034e569e6c 100644 --- a/includes/specials/SpecialApiSandbox.php +++ b/includes/specials/SpecialApiSandbox.php @@ -37,7 +37,7 @@ class SpecialApiSandbox extends SpecialPage { $out->addJsConfigVars( 'apihighlimits', $this->getUser()->isAllowed( 'apihighlimits' ) ); $out->addModuleStyles( [ - 'mediawiki.special.apisandbox.styles', + 'mediawiki.special', ] ); $out->addModules( [ 'mediawiki.special.apisandbox', 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..f03565adef 100644 --- a/includes/specials/SpecialBotPasswords.php +++ b/includes/specials/SpecialBotPasswords.php @@ -21,6 +21,8 @@ * @ingroup SpecialPage */ +use MediaWiki\Logger\LoggerFactory; + /** * Let users manage bot passwords * @@ -40,8 +42,12 @@ class SpecialBotPasswords extends FormSpecialPage { /** @var string New password set, for communication between onSubmit() and onSuccess() */ private $password = null; + /** @var Psr\Log\LoggerInterface */ + private $logger = null; + public function __construct() { parent::__construct( 'BotPasswords', 'editmyprivateinfo' ); + $this->logger = LoggerFactory::getInstance( 'authentication' ); } /** @@ -107,6 +113,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 +162,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, ]; } @@ -257,6 +283,16 @@ class SpecialBotPasswords extends FormSpecialPage { $bp = BotPassword::newFromCentralId( $this->userId, $this->par ); if ( $bp ) { $bp->delete(); + $this->logger->info( + "Bot password {op} for {user}@{app_id}", + [ + 'app_id' => $this->par, + 'user' => $this->getUser()->getName(), + 'centralId' => $this->userId, + 'op' => 'delete', + 'client_ip' => $this->getRequest()->getIP() + ] + ); } return Status::newGood(); @@ -289,6 +325,18 @@ class SpecialBotPasswords extends FormSpecialPage { } if ( $bp->save( $this->operation, $password ) ) { + $this->logger->info( + "Bot password {op} for {user}@{app_id}", + [ + 'op' => $this->operation, + 'user' => $this->getUser()->getName(), + 'app_id' => $this->par, + 'centralId' => $this->userId, + 'restrictions' => $data['restrictions'], + 'grants' => $bp->getGrants(), + 'client_ip' => $this->getRequest()->getIP() + ] + ); return Status::newGood(); } else { // Messages: botpasswords-insert-failed, botpasswords-update-failed diff --git a/includes/specials/SpecialComparePages.php b/includes/specials/SpecialComparePages.php index 35cc6b84f2..28f04faa29 100644 --- a/includes/specials/SpecialComparePages.php +++ b/includes/specials/SpecialComparePages.php @@ -49,7 +49,7 @@ class SpecialComparePages extends SpecialPage { public function execute( $par ) { $this->setHeaders(); $this->outputHeader(); - $this->getOutput()->addModuleStyles( 'mediawiki.special.comparepages.styles' ); + $this->getOutput()->addModuleStyles( 'mediawiki.special' ); $form = HTMLForm::factory( 'ooui', [ 'Page1' => [ diff --git a/includes/specials/SpecialEditTags.php b/includes/specials/SpecialEditTags.php index 60d5fd7c8c..3db7edaf8e 100644 --- a/includes/specials/SpecialEditTags.php +++ b/includes/specials/SpecialEditTags.php @@ -76,7 +76,7 @@ class SpecialEditTags extends UnlistedSpecialPage { $this->outputHeader(); $this->getOutput()->addModules( [ 'mediawiki.special.edittags', - 'mediawiki.special.edittags.styles' ] ); + 'mediawiki.special' ] ); $this->submitClicked = $request->wasPosted() && $request->getBool( 'wpSubmit' ); 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/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/SpecialMovepage.php b/includes/specials/SpecialMovepage.php index d30ff4329d..0069ea1bbe 100644 --- a/includes/specials/SpecialMovepage.php +++ b/includes/specials/SpecialMovepage.php @@ -143,8 +143,8 @@ class MovePageForm extends UnlistedSpecialPage { $out = $this->getOutput(); $out->setPageTitle( $this->msg( 'move-page', $this->oldTitle->getPrefixedText() ) ); + $out->addModuleStyles( 'mediawiki.special' ); $out->addModules( 'mediawiki.special.movePage' ); - $out->addModuleStyles( 'mediawiki.special.movePage.styles' ); $this->addHelpLink( 'Help:Moving a page' ); $out->addWikiMsg( $this->getConfig()->get( 'FixDoubleRedirects' ) ? 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/SpecialPagesWithProp.php b/includes/specials/SpecialPagesWithProp.php index 34fcc78c7e..46ad31c4a8 100644 --- a/includes/specials/SpecialPagesWithProp.php +++ b/includes/specials/SpecialPagesWithProp.php @@ -60,7 +60,7 @@ class SpecialPagesWithProp extends QueryPage { public function execute( $par ) { $this->setHeaders(); $this->outputHeader(); - $this->getOutput()->addModuleStyles( 'mediawiki.special.pagesWithProp' ); + $this->getOutput()->addModuleStyles( 'mediawiki.special' ); $request = $this->getRequest(); $propname = $request->getVal( 'propname', $par ); 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 9ff6d70811..2f285c93ed 100644 --- a/includes/specials/SpecialPrefixindex.php +++ b/includes/specials/SpecialPrefixindex.php @@ -102,8 +102,10 @@ class SpecialPrefixindex extends SpecialAllPages { 'prefix' => [ 'label-message' => 'allpagesprefix', 'name' => 'prefix', + 'id' => 'nsfrom', 'type' => 'text', 'size' => '30', + 'default' => str_replace( '_', ' ', $from ), ], 'namespace' => [ 'type' => 'namespaceselect', @@ -111,7 +113,7 @@ class SpecialPrefixindex extends SpecialAllPages { 'id' => 'namespace', 'label-message' => 'namespace', 'all' => null, - 'value' => $namespace, + 'default' => $namespace, ], 'hidedirects' => [ 'class' => 'HTMLCheckField', @@ -124,10 +126,12 @@ class SpecialPrefixindex extends SpecialAllPages { 'label-message' => 'prefixindex-strip', ], ]; - $htmlForm = new HTMLForm( $formDescriptor, $this->getContext() ); + $context = new DerivativeContext( $this->getContext() ); + $context->setTitle( $this->getPageTitle() ); // Remove subpage + $htmlForm = new HTMLForm( $formDescriptor, $context ); $htmlForm ->setMethod( 'get' ) - ->setWrapperLegendMsg( 'allpages' ) + ->setWrapperLegendMsg( 'prefixindex' ) ->setSubmitTextMsg( 'prefixindex-submit' ); return $htmlForm->prepareForm()->getHTML( false ); diff --git a/includes/specials/SpecialProtectedpages.php b/includes/specials/SpecialProtectedpages.php index 84779eaba0..26f4da5f46 100644 --- a/includes/specials/SpecialProtectedpages.php +++ b/includes/specials/SpecialProtectedpages.php @@ -122,7 +122,7 @@ class SpecialProtectedpages extends SpecialPage { '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/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/SpecialStatistics.php b/includes/specials/SpecialStatistics.php index 146e6e77de..d5e14d299b 100644 --- a/includes/specials/SpecialStatistics.php +++ b/includes/specials/SpecialStatistics.php @@ -170,7 +170,7 @@ class SpecialStatistics extends SpecialPage { Xml::closeElement( 'tr' ) . $this->formatRow( $this->msg( 'statistics-users' )->parse() . ' ' . $this->getLinkRenderer()->makeKnownLink( - SpecialPage::getTitleFor( 'ListUsers' ), + SpecialPage::getTitleFor( 'Listusers' ), $this->msg( 'listgrouprights-members' )->text() ), $this->getLanguage()->formatNum( $this->users ), diff --git a/includes/specials/SpecialUpload.php b/includes/specials/SpecialUpload.php index f7cb654527..2eeafe6900 100644 --- a/includes/specials/SpecialUpload.php +++ b/includes/specials/SpecialUpload.php @@ -387,7 +387,7 @@ class SpecialUpload extends SpecialPage { } // Add styles for the warning, reused from the live preview - $this->getOutput()->addModuleStyles( 'mediawiki.special.upload.styles' ); + $this->getOutput()->addModuleStyles( 'mediawiki.special' ); $linkRenderer = $this->getLinkRenderer(); $warningHtml = '

' . $this->msg( 'uploadwarning' )->escaped() . "

\n" 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/SpecialWatchlist.php b/includes/specials/SpecialWatchlist.php index dda1dac3af..ea73347c1f 100644 --- a/includes/specials/SpecialWatchlist.php +++ b/includes/specials/SpecialWatchlist.php @@ -60,11 +60,10 @@ class SpecialWatchlist extends ChangesListSpecialPage { $output = $this->getOutput(); $request = $this->getRequest(); $this->addHelpLink( 'Help:Watching pages' ); + $output->addModuleStyles( [ 'mediawiki.special' ] ); $output->addModules( [ - 'mediawiki.special.changeslist.visitedstatus', 'mediawiki.special.watchlist', ] ); - $output->addModuleStyles( [ 'mediawiki.special.watchlist.styles' ] ); $mode = SpecialEditWatchlist::getMode( $request, $subpage ); if ( $mode !== false ) { 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 e561fe5882..8ab6f29f09 100644 --- a/includes/specials/forms/UploadForm.php +++ b/includes/specials/forms/UploadForm.php @@ -79,7 +79,7 @@ class UploadForm extends HTMLForm { # Add a link to edit MediaWiki:Licenses if ( $this->getUser()->isAllowed( 'editinterface' ) ) { - $this->getOutput()->addModuleStyles( 'mediawiki.special.upload.styles' ); + $this->getOutput()->addModuleStyles( 'mediawiki.special' ); $licensesLink = $linkRenderer->makeKnownLink( $this->msg( 'licenses' )->inContentLanguage()->getTitle(), $this->msg( 'licenses-edit' )->text(), diff --git a/includes/specials/pagers/ImageListPager.php b/includes/specials/pagers/ImageListPager.php index 3225256fd9..b2f1487645 100644 --- a/includes/specials/pagers/ImageListPager.php +++ b/includes/specials/pagers/ImageListPager.php @@ -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/tidy/Balancer.php b/includes/tidy/Balancer.php deleted file mode 100644 index 6671f49ba7..0000000000 --- a/includes/tidy/Balancer.php +++ /dev/null @@ -1,3584 +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, - // Those defined in tidy.conf - 'video' => true, 'audio' => true, 'bdi' => true, 'data' => true, - 'time' => true, 'mark' => 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/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/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/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/structure/ApiStructureTest.php b/tests/phpunit/structure/ApiStructureTest.php index 77d6e74174..692bd73a9f 100644 --- a/tests/phpunit/structure/ApiStructureTest.php +++ b/tests/phpunit/structure/ApiStructureTest.php @@ -60,6 +60,7 @@ class ApiStructureTest extends MediaWikiTestCase { ApiBase::PARAM_ISMULTI_LIMIT2 => [ 'integer' ], ApiBase::PARAM_MAX_BYTES => [ 'integer' ], ApiBase::PARAM_MAX_CHARS => [ 'integer' ], + ApiBase::PARAM_TEMPLATE_VARS => [ 'array' ], ]; // param => [ other param that must be present => required value or null ] @@ -422,6 +423,45 @@ class ApiStructureTest extends MediaWikiTestCase { "$param: PARAM_MAX_BYTES cannot be less than PARAM_MAX_CHARS" ); } + + if ( isset( $config[ApiBase::PARAM_TEMPLATE_VARS] ) ) { + $this->assertNotSame( [], $config[ApiBase::PARAM_TEMPLATE_VARS], + "$param: PARAM_TEMPLATE_VARS cannot be empty" ); + foreach ( $config[ApiBase::PARAM_TEMPLATE_VARS] as $key => $target ) { + $this->assertRegExp( '/^[^{}]+$/', $key, + "$param: PARAM_TEMPLATE_VARS key may not contain '{' or '}'" ); + + $this->assertContains( '{' . $key . '}', $param, + "$param: Name must contain PARAM_TEMPLATE_VARS key {" . $key . "}" ); + $this->assertArrayHasKey( $target, $params, + "$param: PARAM_TEMPLATE_VARS target parameter '$target' does not exist" ); + $config2 = $params[$target]; + $this->assertTrue( !empty( $config2[ApiBase::PARAM_ISMULTI] ), + "$param: PARAM_TEMPLATE_VARS target parameter '$target' must have PARAM_ISMULTI = true" ); + + if ( isset( $config2[ApiBase::PARAM_TEMPLATE_VARS] ) ) { + $this->assertNotSame( $param, $target, + "$param: PARAM_TEMPLATE_VARS cannot target itself" ); + + $this->assertArraySubset( + $config2[ApiBase::PARAM_TEMPLATE_VARS], + $config[ApiBase::PARAM_TEMPLATE_VARS], + true, + "$param: PARAM_TEMPLATE_VARS target parameter '$target': " + . "the target's PARAM_TEMPLATE_VARS must be a subset of the original." + ); + } + } + + $keys = implode( '|', + array_map( 'preg_quote', array_keys( $config[ApiBase::PARAM_TEMPLATE_VARS] ) ) + ); + $this->assertRegExp( '/^(?>[^{}]+|\{(?:' . $keys . ')\})+$/', $param, + "$param: Name may not contain '{' or '}' other than as defined by PARAM_TEMPLATE_VARS" ); + } else { + $this->assertRegExp( '/^[^{}]+$/', $param, + "$param: Name may not contain '{' or '}' without PARAM_TEMPLATE_VARS" ); + } } } } 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 0653dfd3d0..71362fd0d1 100644 --- a/tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js +++ b/tests/qunit/suites/resources/mediawiki/mediawiki.jqueryMsg.test.js @@ -1154,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.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 b15d4073dc..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 @@ -24,38 +23,34 @@ Set up MediaWiki-Vagrant: By default, Chrome will run in headless mode. If you want to see Chrome, set DISPLAY environment variable to any value: - DISPLAY=:1 npm run selenium + DISPLAY=1 npm run selenium -To run only one file (for example page.js), you first need to spawn the chromedriver: +To run only one test (for example specs/page.js), you first need to start Chromedriver: chromedriver --url-base=wd/hub --port=4444 -Then in another terminal: +Then, in another terminal: - cd tests/selenium - ../../node_modules/.bin/wdio --spec specs/page.js + npm run selenium-test -- --spec tests/selenium/specs/page.js -To run only one test (name contains string 'preferences'): +You can also filter specific cases, for ones that contain the string 'preferences': - ../../node_modules/.bin/wdio --spec specs/user.js --mochaOpts.grep preferences + npm run selenium-test -- tests/selenium/specs/user.js --mochaOpts.grep preferences -The runner reads the config file `wdio.conf.js` and runs the spec listed in -`page.js`. - -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 index 519b7be9ac..4a5c254839 100755 --- a/tests/selenium/selenium.sh +++ b/tests/selenium/selenium.sh @@ -1,9 +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 -./node_modules/.bin/grunt webdriver:test +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.js b/tests/selenium/wdio.conf.js index 0930a0f1fa..f785d36bdb 100644 --- a/tests/selenium/wdio.conf.js +++ b/tests/selenium/wdio.conf.js @@ -1,21 +1,7 @@ -'use strict'; - const fs = require( 'fs' ), - path = require( 'path' ); - -let logPath, password, username; - -// username and password will be used only if -// MEDIAWIKI_USER or MEDIAWIKI_PASSWORD environment variables are not set -if ( process.env.JENKINS_HOME ) { - logPath = '../log/'; - password = 'testpass'; - username = 'WikiAdmin'; -} else { - logPath = './log/'; - password = 'vagrant'; - username = 'Admin'; -} + path = require( 'path' ), + saveScreenshot = require( 'wdio-mediawiki' ).saveScreenshot, + logPath = process.env.LOG_DIR || __dirname + '/log'; function relPath( foo ) { return path.resolve( __dirname, '../..', foo ); @@ -23,306 +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: [ - './extensions/CirrusSearch/tests/selenium/specs/**/*.js' + 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', + maxInstances: 1, chromeOptions: { - // Run headless when there is no DISPLAY - // --headless: since Chrome 59 https://chromium.googlesource.com/chromium/src/+/59.0.3030.0/headless/README.md - args: ( - process.env.DISPLAY ? [] : [ '--headless' ] - ).concat( - // Disable Chrome sandbox when running in Docker - fs.existsSync( '/.dockerenv' ) ? [ '--no-sandbox' ] : [] - ) + // 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. + + // Setting this enables automatic screenshots for when a browser command fails + // It is also used by afterTest for capturig failed assertions. screenshotPath: logPath, - // - // 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: 10000, - // - // 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: [],// + + // 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 + // 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) { - // } };