From: jenkins-bot Date: Thu, 19 Feb 2015 10:10:50 +0000 (+0000) Subject: Merge "DatabaseMssql: Don't duplicate body of makeList()" X-Git-Tag: 1.31.0-rc.0~12365 X-Git-Url: http://git.cyclocoop.org//%22javascript:ModifierStyle%28%27%22.%24id.%22%27%29/%22?a=commitdiff_plain;h=e2011f50dbff9188003fe1708c7444f34728aa01;hp=eb1a5c0b936cb4fae6e58f283b7a78be8ad4ad1b;p=lhc%2Fweb%2Fwiklou.git Merge "DatabaseMssql: Don't duplicate body of makeList()" --- diff --git a/.gitignore b/.gitignore index 93c429fc0a..b1649dfcc4 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ .project cscope.files cscope.out +*.orig ## NetBeans nbproject* project.index @@ -47,6 +48,7 @@ node_modules/ /vendor /composer.lock /composer.json +/composer.local.json # MediaWiki UI documentation /docs/kss/static diff --git a/.jscsrc b/.jscsrc index 2ebd40eeb8..98b81db9dc 100644 --- a/.jscsrc +++ b/.jscsrc @@ -1,8 +1,7 @@ { "preset": "wikimedia", - "disallowKeywordsOnNewLine": null, "disallowQuotedKeysInObjects": null, - "requireSpacesInsideArrayBrackets": null, - "validateIndentation": null + "requireSpacesInsideParentheses": null, + "requireSpacesInsideArrayBrackets": null } diff --git a/CREDITS b/CREDITS index 730e54dfaa..f58fabb15e 100644 --- a/CREDITS +++ b/CREDITS @@ -20,6 +20,7 @@ following names for their contribution to the product. * Brad Jorsch * Brian Wolff * Brion Vibber +* Bryan Davis * Bryan Tong Minh * Chad Horohoe * Charles Melbye diff --git a/Gruntfile.js b/Gruntfile.js new file mode 100644 index 0000000000..e4322797bb --- /dev/null +++ b/Gruntfile.js @@ -0,0 +1,94 @@ +/*jshint node:true */ +module.exports = function ( grunt ) { + grunt.loadNpmTasks( 'grunt-contrib-jshint' ); + grunt.loadNpmTasks( 'grunt-contrib-watch' ); + grunt.loadNpmTasks( 'grunt-banana-checker' ); + grunt.loadNpmTasks( 'grunt-jscs' ); + grunt.loadNpmTasks( 'grunt-jsonlint' ); + grunt.loadNpmTasks( 'grunt-karma' ); + + var wgServer = process.env.MW_SERVER, + wgScriptPath = process.env.MW_SCRIPT_PATH, + karmaProxy = {}; + + karmaProxy[wgScriptPath] = wgServer + wgScriptPath; + + grunt.initConfig( { + pkg: grunt.file.readJSON( 'package.json' ), + jshint: { + options: { + jshintrc: true + }, + all: [ + '*.js', + '{includes,languages,resources,tests}/**/*.js' + ] + }, + jscs: { + all: [ + '<%= jshint.all %>', + // Auto-generated file with JSON (double quotes) + '!tests/qunit/data/mediawiki.jqueryMsg.data.js', + // Skip functions are stored as script files but wrapped in a function when + // executed. node-jscs trips on the would-be "Illegal return statement". + '!resources/src/*-skip.js' + + // Exclude all files ignored by jshint + ].concat( grunt.file.read( '.jshintignore' ).split( '\n' ).reduce( function ( patterns, pattern ) { + // Filter out empty lines + if ( pattern.length && pattern[0] !== '#' ) { + patterns.push( '!' + pattern ); + } + return patterns; + }, [] ) ) + }, + jsonlint: { + all: [ + '.jscsrc', + '{languages,maintenance,resources}/**/*.json', + 'package.json' + ] + }, + banana: { + core: 'languages/i18n/', + api: 'includes/api/i18n/', + installer: 'includes/installer/i18n/' + }, + watch: { + files: [ + '<%= jscs.all %>', + '<%= jsonlint.all %>', + '.jshintignore', + '.jshintrc' + ], + tasks: 'test' + }, + karma: { + options: { + proxies: karmaProxy, + files: [ { + pattern: wgServer + wgScriptPath + '/index.php?title=Special:JavaScriptTest/qunit/export', + watched: false, + included: true, + served: false + } ], + frameworks: [ 'qunit' ], + reporters: [ 'dots' ], + singleRun: true, + autoWatch: false + }, + main: { + browsers: [ 'Chrome' ] + }, + more: { + browsers: [ 'Chrome', 'Firefox' ] + } + } + } ); + + grunt.registerTask( 'lint', ['jshint', 'jscs', 'jsonlint', 'banana'] ); + grunt.registerTask( 'qunit', 'karma:main' ); + + grunt.registerTask( 'test', ['lint'] ); + grunt.registerTask( 'default', 'test' ); +}; diff --git a/RELEASE-NOTES-1.25 b/RELEASE-NOTES-1.25 index 4e195c5a6f..8992ce0ce9 100644 --- a/RELEASE-NOTES-1.25 +++ b/RELEASE-NOTES-1.25 @@ -33,6 +33,18 @@ production. * (T46740) The temporary option $wgIncludejQueryMigrate was removed, along with the jQuery Migrate library, as indicated when this option was provided in MediaWiki 1.24. +* ProfilerStandard and ProfilerSimpleTrace were removed. Make sure that any + StartProfiler.php config is updated to reflect this. Xhprof is available + for zend/hhvm. Also, for hhvm, one can consider using its xenon profiler. +* Default value of $wgSVGConverters['rsvg'] now uses the 'rsvg-convert' binary + rather than 'rsvg'. +* Default value of $wgSVGConverters['ImageMagick'] now uses transparent + background with white fallback color, rather than just white background. + * MediaWikiBagOStuff class removed, make sure any object cache config + uses SqlBagOStuff instead. +* The 'daemonized' flag must be set to true in $wgJobTypeConf for any redis + job queues. This means that mediawiki/services/jobrunner service has to + be installed and running for any such queues to work. === New features in 1.25 === * (T64861) Updated plural rules to CLDR 26. Includes incompatible changes @@ -48,7 +60,7 @@ production. * (T69341) SVG images will no longer be base64-encoded when being embedded in CSS. This results in slight size increase before gzip compression (due to percent-encoding), but up to 20% decrease after it. -* Upgrade jStorage to v0.4.12. +* Update jStorage to v0.4.12. * MediaWiki now natively supports page status indicators: icons (or short text snippets) usually displayed in the top-right corner of the page. They have been in use on Wikipedia for a long time, implemented using templates and CSS @@ -71,6 +83,22 @@ production. should implement supporting behavior. Not doing so can result in undefined behavior from API clients trying to continue through prefix results. * Update jQuery from v1.11.1 to v1.11.2. +* External libraries installed via composer will now be displayed + on Special:Version in their own section. Extensions or skins that are + installed via composer will not be shown in this section as it is assumed + they will add the proper credits to the skins or extensions section. They + can also be accessed through the API via the new siprop=libraries to + ApiQuerySiteInfo. +* Update QUnit from v1.14.0 to v1.16.0. +* Update Moment.js from v2.8.3 to v2.8.4. +* Special:Tags now allows for manipulating the list of user-modifiable change + tags. Actually modifying the tagging of a revision or log entry is not + implemented yet. +* Added 'managetags' user right and 'ChangeTagCanCreate', 'ChangeTagCanDelete', + and 'ChangeTagCanCreate' hooks to allow for managing user-modifiable change + tags. +* Added 'ChangeTagsListActive' hook, to separate the concepts of "defined" and + "active" formerly conflated by the 'ListDefinedTags' hook. ==== External libraries ==== * MediaWiki now requires certain external libraries to be installed. In the past @@ -84,7 +112,7 @@ production. * The following libraries are now required: ** psr/log This library provides the interfaces set by the PSR-3 standard (http://www.php-fig.org/psr/psr-3/) - which are used by MediaWiki interally by the MWLogger class. + which are used by MediaWiki internally via the MWLoggerFactory class. See the structured logging RfC (https://www.mediawiki.org/wiki/Requests_for_comment/Structured_logging) for more background information. ** cssjanus/cssjanus @@ -119,6 +147,13 @@ production. on action=info about a file page does not list file links anymore. * (T78637) Search bar is not autofocused unless it is empty so that proper scrolling using arrow keys is possible. * (T50853) Database::makeList() modified to handle 'NULL' separately when building IN clause +* (T85192) Captcha position modified in Usercreate template. As a result: +** extrafields parameter added to Usercreate.php to insert additional data +** 'extend' method added to QuickTemplate to append additional values to any field of data array +* (T86974) Several Title methods now load from the database when necessary + (instead of returning incorrect results) even when the page ID is known. +* (T74070) Duplicate search for archived files on file upload now omits the extension. + This requires the fa_sha1 field being populated. === Action API changes in 1.25 === * (T67403) XML tag highlighting is now only performed for formats @@ -166,6 +201,18 @@ production. * (T78737) action=expandtemplates can now return page properties. * (T78690) list=allimages now accepts multiple pipe-separated values for the 'aimime' parameter. +* prop=info with inprop=protections will now return applicable protection types + with the 'restrictiontypes' key. +* (T85417) When resolving redirects, ApiPageSet will now add the targets of + interwiki redirects to the list of interwiki titles. +* (T85417) When outputting the list of redirect titles, a 'tointerwiki' + property (like the existing 'tofragment' property) will be set. +* Added action=managetags to allow for managing the list of + user-modifiable change tags. Actually modifying the tagging of a revision or + log entry is not implemented yet. +* list=tags has additional properties to indicate 'active' status and tag + sources. +* siprop=libraries was added to ApiQuerySiteInfo to list installed external libraries. === Action API internal changes in 1.25 === * ApiHelp has been rewritten to support i18n and paginated HTML output. @@ -232,7 +279,7 @@ changes to languages because of Bugzilla reports. * The skin autodiscovery mechanism, deprecated in MediaWiki 1.23, has been removed. See https://www.mediawiki.org/wiki/Manual:Skin_autodiscovery for migration guide for creators and users of custom skins that relied on it. -* Javascript variable 'wgFileCanRotate' and 'wgFileExtensions' now only +* Javascript variables 'wgFileCanRotate' and 'wgFileExtensions' now only available on Special:Upload. * (T58257) Set site logo from mediawiki.skinning.interface module instead of inline styles in the HTML. @@ -291,6 +338,21 @@ changes to languages because of Bugzilla reports. rather than as strings that must be prepended or appended to $comment. * (T30950, T31025) RFC, PMID, and ISBN "magic links" can no longer contain newlines; but they can contain   and other non-newline whitespace. +* The 'mediawiki.action.edit' ResourceLoader module no longer generates the edit + toolbar, which has been moved to a separate 'mediawiki.toolbar' module. If you + relied on this behavior, update your scripts' dependencies. +* HTMLForm's 'vform' display style has been separated to a subclass. Therefore: + * HTMLForm::isVForm() is now deprecated. + * You can no longer do this: + $form = new HTMLForm( … ); + $form->setDisplayFormat( 'vform' ); // throws exception + Instead, do this: + $form = HTMLForm::factory( 'vform', … ); +* Deprecated Revision methods getRawUser(), getRawUserText() and getRawComment(). +* BREAKING CHANGE: mediawiki.user.generateRandomSessionId: + The alphabet of the prior string returned was A-Za-z0-9 and now it is 0-9A-F +* (T87504) Avoid serving SVG background-images in CSS for Opera 12, which + renders them incorrectly when combined with border-radius or background-size. == Compatibility == diff --git a/StartProfiler.sample b/StartProfiler.sample index d20c0e1bd9..4721a9dde3 100644 --- a/StartProfiler.sample +++ b/StartProfiler.sample @@ -1,10 +1,7 @@ __DIR__ . '/includes/api/ApiLogin.php', 'ApiLogout' => __DIR__ . '/includes/api/ApiLogout.php', 'ApiMain' => __DIR__ . '/includes/api/ApiMain.php', + 'ApiManageTags' => __DIR__ . '/includes/api/ApiManageTags.php', 'ApiModuleManager' => __DIR__ . '/includes/api/ApiModuleManager.php', 'ApiMove' => __DIR__ . '/includes/api/ApiMove.php', 'ApiOpenSearch' => __DIR__ . '/includes/api/ApiOpenSearch.php', @@ -244,6 +245,7 @@ $wgAutoloadLocalClasses = array( 'ContentHandler' => __DIR__ . '/includes/content/ContentHandler.php', 'ContextSource' => __DIR__ . '/includes/context/ContextSource.php', 'ContribsPager' => __DIR__ . '/includes/specials/SpecialContributions.php', + 'ConvertExtensionToRegistration' => __DIR__ . '/maintenance/convertExtensionToRegistration.php', 'ConvertLinks' => __DIR__ . '/maintenance/convertLinks.php', 'ConvertUserOptions' => __DIR__ . '/maintenance/convertUserOptions.php', 'ConverterRule' => __DIR__ . '/languages/ConverterRule.php', @@ -291,6 +293,7 @@ $wgAutoloadLocalClasses = array( 'DateFormatter' => __DIR__ . '/includes/parser/DateFormatter.php', 'DeadendPagesPage' => __DIR__ . '/includes/specials/SpecialDeadendpages.php', 'DeferrableUpdate' => __DIR__ . '/includes/deferred/DeferredUpdates.php', + 'DeferredStringifier' => __DIR__ . '/includes/libs/DeferredStringifier.php', 'DeferredUpdates' => __DIR__ . '/includes/deferred/DeferredUpdates.php', 'DeleteAction' => __DIR__ . '/includes/actions/DeleteAction.php', 'DeleteArchivedFiles' => __DIR__ . '/maintenance/deleteArchivedFiles.php', @@ -330,7 +333,7 @@ $wgAutoloadLocalClasses = array( 'DjVuImage' => __DIR__ . '/includes/media/DjVuImage.php', 'DoubleRedirectJob' => __DIR__ . '/includes/jobqueue/jobs/DoubleRedirectJob.php', 'DoubleRedirectsPage' => __DIR__ . '/includes/specials/SpecialDoubleRedirects.php', - 'DoubleReplacer' => __DIR__ . '/includes/utils/StringUtils.php', + 'DoubleReplacer' => __DIR__ . '/includes/libs/replacers/DoubleReplacer.php', 'DummyLinker' => __DIR__ . '/includes/Linker.php', 'DummyTermColorer' => __DIR__ . '/maintenance/term/MWTerm.php', 'Dump7ZipOutput' => __DIR__ . '/includes/Export.php', @@ -370,9 +373,11 @@ $wgAutoloadLocalClasses = array( 'ErrorPageError' => __DIR__ . '/includes/exception/ErrorPageError.php', 'Exif' => __DIR__ . '/includes/media/Exif.php', 'ExifBitmapHandler' => __DIR__ . '/includes/media/ExifBitmap.php', - 'ExplodeIterator' => __DIR__ . '/includes/utils/StringUtils.php', + 'ExplodeIterator' => __DIR__ . '/includes/libs/ExplodeIterator.php', 'ExportProgressFilter' => __DIR__ . '/maintenance/backup.inc', 'ExtensionLanguages' => __DIR__ . '/maintenance/language/languages.inc', + 'ExtensionProcessor' => __DIR__ . '/includes/registration/ExtensionProcessor.php', + 'ExtensionRegistry' => __DIR__ . '/includes/registration/ExtensionRegistry.php', 'ExternalStore' => __DIR__ . '/includes/externalstore/ExternalStore.php', 'ExternalStoreDB' => __DIR__ . '/includes/externalstore/ExternalStoreDB.php', 'ExternalStoreHttp' => __DIR__ . '/includes/externalstore/ExternalStoreHttp.php', @@ -431,6 +436,8 @@ $wgAutoloadLocalClasses = array( 'ForeignDBFile' => __DIR__ . '/includes/filerepo/file/ForeignDBFile.php', 'ForeignDBRepo' => __DIR__ . '/includes/filerepo/ForeignDBRepo.php', 'ForeignDBViaLBRepo' => __DIR__ . '/includes/filerepo/ForeignDBViaLBRepo.php', + 'ForeignTitle' => __DIR__ . '/includes/title/ForeignTitle.php', + 'ForeignTitleFactory' => __DIR__ . '/includes/title/ForeignTitleFactory.php', 'ForkController' => __DIR__ . '/includes/ForkController.php', 'FormAction' => __DIR__ . '/includes/actions/FormAction.php', 'FormOptions' => __DIR__ . '/includes/FormOptions.php', @@ -489,7 +496,7 @@ $wgAutoloadLocalClasses = array( 'HashBagOStuff' => __DIR__ . '/includes/objectcache/HashBagOStuff.php', 'HashConfig' => __DIR__ . '/includes/config/HashConfig.php', 'HashRing' => __DIR__ . '/includes/libs/HashRing.php', - 'HashtableReplacer' => __DIR__ . '/includes/utils/StringUtils.php', + 'HashtableReplacer' => __DIR__ . '/includes/libs/replacers/HashtableReplacer.php', 'HistoryAction' => __DIR__ . '/includes/actions/HistoryAction.php', 'HistoryBlob' => __DIR__ . '/includes/HistoryBlob.php', 'HistoryBlobCurStub' => __DIR__ . '/includes/HistoryBlob.php', @@ -528,8 +535,10 @@ $wgAutoloadLocalClasses = array( 'ImageQueryPage' => __DIR__ . '/includes/specialpage/ImageQueryPage.php', 'ImportReporter' => __DIR__ . '/includes/specials/SpecialImport.php', 'ImportSiteScripts' => __DIR__ . '/maintenance/importSiteScripts.php', + 'ImportSource' => __DIR__ . '/includes/Import.php', 'ImportStreamSource' => __DIR__ . '/includes/Import.php', 'ImportStringSource' => __DIR__ . '/includes/Import.php', + 'ImportTitleFactory' => __DIR__ . '/includes/title/ImportTitleFactory.php', 'IncludableSpecialPage' => __DIR__ . '/includes/specialpage/IncludableSpecialPage.php', 'IndexPager' => __DIR__ . '/includes/pager/IndexPager.php', 'InfoAction' => __DIR__ . '/includes/actions/InfoAction.php', @@ -683,6 +692,7 @@ $wgAutoloadLocalClasses = array( 'MWHookException' => __DIR__ . '/includes/Hooks.php', 'MWHttpRequest' => __DIR__ . '/includes/HttpFunctions.php', 'MWLogger' => __DIR__ . '/includes/debug/logger/Logger.php', + 'MWLoggerFactory' => __DIR__ . '/includes/debug/logger/Factory.php', 'MWLoggerLegacyLogger' => __DIR__ . '/includes/debug/logger/legacy/Logger.php', 'MWLoggerLegacySpi' => __DIR__ . '/includes/debug/logger/legacy/Spi.php', 'MWLoggerMonologHandler' => __DIR__ . '/includes/debug/logger/monolog/Handler.php', @@ -690,6 +700,7 @@ $wgAutoloadLocalClasses = array( 'MWLoggerMonologProcessor' => __DIR__ . '/includes/debug/logger/monolog/Processor.php', 'MWLoggerMonologSamplingHandler' => __DIR__ . '/includes/debug/logger/monolog/SamplingHandler.php', 'MWLoggerMonologSpi' => __DIR__ . '/includes/debug/logger/monolog/Spi.php', + 'MWLoggerMonologSyslogHandler' => __DIR__ . '/includes/debug/logger/monolog/SyslogHandler.php', 'MWLoggerNullSpi' => __DIR__ . '/includes/debug/logger/NullSpi.php', 'MWLoggerSpi' => __DIR__ . '/includes/debug/logger/Spi.php', 'MWMemcached' => __DIR__ . '/includes/objectcache/MemcachedClient.php', @@ -716,9 +727,9 @@ $wgAutoloadLocalClasses = array( 'MediaHandler' => __DIR__ . '/includes/media/MediaHandler.php', 'MediaStatisticsPage' => __DIR__ . '/includes/specials/SpecialMediaStatistics.php', 'MediaTransformError' => __DIR__ . '/includes/media/MediaTransformOutput.php', + 'MediaTransformInvalidParametersException' => __DIR__ . '/includes/media/MediaTransformInvalidParametersException.php', 'MediaTransformOutput' => __DIR__ . '/includes/media/MediaTransformOutput.php', 'MediaWiki' => __DIR__ . '/includes/MediaWiki.php', - 'MediaWikiBagOStuff' => __DIR__ . '/includes/objectcache/SqlBagOStuff.php', 'MediaWikiI18N' => __DIR__ . '/includes/skins/MediaWikiI18N.php', 'MediaWikiPageLinkRenderer' => __DIR__ . '/includes/title/MediaWikiPageLinkRenderer.php', 'MediaWikiSite' => __DIR__ . '/includes/site/MediaWikiSite.php', @@ -737,6 +748,7 @@ $wgAutoloadLocalClasses = array( 'MessageBlobStore' => __DIR__ . '/includes/MessageBlobStore.php', 'MessageCache' => __DIR__ . '/includes/cache/MessageCache.php', 'MessageContent' => __DIR__ . '/includes/content/MessageContent.php', + 'MessageSpecifier' => __DIR__ . '/includes/libs/MessageSpecifier.php', 'MigrateUserGroup' => __DIR__ . '/maintenance/migrateUserGroup.php', 'MimeMagic' => __DIR__ . '/includes/MimeMagic.php', 'MinifyScript' => __DIR__ . '/maintenance/minify.php', @@ -768,7 +780,11 @@ $wgAutoloadLocalClasses = array( 'MySqlLockManager' => __DIR__ . '/includes/filebackend/lockmanager/DBLockManager.php', 'MysqlInstaller' => __DIR__ . '/includes/installer/MysqlInstaller.php', 'MysqlUpdater' => __DIR__ . '/includes/installer/MysqlUpdater.php', + 'NaiveForeignTitleFactory' => __DIR__ . '/includes/title/NaiveForeignTitleFactory.php', + 'NaiveImportTitleFactory' => __DIR__ . '/includes/title/NaiveImportTitleFactory.php', + 'NamespaceAwareForeignTitleFactory' => __DIR__ . '/includes/title/NamespaceAwareForeignTitleFactory.php', 'NamespaceConflictChecker' => __DIR__ . '/maintenance/namespaceDupes.php', + 'NamespaceImportTitleFactory' => __DIR__ . '/includes/title/NamespaceImportTitleFactory.php', 'NewFilesPager' => __DIR__ . '/includes/specials/SpecialNewimages.php', 'NewPagesPager' => __DIR__ . '/includes/specials/SpecialNewpages.php', 'NewUsersLogFormatter' => __DIR__ . '/includes/logging/NewUsersLogFormatter.php', @@ -838,6 +854,7 @@ $wgAutoloadLocalClasses = array( 'ParserDiffTest' => __DIR__ . '/includes/parser/ParserDiffTest.php', 'ParserOptions' => __DIR__ . '/includes/parser/ParserOptions.php', 'ParserOutput' => __DIR__ . '/includes/parser/ParserOutput.php', + 'ParsoidVirtualRESTService' => __DIR__ . '/includes/libs/virtualrest/ParsoidVirtualRESTService.php', 'Password' => __DIR__ . '/includes/password/Password.php', 'PasswordError' => __DIR__ . '/includes/password/PasswordError.php', 'PasswordFactory' => __DIR__ . '/includes/password/PasswordFactory.php', @@ -880,14 +897,14 @@ $wgAutoloadLocalClasses = array( 'Preprocessor_DOM' => __DIR__ . '/includes/parser/Preprocessor_DOM.php', 'Preprocessor_Hash' => __DIR__ . '/includes/parser/Preprocessor_Hash.php', 'ProcessCacheLRU' => __DIR__ . '/includes/libs/ProcessCacheLRU.php', + 'Processor' => __DIR__ . '/includes/registration/Processor.php', 'ProfileSection' => __DIR__ . '/includes/profiler/ProfileSection.php', 'Profiler' => __DIR__ . '/includes/profiler/Profiler.php', 'ProfilerOutput' => __DIR__ . '/includes/profiler/output/ProfilerOutput.php', 'ProfilerOutputDb' => __DIR__ . '/includes/profiler/output/ProfilerOutputDb.php', 'ProfilerOutputText' => __DIR__ . '/includes/profiler/output/ProfilerOutputText.php', 'ProfilerOutputUdp' => __DIR__ . '/includes/profiler/output/ProfilerOutputUdp.php', - 'ProfilerSimpleTrace' => __DIR__ . '/includes/profiler/ProfilerSimpleTrace.php', - 'ProfilerStandard' => __DIR__ . '/includes/profiler/ProfilerStandard.php', + 'ProfilerSectionOnly' => __DIR__ . '/includes/profiler/ProfilerSectionOnly.php', 'ProfilerStub' => __DIR__ . '/includes/profiler/ProfilerStub.php', 'ProfilerXhprof' => __DIR__ . '/includes/profiler/ProfilerXhprof.php', 'Protect' => __DIR__ . '/maintenance/protect.php', @@ -926,6 +943,7 @@ $wgAutoloadLocalClasses = array( 'RebuildSitesCache' => __DIR__ . '/maintenance/rebuildSitesCache.php', 'RebuildTextIndex' => __DIR__ . '/maintenance/rebuildtextindex.php', 'RecentChange' => __DIR__ . '/includes/changes/RecentChange.php', + 'RecentChangesUpdateJob' => __DIR__ . '/includes/jobqueue/jobs/RecentChangesUpdateJob.php', 'RecompressTracked' => __DIR__ . '/maintenance/storage/recompressTracked.php', 'RedirectSpecialArticle' => __DIR__ . '/includes/specialpage/RedirectSpecialPage.php', 'RedirectSpecialPage' => __DIR__ . '/includes/specialpage/RedirectSpecialPage.php', @@ -938,14 +956,13 @@ $wgAutoloadLocalClasses = array( 'RefreshImageMetadata' => __DIR__ . '/maintenance/refreshImageMetadata.php', 'RefreshLinks' => __DIR__ . '/maintenance/refreshLinks.php', 'RefreshLinksJob' => __DIR__ . '/includes/jobqueue/jobs/RefreshLinksJob.php', - 'RefreshLinksJob2' => __DIR__ . '/includes/jobqueue/jobs/RefreshLinksJob2.php', - 'RegexlikeReplacer' => __DIR__ . '/includes/utils/StringUtils.php', + 'RegexlikeReplacer' => __DIR__ . '/includes/libs/replacers/RegexlikeReplacer.php', 'RemoveInvalidEmails' => __DIR__ . '/maintenance/removeInvalidEmails.php', 'RemoveUnusedAccounts' => __DIR__ . '/maintenance/removeUnusedAccounts.php', 'RenameDbPrefix' => __DIR__ . '/maintenance/renameDbPrefix.php', 'RenderAction' => __DIR__ . '/includes/actions/RenderAction.php', - 'ReplacementArray' => __DIR__ . '/includes/utils/StringUtils.php', - 'Replacer' => __DIR__ . '/includes/utils/StringUtils.php', + 'ReplacementArray' => __DIR__ . '/includes/libs/ReplacementArray.php', + 'Replacer' => __DIR__ . '/includes/libs/replacers/Replacer.php', 'RepoGroup' => __DIR__ . '/includes/filerepo/RepoGroup.php', 'RequestContext' => __DIR__ . '/includes/context/RequestContext.php', 'ResetUserTokens' => __DIR__ . '/maintenance/resetUserTokens.php', @@ -1138,15 +1155,17 @@ $wgAutoloadLocalClasses = array( 'StatCounter' => __DIR__ . '/includes/StatCounter.php', 'StatsOutput' => __DIR__ . '/maintenance/language/StatOutputs.php', 'Status' => __DIR__ . '/includes/Status.php', + 'StatusValue' => __DIR__ . '/includes/libs/StatusValue.php', 'StorageTypeStats' => __DIR__ . '/maintenance/storage/storageTypeStats.php', 'StoreFileOp' => __DIR__ . '/includes/filebackend/FileOp.php', 'StreamFile' => __DIR__ . '/includes/StreamFile.php', 'StringPrefixSearch' => __DIR__ . '/includes/PrefixSearch.php', - 'StringUtils' => __DIR__ . '/includes/utils/StringUtils.php', + 'StringUtils' => __DIR__ . '/includes/libs/StringUtils.php', 'StripState' => __DIR__ . '/includes/parser/StripState.php', 'StubObject' => __DIR__ . '/includes/StubObject.php', 'StubUserLang' => __DIR__ . '/includes/StubObject.php', 'SubmitAction' => __DIR__ . '/includes/actions/SubmitAction.php', + 'SubpageImportTitleFactory' => __DIR__ . '/includes/title/SubpageImportTitleFactory.php', 'SvgHandler' => __DIR__ . '/includes/media/SVG.php', 'SwiftFileBackend' => __DIR__ . '/includes/filebackend/SwiftFileBackend.php', 'SwiftFileBackendDirList' => __DIR__ . '/includes/filebackend/SwiftFileBackend.php', @@ -1254,8 +1273,10 @@ $wgAutoloadLocalClasses = array( 'UserloginTemplate' => __DIR__ . '/includes/templates/Userlogin.php', 'UserrightsPage' => __DIR__ . '/includes/specials/SpecialUserrights.php', 'UsersPager' => __DIR__ . '/includes/specials/SpecialListusers.php', - 'UtfNormal' => __DIR__ . '/includes/normal/UtfNormal.php', + 'UtfNormal' => __DIR__ . '/includes/libs/normal/UtfNormal.php', 'UzConverter' => __DIR__ . '/languages/classes/LanguageUz.php', + 'VFormHTMLForm' => __DIR__ . '/includes/htmlform/VFormHTMLForm.php', + 'ValidateRegistrationFile' => __DIR__ . '/maintenance/validateRegistrationFile.php', 'ViewAction' => __DIR__ . '/includes/actions/ViewAction.php', 'VirtualRESTService' => __DIR__ . '/includes/libs/virtualrest/VirtualRESTService.php', 'VirtualRESTServiceClient' => __DIR__ . '/includes/libs/virtualrest/VirtualRESTServiceClient.php', diff --git a/composer.json b/composer.json index 8aeb7923dc..75aed40070 100644 --- a/composer.json +++ b/composer.json @@ -9,22 +9,25 @@ "homepage": "https://www.mediawiki.org/wiki/Special:Version/Credits" } ], - "license": "GPL-2.0", + "license": "GPL-2.0+", "support": { "issues": "https://bugs.mediawiki.org/", "irc": "irc://irc.freenode.net/mediawiki", "wiki": "https://www.mediawiki.org/" }, "require": { + "cssjanus/cssjanus": "1.1.1", "leafo/lessphp": "0.5.0", + "oojs/oojs-ui": "0.8.0", "php": ">=5.3.3", "psr/log": "1.0.0", - "cssjanus/cssjanus": "1.1.1", "wikimedia/cdb": "1.0.1", - "oojs/oojs-ui": "0.6.0" + "wikimedia/composer-merge-plugin": "0.5.0", + "zordius/lightncandy": "0.18" }, "require-dev": { - "phpunit/phpunit": "*" + "justinrainbow/json-schema": "~1.3", + "phpunit/phpunit": "~4.5" }, "suggest": { "ext-fileinfo": "*", @@ -45,5 +48,12 @@ "config": { "prepend-autoloader": false, "optimize-autoloader": true + }, + "extra": { + "merge-plugin": { + "include": [ + "composer.local.json" + ] + } } } diff --git a/docs/extension.schema.json b/docs/extension.schema.json new file mode 100644 index 0000000000..33029bd436 --- /dev/null +++ b/docs/extension.schema.json @@ -0,0 +1,625 @@ +{ + "$schema": "http://json-schema.org/schema#", + "description": "MediaWiki extension.json schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The extension's canonical name." + }, + "type": { + "type": "string", + "description": "The extension's type, as an index to $wgExtensionCredits.", + "default": "other", + "enum": [ + "api", + "antispam", + "datavalues", + "media", + "parserhook", + "semantic", + "skin", + "specialpage", + "variable", + "other" + ] + }, + "author": { + "type": [ + "string", + "array" + ], + "description": "Extension's authors.", + "items": { + "type": "string" + }, + "additionalItems": false + }, + "version": { + "type": "string", + "description": "The version of this release of the extension." + }, + "url": { + "type": "string", + "description": "URL to the homepage for the extension.", + "format": "uri" + }, + "description": { + "type": "string", + "description": "Raw description of the extension." + }, + "descriptionmsg": { + "type": "string", + "description": "Message key for a i18n message describing the extension." + }, + "license-name": { + "type": "string", + "description": "Short identifier for the license under which the extension is released.", + "enum": [ + "AFL-1.1", + "AFL-1.2", + "AFL-2.0", + "AFL-2.1", + "AFL-3.0", + "APL-1.0", + "Aladdin", + "ANTLR-PD", + "Apache-1.0", + "Apache-1.1", + "Apache-2.0", + "APSL-1.0", + "APSL-1.1", + "APSL-1.2", + "APSL-2.0", + "Artistic-1.0", + "Artistic-1.0-cl8", + "Artistic-1.0-Perl", + "Artistic-2.0", + "AAL", + "BitTorrent-1.0", + "BitTorrent-1.1", + "BSL-1.0", + "BSD-2-Clause", + "BSD-2-Clause-FreeBSD", + "BSD-2-Clause-NetBSD", + "BSD-3-Clause", + "BSD-3-Clause-Clear", + "BSD-4-Clause", + "BSD-4-Clause-UC", + "CECILL-1.0", + "CECILL-1.1", + "CECILL-2.0", + "CECILL-B", + "CECILL-C", + "ClArtistic", + "CNRI-Python", + "CNRI-Python-GPL-Compatible", + "CPOL-1.02", + "CDDL-1.0", + "CDDL-1.1", + "CPAL-1.0", + "CPL-1.0", + "CATOSL-1.1", + "Condor-1.1", + "CC-BY-1.0", + "CC-BY-2.0", + "CC-BY-2.5", + "CC-BY-3.0", + "CC-BY-ND-1.0", + "CC-BY-ND-2.0", + "CC-BY-ND-2.5", + "CC-BY-ND-3.0", + "CC-BY-NC-1.0", + "CC-BY-NC-2.0", + "CC-BY-NC-2.5", + "CC-BY-NC-3.0", + "CC-BY-NC-ND-1.0", + "CC-BY-NC-ND-2.0", + "CC-BY-NC-ND-2.5", + "CC-BY-NC-ND-3.0", + "CC-BY-NC-SA-1.0", + "CC-BY-NC-SA-2.0", + "CC-BY-NC-SA-2.5", + "CC-BY-NC-SA-3.0", + "CC-BY-SA-1.0", + "CC-BY-SA-2.0", + "CC-BY-SA-2.5", + "CC-BY-SA-3.0", + "CC0-1.0", + "CUA-OPL-1.0", + "D-FSL-1.0", + "WTFPL", + "EPL-1.0", + "eCos-2.0", + "ECL-1.0", + "ECL-2.0", + "EFL-1.0", + "EFL-2.0", + "Entessa", + "ErlPL-1.1", + "EUDatagrid", + "EUPL-1.0", + "EUPL-1.1", + "Fair", + "Frameworx-1.0", + "FTL", + "AGPL-1.0", + "AGPL-3.0", + "GFDL-1.1", + "GFDL-1.2", + "GFDL-1.3", + "GPL-1.0", + "GPL-1.0+", + "GPL-2.0", + "GPL-2.0+", + "GPL-2.0-with-autoconf-exception", + "GPL-2.0-with-bison-exception", + "GPL-2.0-with-classpath-exception", + "GPL-2.0-with-font-exception", + "GPL-2.0-with-GCC-exception", + "GPL-3.0", + "GPL-3.0+", + "GPL-3.0-with-autoconf-exception", + "GPL-3.0-with-GCC-exception", + "LGPL-2.1", + "LGPL-2.1+", + "LGPL-3.0", + "LGPL-3.0+", + "LGPL-2.0", + "LGPL-2.0+", + "gSOAP-1.3b", + "HPND", + "IBM-pibs", + "IPL-1.0", + "Imlib2", + "IJG", + "Intel", + "IPA", + "ISC", + "JSON", + "LPPL-1.3a", + "LPPL-1.0", + "LPPL-1.1", + "LPPL-1.2", + "LPPL-1.3c", + "Libpng", + "LPL-1.02", + "LPL-1.0", + "MS-PL", + "MS-RL", + "MirOS", + "MIT", + "Motosoto", + "MPL-1.0", + "MPL-1.1", + "MPL-2.0", + "MPL-2.0-no-copyleft-exception", + "Multics", + "NASA-1.3", + "Naumen", + "NBPL-1.0", + "NGPL", + "NOSL", + "NPL-1.0", + "NPL-1.1", + "Nokia", + "NPOSL-3.0", + "NTP", + "OCLC-2.0", + "ODbL-1.0", + "PDDL-1.0", + "OGTSL", + "OLDAP-2.2.2", + "OLDAP-1.1", + "OLDAP-1.2", + "OLDAP-1.3", + "OLDAP-1.4", + "OLDAP-2.0", + "OLDAP-2.0.1", + "OLDAP-2.1", + "OLDAP-2.2", + "OLDAP-2.2.1", + "OLDAP-2.3", + "OLDAP-2.4", + "OLDAP-2.5", + "OLDAP-2.6", + "OLDAP-2.7", + "OPL-1.0", + "OSL-1.0", + "OSL-2.0", + "OSL-2.1", + "OSL-3.0", + "OLDAP-2.8", + "OpenSSL", + "PHP-3.0", + "PHP-3.01", + "PostgreSQL", + "Python-2.0", + "QPL-1.0", + "RPSL-1.0", + "RPL-1.1", + "RPL-1.5", + "RHeCos-1.1", + "RSCPL", + "Ruby", + "SAX-PD", + "SGI-B-1.0", + "SGI-B-1.1", + "SGI-B-2.0", + "OFL-1.0", + "OFL-1.1", + "SimPL-2.0", + "Sleepycat", + "SMLNJ", + "SugarCRM-1.1.3", + "SISSL", + "SISSL-1.2", + "SPL-1.0", + "Watcom-1.0", + "NCSA", + "VSL-1.0", + "W3C", + "WXwindows", + "Xnet", + "X11", + "XFree86-1.1", + "YPL-1.0", + "YPL-1.1", + "Zimbra-1.3", + "Zlib", + "ZPL-1.1", + "ZPL-2.0", + "ZPL-2.1", + "Unlicense" + ] + }, + "ResourceFileModulePaths": { + "type": "object", + "description": "Default paths to use for all ResourceLoader file modules", + "additionalProperties": false, + "properties": { + "localBasePath": { + "type": "string", + "description": "Base path to prepend to all local paths, relative to current directory" + }, + "remoteExtPath": { + "type": "string", + "description": "Base path to prepend to all remote paths, relative to $wgExtensionAssetsPath" + }, + "remoteSkinPath": { + "type": "string", + "description": "Base path to prepend to all remote paths, relative to $wgStylePath" + } + } + }, + "ResourceLoaderModules": { + "type": "object", + "description": "ResourceLoader modules to register", + "additionalProperties": false, + "patternProperties": { + "^[a-zA-Z0-9\\.]+$": { + "type": "object", + "description": "A single ResourceLoader module descriptor", + "properties": { + "localBasePath": { + "type": "string", + "description": "Base path to prepend to all local paths in $options. Defaults to $IP" + }, + "remoteBasePath": { + "type": "string", + "description": "Base path to prepend to all remote paths in $options. Defaults to $wgScriptPath" + }, + "remoteExtPath": { + "type": "string", + "description": "Equivalent of remoteBasePath, but relative to $wgExtensionAssetsPath" + }, + "scripts": { + "type": "array", + "description": "Scripts to always include (array of file paths)", + "items": { + "type": "string" + } + }, + "languageScripts": { + "type": "object", + "description": "Scripts to include in specific language contexts (mapping of language code to file path(s))", + "patternProperties": { + "^[a-zA-Z0-9-]{2,}$": { + "type": [ + "string", + "array" + ], + "items": { + "type": "string" + } + } + } + }, + "skinScripts": { + "type": "object", + "description": "Scripts to include in specific skin contexts (mapping of skin name to script(s)", + "patternProperties": { + ".+": { + "type": [ + "string", + "array" + ], + "items": { + "type": "string" + } + } + } + }, + "debugScripts": { + "type": "array", + "description": "Scripts to include in debug contexts", + "items": { + "type": "string" + } + }, + "loaderScripts": { + "type": "array", + "description": "Scripts to include in the startup module", + "items": { + "type": "string" + } + }, + "dependencies": { + "type": "array", + "description": "Modules which must be loaded before this module", + "items": { + "type": "string" + } + }, + "styles": { + "type": "array", + "description": "Styles to always load", + "items": { + "type": "string" + } + }, + "skinStyles": { + "type": "object", + "description": "Styles to include in specific skin contexts (mapping of skin name to style(s))", + "patternProperties": { + ".+": { + "type": [ + "string", + "array" + ], + "items": { + "type": "string" + } + } + } + }, + "messages": { + "type": "array", + "description": "Messages to always load", + "items": { + "type": "string" + } + }, + "group": { + "type": "string", + "description": "Group which this module should be loaded together with" + }, + "position": { + "type": "string", + "description": "Position on the page to load this module at", + "enum": [ + "bottom", + "top" + ] + } + } + } + } + }, + "ResourceLoaderSources": { + "type": "object", + "description": "ResourceLoader sources to register" + }, + "ResourceLoaderLESSVars": { + "type": "object", + "description": "ResourceLoader LESS variables" + }, + "ResourceLoaderLESSFunctions": { + "type": "object", + "description": "ResourceLoader LESS functions" + }, + "ResourceLoaderLESSImportPaths": { + "type": "object", + "description": "ResourceLoader import paths" + }, + "ConfigRegistry": { + "type": "object", + "description": "Registry of factory functions to create Config objects" + }, + "namespaces": { + "type": "object", + "description": "Method to add extra namespaces", + "properties": { + "id": { + "type": "integer" + }, + "constant": { + "type": "string" + }, + "name": { + "type": "string" + }, + "gender": { + "type": "object", + "properties": { + "male": { + "type": "string" + }, + "female": { + "type": "string" + } + } + }, + "subpages": { + "type": "boolean", + "default": false + }, + "content": { + "type": "boolean", + "default": false + }, + "defaultcontentmodel": { + "type": "string" + } + } + }, + "TrackingCategories": { + "type": "array", + "description": "Tracking category message keys" + }, + "DefaultUserOptions": { + "type": "object", + "description": "Default values of user options" + }, + "HiddenPrefs": { + "type": "array", + "description": "Preferences users cannot set" + }, + "GroupPermissions": { + "type": "object", + "description": "Default permissions to give to user groups" + }, + "RevokePermissions": { + "type": "object", + "description": "Default permissions to revoke from user groups" + }, + "ImplicitGroups": { + "type": "array", + "description": "Implicit groups" + }, + "GroupsAddToSelf": { + "type": "object", + "description": "Groups a user can add to themselves" + }, + "GroupsRemoveFromSelf": { + "type": "object", + "description": "Groups a user can remove from themselves" + }, + "AddGroups": { + "type": "object", + "description": "Groups a user can add to users" + }, + "RemoveGroups": { + "type": "object", + "description": "Groups a user can remove from users" + }, + "AvailableRights": { + "type": "array", + "description": "User rights added by the extension" + }, + "ContentHandlers": { + "type": "object", + "description": "Mapping of model ID to class name" + }, + "RateLimits": { + "type": "object", + "description": "Rate limits" + }, + "ParserTestFiles": { + "type": "array", + "description": "Parser test files to run" + }, + "RecentChangesFlags": { + "type": "object", + "description": "Flags (letter symbols) shown on RecentChanges pages" + }, + "ExtensionFunctions": { + "type": [ + "array", + "string" + ], + "description": "Function to call after setup has finished" + }, + "ExtensionMessagesFiles": { + "type": "object", + "description": "File paths containing PHP internationalization data" + }, + "MessagesDirs": { + "type": "object", + "description": "Directory paths containing JSON internationalization data" + }, + "ExtensionEntryPointListFiles": { + "type": "object" + }, + "SpecialPages": { + "type": "object", + "description": "SpecialPages implemented in this extension (mapping of page name to class name)" + }, + "SpecialPageGroups": { + "type": "object", + "description": "Mapping of special page name to group it belongs to" + }, + "AutoloadClasses": { + "type": "object" + }, + "Hooks": { + "type": "object", + "description": "Hooks this extension uses (mapping of hook name to callback)" + }, + "JobClasses": { + "type": "object", + "description": "Job types this extension implements (mapping of job type to class name)" + }, + "LogTypes": { + "type": "array", + "description": "List of new log types this extension uses" + }, + "LogRestrictions": { + "type": "object" + }, + "FilterLogTypes": { + "type": "array" + }, + "LogNames": { + "type": "object" + }, + "LogHeaders": { + "type": "object" + }, + "LogActions": { + "type": "object" + }, + "LogActionsHandlers": { + "type": "object" + }, + "Actions": { + "type": "object" + }, + "APIModules": { + "type": "object" + }, + "APIFormatModules": { + "type": "object" + }, + "APIMetaModules": { + "type": "object" + }, + "APIPropModules": { + "type": "object" + }, + "APIListModules": { + "type": "object" + }, + "callback": { + "type": [ + "array", + "string" + ], + "description": "A function to be called right after MediaWiki processes this file" + }, + "config": { + "type": "object", + "description": "Configuration options for this extension" + } + } +} diff --git a/docs/hooks.txt b/docs/hooks.txt index b48067bfa3..f47890d8da 100644 --- a/docs/hooks.txt +++ b/docs/hooks.txt @@ -314,7 +314,7 @@ $output: The OutputPage object where output() was called 'AfterImportPage': When a page import is completed. $title: Title under which the revisions were imported -$origTitle: Title provided by the XML file +$foreignTitle: ForeignTitle object based on data provided by the XML file $revCount: Number of revisions in the XML file $sRevCount: Number of successfully imported revisions $pageInfo: associative array of page information @@ -372,6 +372,17 @@ $editPage : the EditPage object $text : the new text of the article (has yet to be saved) &$resultArr : data in this array will be added to the API result +'ApiFeedContributions::feedItem': Called to convert the result of ContribsPager +into a FeedItem instance that ApiFeedContributions can consume. Implementors of +this hook may cancel the hook to signal that the item is not viewable in the +provided context. +$row: A row of data from ContribsPager. The set of data returned by ContribsPager + can be adjusted by handling the ContribsPager::reallyDoQuery hook. +$context: An IContextSource implementation. +&$feedItem: Set this to a FeedItem instance if the callback can handle the provided + row. This is provided to the hook as a null, if it is non null then another callback + has already handled the hook. + 'ApiFormatHighlight': Use to syntax-highlight API pretty-printed output. When highlighting, add output to $context->getOutput() and return false. $context: An IContextSource. @@ -892,6 +903,38 @@ $name: name of the special page, e.g. 'Watchlist' &$join_conds: join conditions for the tables $opts: FormOptions for this request +'ChangeTagAfterDelete': Called after a change tag has been deleted (that is, +removed from all revisions and log entries to which it was applied). This gives +extensions a chance to take it off their books. +$tag: name of the tag +&$status: Status object. Add warnings to this as required. There is no point + setting errors, as the deletion has already been partly carried out by this + point. + +'ChangeTagCanCreate': Tell whether a change tag should be able to be created +from the UI (Special:Tags) or via the API. You could use this hook if you want +to reserve a specific "namespace" of tags, or something similar. +$tag: name of the tag +$user: user initiating the action +&$status: Status object. Add your errors using `$status->fatal()` or warnings + using `$status->warning()`. Errors and warnings will be relayed to the user. + If you set an error, the user will be unable to create the tag. + +'ChangeTagCanDelete': Tell whether a change tag should be able to be +deleted from the UI (Special:Tags) or via the API. The default is that tags +defined using the ListDefinedTags hook are not allowed to be deleted unless +specifically allowed. If you wish to allow deletion of the tag, set +`$status = Status::newGood()` to allow deletion, and then `return false` from +the hook function. Ensure you consume the 'ChangeTagAfterDelete' hook to carry +out custom deletion actions. +$tag: name of the tag +$user: user initiating the action +&$status: Status object. See above. + +'ChangeTagsListActive': Allows you to nominate which of the tags your extension +uses are in active use. +&$tags: list of all active tags. Append to this array. + 'LoginUserMigrated': Called during login to allow extensions the opportunity to inform a user that their username doesn't exist for a specific reason, instead of letting the login form give the generic error message that the account does @@ -2234,6 +2277,10 @@ configuration variables to JavaScript. Things that depend on the current page or request state must be added through MakeGlobalVariablesScript instead. &$vars: array( variable name => value ) +'ResourceLoaderGetLessVars': Called in ResourceLoader::getLessVars after variables +from $wgResourceLoaderLESSVars are added. Can be used to add context-based variables. +&$lessVars: array of variables already added + 'ResourceLoaderRegisterModules': Right before modules information is required, such as when responding to a resource loader request or generating HTML output. diff --git a/docs/kss/Makefile b/docs/kss/Makefile index 31feec1a1c..dadfb47c63 100644 --- a/docs/kss/Makefile +++ b/docs/kss/Makefile @@ -4,9 +4,9 @@ kss: kssnodecheck # Generates CSS of mediawiki.ui and mediawiki.ui.button using ResourceLoader, then applies it to the # KSS style guide $(eval KSS_RL_TMP := $(shell mktemp /tmp/tmp.XXXXXXXXXX)) -# Keep module names in strict alphabetical order, so CSS loads in the same order as ResourceLoader's addModuleStyles does; this can affect rendering. + $(eval MODULE_STR := $(shell paste -sd "|" styleGuideModules.txt)) # See OutputPage::makeResourceLoaderLink. - @curl -sG "${MEDIAWIKI_LOAD_URL}?modules=mediawiki.legacy.commonPrint|mediawiki.legacy.shared|mediawiki.ui|mediawiki.ui.anchor|mediawiki.ui.button|mediawiki.ui.checkbox|mediawiki.ui.radio|mediawiki.ui.icon|mediawiki.ui.input|mediawiki.ui.text&only=styles" > $(KSS_RL_TMP) + @curl -sG "${MEDIAWIKI_LOAD_URL}?modules=${MODULE_STR}&only=styles" > $(KSS_RL_TMP) @node_modules/.bin/kss-node ../../resources/src/mediawiki.ui static/ --css $(KSS_RL_TMP) -t styleguide-template @rm $(KSS_RL_TMP) diff --git a/docs/kss/README.txt b/docs/kss/README.txt index c383af9ea2..76cfb6279f 100644 --- a/docs/kss/README.txt +++ b/docs/kss/README.txt @@ -17,3 +17,5 @@ If MediaWiki is running on localhost, you can omit MEDIAWIKI_LOAD_URL. To rebuild without opening the web browser, run: MEDIAWIKI_LOAD_URL=mediawiki_hostname/w/load.php make + +When modifying styleGuideModules.txt, keep the list in strict alphabetical order (with no extra formatting), so CSS loads in the same order as ResourceLoader's addModuleStyles does; this can affect rendering. diff --git a/docs/kss/styleGuideModules.txt b/docs/kss/styleGuideModules.txt new file mode 100644 index 0000000000..2091010530 --- /dev/null +++ b/docs/kss/styleGuideModules.txt @@ -0,0 +1,10 @@ +mediawiki.legacy.commonPrint +mediawiki.legacy.shared +mediawiki.ui +mediawiki.ui.anchor +mediawiki.ui.button +mediawiki.ui.checkbox +mediawiki.ui.icon +mediawiki.ui.input +mediawiki.ui.radio +mediawiki.ui.text diff --git a/docs/mwlogger.txt b/docs/mwlogger.txt index aab95992f0..ecc3626db0 100644 --- a/docs/mwlogger.txt +++ b/docs/mwlogger.txt @@ -1,28 +1,30 @@ -MWLogger implements a PSR-3 [0] compatible message logging system. +MWLoggerFactory implements a PSR-3 [0] compatible message logging system. -The MWLogger class is actually a thin wrapper around any PSR-3 LoggerInterface -implementation. Named MWLogger instances can be obtained from the -MWLogger::getInstance() static method. MWLogger expects a class implementing -the MWLoggerSpi interface to act as a factory for new MWLogger instances. +Named Psr\Log\LoggerInterface instances can be obtained from the +MWLoggerFactory::getInstance() static method. MWLoggerFactory expects a class +implementing the MWLoggerSpi interface to act as a factory for new +Psr\Log\LoggerInterface instances. -The "Spi" in MWLoggerSpi stands for "service provider interface". An SPI is -a API intended to be implemented or extended by a third party. This software +The "Spi" in MWLoggerSpi stands for "service provider interface". A SPI is +an API intended to be implemented or extended by a third party. This software design pattern is intended to enable framework extension and replaceable -components. It is specifically used in the MWLogger service to allow alternate -PSR-3 logging implementations to be easily integrated with MediaWiki. +components. It is specifically used in the MWLoggerFactory service to allow +alternate PSR-3 logging implementations to be easily integrated with +MediaWiki. -The MWLogger::getInstance() static method is the means by which most code -acquires an MWLogger instance. This in turn delegates creation of MWLogger -instances to a class implementing the MWLoggerSpi service provider interface. +The MWLoggerFactory::getInstance() static method is the means by which most +code acquires a Psr\Log\LoggerInterface instance. This in turn delegates +creation of Psr\Log\LoggerInterface instances to a class implementing the +MWLoggerSpi service provider interface. The service provider interface allows the backend logging library to be implemented in multiple ways. The $wgMWLoggerDefaultSpi global provides the classname of the default MWLoggerSpi implementation to be loaded at runtime. This can either be the name of a class implementing the MWLoggerSpi with a zero argument constructor or a callable that will return an MWLoggerSpi -instance. Alternately the MWLogger::registerProvider method can be called -to inject an MWLoggerSpi instance into MWLogger and bypass the use of this -configuration variable. +instance. Alternately the MWLoggerFactory::registerProvider method can be +called to inject an MWLoggerSpi instance into MWLoggerFactory and bypass the +use of this configuration variable. The MWLoggerLegacySpi class implements a service provider to generate MWLoggerLegacyLogger instances. The MWLoggerLegacyLogger class implements the @@ -33,18 +35,17 @@ DefaultSettings.php. It's usage should be transparent for users who are not ready or do not wish to switch to a alternate logging platform. The MWLoggerMonologSpi class implements a service provider to generate -MWLogger instances that use the Monolog [1] logging library. See the PHP docs -(or source) for MWLoggerMonologSpi for details on the configuration of this -provider. The default configuration installs a null handler that will silently -discard all logging events. The documentation provided by the class describes -a more feature rich logging configuration. +Psr\Log\LoggerInterface instances that use the Monolog [1] logging library. +See the PHP docs (or source) for MWLoggerMonologSpi for details on the +configuration of this provider. The default configuration installs a null +handler that will silently discard all logging events. The documentation +provided by the class describes a more feature rich logging configuration. == Classes == -; MWLogger -: PSR-3 compatible logger that wraps any \Psr\Log\LoggerInterface - implementation +; MWLoggerFactory +: Factory for Psr\Log\LoggerInterface loggers ; MWLoggerSpi -: Service provider interface for MWLogger factories +: Service provider interface for MWLoggerFactory ; MWLoggerNullSpi : MWLoggerSpi for creating instances that discard all log events ; MWLoggerLegacySpi @@ -64,7 +65,7 @@ a more feature rich logging configuration. == Globals == ; $wgMWLoggerDefaultSpi : Specification for creating the default service provider interface to use - with MWLogger + with MWLoggerFactory [0]: https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md [1]: https://github.com/Seldaek/monolog diff --git a/img_auth.php b/img_auth.php index dcd171f94e..f44cac0b97 100644 --- a/img_auth.php +++ b/img_auth.php @@ -39,7 +39,6 @@ define( 'MW_NO_OUTPUT_COMPRESSION', 1 ); require __DIR__ . '/includes/WebStart.php'; -wfProfileIn( 'img_auth.php' ); # Set action base paths so that WebRequest::getPathInfo() # recognizes the "X" as the 'title' in ../img_auth.php/X urls. @@ -47,7 +46,6 @@ $wgArticlePath = false; # Don't let a "/*" article path clober our action path $wgActionPaths = array( "$wgUploadPath/" ); wfImageAuthMain(); -wfProfileOut( 'img_auth.php' ); wfLogProfilingData(); // Commit and close up! $factory = wfGetLBFactory(); @@ -203,7 +201,12 @@ function wfForbidden( $msg1, $msg2 ) { header( 'Cache-Control: no-cache' ); header( 'Content-Type: text/html; charset=utf-8' ); echo << + + +$msgHdr +

$msgHdr

$detailMsg

diff --git a/includes/AjaxDispatcher.php b/includes/AjaxDispatcher.php index 9bc92be94c..b14114d76b 100644 --- a/includes/AjaxDispatcher.php +++ b/includes/AjaxDispatcher.php @@ -56,8 +56,6 @@ class AjaxDispatcher { * Load up our object with user supplied data */ function __construct( Config $config ) { - wfProfileIn( __METHOD__ ); - $this->config = $config; $this->mode = ""; @@ -88,13 +86,11 @@ class AjaxDispatcher { } break; default: - wfProfileOut( __METHOD__ ); return; # Or we could throw an exception: # throw new MWException( __METHOD__ . ' called without any data (mode empty).' ); } - wfProfileOut( __METHOD__ ); } /** @@ -110,11 +106,8 @@ class AjaxDispatcher { return; } - wfProfileIn( __METHOD__ ); - if ( !in_array( $this->func_name, $this->config->get( 'AjaxExportList' ) ) ) { wfDebug( __METHOD__ . ' Bad Request for unknown function ' . $this->func_name . "\n" ); - wfHttpError( 400, 'Bad Request', @@ -127,7 +120,6 @@ class AjaxDispatcher { 'You are not allowed to view pages.' ); } else { wfDebug( __METHOD__ . ' dispatching ' . $this->func_name . "\n" ); - try { $result = call_user_func_array( $this->func_name, $this->args ); @@ -162,6 +154,5 @@ class AjaxDispatcher { } } - wfProfileOut( __METHOD__ ); } } diff --git a/includes/Block.php b/includes/Block.php index 9079fb0d85..4698f457d6 100644 --- a/includes/Block.php +++ b/includes/Block.php @@ -752,7 +752,6 @@ class Block { * @return bool */ public function deleteIfExpired() { - wfProfileIn( __METHOD__ ); if ( $this->isExpired() ) { wfDebug( "Block::deleteIfExpired() -- deleting\n" ); @@ -763,7 +762,6 @@ class Block { $retVal = false; } - wfProfileOut( __METHOD__ ); return $retVal; } @@ -1055,7 +1053,6 @@ class Block { return array(); } - wfProfileIn( __METHOD__ ); $conds = array(); foreach ( array_unique( $ipChain ) as $ipaddr ) { # Discard invalid IP addresses. Since XFF can be spoofed and we do not @@ -1077,7 +1074,6 @@ class Block { } if ( !count( $conds ) ) { - wfProfileOut( __METHOD__ ); return array(); } @@ -1108,7 +1104,6 @@ class Block { } } - wfProfileOut( __METHOD__ ); return $blocks; } @@ -1140,8 +1135,6 @@ class Block { return $blocks[0]; } - wfProfileIn( __METHOD__ ); - // Sort hard blocks before soft ones and secondarily sort blocks // that disable account creation before those that don't. usort( $blocks, function ( Block $a, Block $b ) { @@ -1222,11 +1215,9 @@ class Block { } elseif ( $blocksList['auto'] ) { $chosenBlock = $blocksList['auto']; } else { - wfProfileOut( __METHOD__ ); throw new MWException( "Proxy block found, but couldn't be classified." ); } - wfProfileOut( __METHOD__ ); return $chosenBlock; } diff --git a/includes/Category.php b/includes/Category.php index 322b0530b5..3a21e256c7 100644 --- a/includes/Category.php +++ b/includes/Category.php @@ -60,8 +60,6 @@ class Category { return true; } - wfProfileIn( __METHOD__ ); - $dbr = wfGetDB( DB_SLAVE ); $row = $dbr->selectRow( 'category', @@ -70,8 +68,6 @@ class Category { __METHOD__ ); - wfProfileOut( __METHOD__ ); - if ( !$row ) { # Okay, there were no contents. Nothing to initialize. if ( $this->mTitle ) { @@ -258,7 +254,6 @@ class Category { * @return TitleArray TitleArray object for category members. */ public function getMembers( $limit = false, $offset = '' ) { - wfProfileIn( __METHOD__ ); $dbr = wfGetDB( DB_SLAVE ); @@ -284,8 +279,6 @@ class Category { ) ); - wfProfileOut( __METHOD__ ); - return $result; } @@ -318,8 +311,6 @@ class Category { } } - wfProfileIn( __METHOD__ ); - $dbw = wfGetDB( DB_MASTER ); $dbw->startAtomic( __METHOD__ ); @@ -363,8 +354,6 @@ class Category { ); $dbw->endAtomic( __METHOD__ ); - wfProfileOut( __METHOD__ ); - # Now we should update our local counts. $this->mPages = $result->pages; $this->mSubcats = $result->subcats; diff --git a/includes/CategoryFinder.php b/includes/CategoryFinder.php index cf537e15e5..33de7404eb 100644 --- a/includes/CategoryFinder.php +++ b/includes/CategoryFinder.php @@ -185,7 +185,6 @@ class CategoryFinder { * Scans a "parent layer" of the articles/categories in $this->next */ private function scanNextLayer() { - $profiler = new ProfileSection( __METHOD__ ); # Find all parents of the article currently in $this->next $layer = array(); diff --git a/includes/CategoryViewer.php b/includes/CategoryViewer.php index f68da956e9..6b86853e51 100644 --- a/includes/CategoryViewer.php +++ b/includes/CategoryViewer.php @@ -104,7 +104,6 @@ class CategoryViewer extends ContextSource { * @return string HTML output */ public function getHTML() { - wfProfileIn( __METHOD__ ); $this->showGallery = $this->getConfig()->get( 'CategoryMagicGallery' ) && !$this->getOutput()->mNoGallery; @@ -140,7 +139,6 @@ class CategoryViewer extends ContextSource { # put a div around the headings which are in the user language $r = Html::openElement( 'div', $langAttribs ) . $r . ''; - wfProfileOut( __METHOD__ ); return $r; } @@ -154,7 +152,7 @@ class CategoryViewer extends ContextSource { $mode = $this->getRequest()->getVal( 'gallerymode', null ); try { $this->gallery = ImageGalleryBase::factory( $mode, $this->getContext() ); - } catch ( MWException $e ) { + } catch ( Exception $e ) { // User specified something invalid, fallback to default. $this->gallery = ImageGalleryBase::factory( false, $this->getContext() ); } @@ -390,7 +388,7 @@ class CategoryViewer extends ContextSource { if ( $rescnt > 0 ) { # Showing subcategories $r .= "
\n"; - $r .= '

' . $this->msg( 'subcategories' )->text() . "

\n"; + $r .= '

' . $this->msg( 'subcategories' )->parse() . "

\n"; $r .= $countmsg; $r .= $this->getSectionPagingLinks( 'subcat' ); $r .= $this->formatList( $this->children, $this->children_start_char ); @@ -419,7 +417,7 @@ class CategoryViewer extends ContextSource { if ( $rescnt > 0 ) { $r = "
\n"; - $r .= '

' . $this->msg( 'category_header', $ti )->text() . "

\n"; + $r .= '

' . $this->msg( 'category_header', $ti )->parse() . "

\n"; $r .= $countmsg; $r .= $this->getSectionPagingLinks( 'page' ); $r .= $this->formatList( $this->articles, $this->articles_start_char ); diff --git a/includes/ChangeTags.php b/includes/ChangeTags.php index 9ee2460ddd..d597d6d473 100644 --- a/includes/ChangeTags.php +++ b/includes/ChangeTags.php @@ -21,6 +21,13 @@ */ class ChangeTags { + /** + * Can't delete tags with more than this many uses. Similar in intent to + * the bigdelete user right + * @todo Use the job queue for tag deletion to avoid this restriction + */ + const MAX_DELETE_USES = 5000; + /** * Creates HTML for the given tags * @@ -185,6 +192,7 @@ class ChangeTags { $dbw->insert( 'change_tag', $tagsRows, __METHOD__, array( 'IGNORE' ) ); + self::purgeTagUsageCache(); return true; } @@ -293,19 +301,479 @@ class ChangeTags { return $html; } + /** + * Defines a tag in the valid_tag table, without checking that the tag name + * is valid. + * Extensions should NOT use this function; they can use the ListDefinedTags + * hook instead. + * + * @param string $tag Tag to create + * @since 1.25 + */ + public static function defineTag( $tag ) { + $dbw = wfGetDB( DB_MASTER ); + $dbw->replace( 'valid_tag', + array( 'vt_tag' ), + array( 'vt_tag' => $tag ), + __METHOD__ ); + + // clear the memcache of defined tags + self::purgeTagCacheAll(); + } + + /** + * Removes a tag from the valid_tag table. The tag may remain in use by + * extensions, and may still show up as 'defined' if an extension is setting + * it from the ListDefinedTags hook. + * + * @param string $tag Tag to remove + * @since 1.25 + */ + public static function undefineTag( $tag ) { + $dbw = wfGetDB( DB_MASTER ); + $dbw->delete( 'valid_tag', array( 'vt_tag' => $tag ), __METHOD__ ); + + // clear the memcache of defined tags + self::purgeTagCacheAll(); + } + + /** + * Writes a tag action into the tag management log. + * + * @param string $action + * @param string $tag + * @param string $reason + * @param User $user Who to attribute the action to + * @param int $tagCount For deletion only, how many usages the tag had before + * it was deleted. + * @since 1.25 + */ + protected static function logTagAction( $action, $tag, $reason, User $user, + $tagCount = null ) { + + $dbw = wfGetDB( DB_MASTER ); + + $logEntry = new ManualLogEntry( 'managetags', $action ); + $logEntry->setPerformer( $user ); + // target page is not relevant, but it has to be set, so we just put in + // the title of Special:Tags + $logEntry->setTarget( Title::newFromText( 'Special:Tags' ) ); + $logEntry->setComment( $reason ); + + $params = array( '4::tag' => $tag ); + if ( !is_null( $tagCount ) ) { + $params['5:number:count'] = $tagCount; + } + $logEntry->setParameters( $params ); + $logEntry->setRelations( array( 'Tag' => $tag ) ); + + $logId = $logEntry->insert( $dbw ); + $logEntry->publish( $logId ); + return $logId; + } + + /** + * Is it OK to allow the user to activate this tag? + * + * @param string $tag Tag that you are interested in activating + * @param User|null $user User whose permission you wish to check, or null if + * you don't care (e.g. maintenance scripts) + * @return Status + * @since 1.25 + */ + public static function canActivateTag( $tag, User $user = null ) { + if ( !is_null( $user ) && !$user->isAllowed( 'managechangetags' ) ) { + return Status::newFatal( 'tags-manage-no-permission' ); + } + + // non-existing tags cannot be activated + $tagUsage = self::tagUsageStatistics(); + if ( !isset( $tagUsage[$tag] ) ) { + return Status::newFatal( 'tags-activate-not-found', $tag ); + } + + // defined tags cannot be activated (a defined tag is either extension- + // defined, in which case the extension chooses whether or not to active it; + // or user-defined, in which case it is considered active) + $definedTags = self::listDefinedTags(); + if ( in_array( $tag, $definedTags ) ) { + return Status::newFatal( 'tags-activate-not-allowed', $tag ); + } + + return Status::newGood(); + } + + /** + * Activates a tag, checking whether it is allowed first, and adding a log + * entry afterwards. + * + * Includes a call to ChangeTag::canActivateTag(), so your code doesn't need + * to do that. + * + * @param string $tag + * @param string $reason + * @param User $user Who to give credit for the action + * @param bool $ignoreWarnings Can be used for API interaction, default false + * @return Status If successful, the Status contains the ID of the added log + * entry as its value + * @since 1.25 + */ + public static function activateTagWithChecks( $tag, $reason, User $user, + $ignoreWarnings = false ) { + + // are we allowed to do this? + $result = self::canActivateTag( $tag, $user ); + if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) { + $result->value = null; + return $result; + } + + // do it! + self::defineTag( $tag ); + + // log it + $logId = self::logTagAction( 'activate', $tag, $reason, $user ); + return Status::newGood( $logId ); + } + + /** + * Is it OK to allow the user to deactivate this tag? + * + * @param string $tag Tag that you are interested in deactivating + * @param User|null $user User whose permission you wish to check, or null if + * you don't care (e.g. maintenance scripts) + * @return Status + * @since 1.25 + */ + public static function canDeactivateTag( $tag, User $user = null ) { + if ( !is_null( $user ) && !$user->isAllowed( 'managechangetags' ) ) { + return Status::newFatal( 'tags-manage-no-permission' ); + } + + // only explicitly-defined tags can be deactivated + $explicitlyDefinedTags = self::listExplicitlyDefinedTags(); + if ( !in_array( $tag, $explicitlyDefinedTags ) ) { + return Status::newFatal( 'tags-deactivate-not-allowed', $tag ); + } + return Status::newGood(); + } + + /** + * Deactivates a tag, checking whether it is allowed first, and adding a log + * entry afterwards. + * + * Includes a call to ChangeTag::canDeactivateTag(), so your code doesn't need + * to do that. + * + * @param string $tag + * @param string $reason + * @param User $user Who to give credit for the action + * @param bool $ignoreWarnings Can be used for API interaction, default false + * @return Status If successful, the Status contains the ID of the added log + * entry as its value + * @since 1.25 + */ + public static function deactivateTagWithChecks( $tag, $reason, User $user, + $ignoreWarnings = false ) { + + // are we allowed to do this? + $result = self::canDeactivateTag( $tag, $user ); + if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) { + $result->value = null; + return $result; + } + + // do it! + self::undefineTag( $tag ); + + // log it + $logId = self::logTagAction( 'deactivate', $tag, $reason, $user ); + return Status::newGood( $logId ); + } + + /** + * Is it OK to allow the user to create this tag? + * + * @param string $tag Tag that you are interested in creating + * @param User|null $user User whose permission you wish to check, or null if + * you don't care (e.g. maintenance scripts) + * @return Status + * @since 1.25 + */ + public static function canCreateTag( $tag, User $user = null ) { + if ( !is_null( $user ) && !$user->isAllowed( 'managechangetags' ) ) { + return Status::newFatal( 'tags-manage-no-permission' ); + } + + // no empty tags + if ( $tag === '' ) { + return Status::newFatal( 'tags-create-no-name' ); + } + + // tags cannot contain commas (used as a delimiter in tag_summary table) or + // slashes (would break tag description messages in MediaWiki namespace) + if ( strpos( $tag, ',' ) !== false || strpos( $tag, '/' ) !== false ) { + return Status::newFatal( 'tags-create-invalid-chars' ); + } + + // could the MediaWiki namespace description messages be created? + $title = Title::makeTitleSafe( NS_MEDIAWIKI, "Tag-$tag-description" ); + if ( is_null( $title ) ) { + return Status::newFatal( 'tags-create-invalid-title-chars' ); + } + + // does the tag already exist? + $tagUsage = self::tagUsageStatistics(); + if ( isset( $tagUsage[$tag] ) ) { + return Status::newFatal( 'tags-create-already-exists', $tag ); + } + + // check with hooks + $canCreateResult = Status::newGood(); + Hooks::run( 'ChangeTagCanCreate', array( $tag, $user, &$canCreateResult ) ); + return $canCreateResult; + } + + /** + * Creates a tag by adding a row to the `valid_tag` table. + * + * Includes a call to ChangeTag::canDeleteTag(), so your code doesn't need to + * do that. + * + * @param string $tag + * @param string $reason + * @param User $user Who to give credit for the action + * @param bool $ignoreWarnings Can be used for API interaction, default false + * @return Status If successful, the Status contains the ID of the added log + * entry as its value + * @since 1.25 + */ + public static function createTagWithChecks( $tag, $reason, User $user, + $ignoreWarnings = false ) { + + // are we allowed to do this? + $result = self::canCreateTag( $tag, $user ); + if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) { + $result->value = null; + return $result; + } + + // do it! + self::defineTag( $tag ); + + // log it + $logId = self::logTagAction( 'create', $tag, $reason, $user ); + return Status::newGood( $logId ); + } + + /** + * Permanently removes all traces of a tag from the DB. Good for removing + * misspelt or temporary tags. + * + * This function should be directly called by maintenance scripts only, never + * by user-facing code. See deleteTagWithChecks() for functionality that can + * safely be exposed to users. + * + * @param string $tag Tag to remove + * @return Status The returned status will be good unless a hook changed it + * @since 1.25 + */ + public static function deleteTagEverywhere( $tag ) { + $dbw = wfGetDB( DB_MASTER ); + $dbw->begin( __METHOD__ ); + + // delete from valid_tag + self::undefineTag( $tag ); + + // find out which revisions use this tag, so we can delete from tag_summary + $result = $dbw->select( 'change_tag', + array( 'ct_rc_id', 'ct_log_id', 'ct_rev_id', 'ct_tag' ), + array( 'ct_tag' => $tag ), + __METHOD__ ); + foreach ( $result as $row ) { + if ( $row->ct_rev_id ) { + $field = 'ts_rev_id'; + $fieldValue = $row->ct_rev_id; + } elseif ( $row->ct_log_id ) { + $field = 'ts_log_id'; + $fieldValue = $row->ct_log_id; + } elseif ( $row->ct_rc_id ) { + $field = 'ts_rc_id'; + $fieldValue = $row->ct_rc_id; + } else { + // don't know what's up; just skip it + continue; + } + + // remove the tag from the relevant row of tag_summary + $tsResult = $dbw->selectField( 'tag_summary', + 'ts_tags', + array( $field => $fieldValue ), + __METHOD__ ); + $tsValues = explode( ',', $tsResult ); + $tsValues = array_values( array_diff( $tsValues, array( $tag ) ) ); + if ( !$tsValues ) { + // no tags left, so delete the row altogether + $dbw->delete( 'tag_summary', + array( $field => $fieldValue ), + __METHOD__ ); + } else { + $dbw->update( 'tag_summary', + array( 'ts_tags' => implode( ',', $tsValues ) ), + array( $field => $fieldValue ), + __METHOD__ ); + } + } + + // delete from change_tag + $dbw->delete( 'change_tag', array( 'ct_tag' => $tag ), __METHOD__ ); + + $dbw->commit( __METHOD__ ); + + // give extensions a chance + $status = Status::newGood(); + Hooks::run( 'ChangeTagAfterDelete', array( $tag, &$status ) ); + // let's not allow error results, as the actual tag deletion succeeded + if ( !$status->isOK() ) { + wfDebug( 'ChangeTagAfterDelete error condition downgraded to warning' ); + $status->ok = true; + } + + // clear the memcache of defined tags + self::purgeTagCacheAll(); + + return $status; + } + + /** + * Is it OK to allow the user to delete this tag? + * + * @param string $tag Tag that you are interested in deleting + * @param User|null $user User whose permission you wish to check, or null if + * you don't care (e.g. maintenance scripts) + * @return Status + * @since 1.25 + */ + public static function canDeleteTag( $tag, User $user = null ) { + $tagUsage = self::tagUsageStatistics(); + + if ( !is_null( $user ) && !$user->isAllowed( 'managechangetags' ) ) { + return Status::newFatal( 'tags-manage-no-permission' ); + } + + if ( !isset( $tagUsage[$tag] ) ) { + return Status::newFatal( 'tags-delete-not-found', $tag ); + } + + if ( $tagUsage[$tag] > self::MAX_DELETE_USES ) { + return Status::newFatal( 'tags-delete-too-many-uses', $tag, self::MAX_DELETE_USES ); + } + + $extensionDefined = self::listExtensionDefinedTags(); + if ( in_array( $tag, $extensionDefined ) ) { + // extension-defined tags can't be deleted unless the extension + // specifically allows it + $status = Status::newFatal( 'tags-delete-not-allowed' ); + } else { + // user-defined tags are deletable unless otherwise specified + $status = Status::newGood(); + } + + Hooks::run( 'ChangeTagCanDelete', array( $tag, $user, &$status ) ); + return $status; + } + + /** + * Deletes a tag, checking whether it is allowed first, and adding a log entry + * afterwards. + * + * Includes a call to ChangeTag::canDeleteTag(), so your code doesn't need to + * do that. + * + * @param string $tag + * @param string $reason + * @param User $user Who to give credit for the action + * @param bool $ignoreWarnings Can be used for API interaction, default false + * @return Status If successful, the Status contains the ID of the added log + * entry as its value + * @since 1.25 + */ + public static function deleteTagWithChecks( $tag, $reason, User $user, + $ignoreWarnings = false ) { + + // are we allowed to do this? + $result = self::canDeleteTag( $tag, $user ); + if ( $ignoreWarnings ? !$result->isOK() : !$result->isGood() ) { + $result->value = null; + return $result; + } + + // store the tag usage statistics + $tagUsage = self::tagUsageStatistics(); + + // do it! + $deleteResult = self::deleteTagEverywhere( $tag ); + if ( !$deleteResult->isOK() ) { + return $deleteResult; + } + + // log it + $logId = self::logTagAction( 'delete', $tag, $reason, $user, $tagUsage[$tag] ); + $deleteResult->value = $logId; + return $deleteResult; + } + + /** + * Lists those tags which extensions report as being "active". + * + * @return array + * @since 1.25 + */ + public static function listExtensionActivatedTags() { + // Caching... + global $wgMemc; + $key = wfMemcKey( 'active-tags' ); + $tags = $wgMemc->get( $key ); + if ( $tags ) { + return $tags; + } + + // ask extensions which tags they consider active + $extensionActive = array(); + Hooks::run( 'ChangeTagsListActive', array( &$extensionActive ) ); + + // Short-term caching. + $wgMemc->set( $key, $extensionActive, 300 ); + return $extensionActive; + } + /** * Basically lists defined tags which count even if they aren't applied to anything. - * Tags on items in table 'change_tag' which are not (or no longer) in table 'valid_tag' - * are not included. + * It returns a union of the results of listExplicitlyDefinedTags() and + * listExtensionDefinedTags(). + * + * @return string[] Array of strings: tags + */ + public static function listDefinedTags() { + $tags1 = self::listExplicitlyDefinedTags(); + $tags2 = self::listExtensionDefinedTags(); + return array_values( array_unique( array_merge( $tags1, $tags2 ) ) ); + } + + /** + * Lists tags explicitly defined in the `valid_tag` table of the database. + * Tags in table 'change_tag' which are not in table 'valid_tag' are not + * included. * * Tries memcached first. * * @return string[] Array of strings: tags + * @since 1.25 */ - public static function listDefinedTags() { + public static function listExplicitlyDefinedTags() { // Caching... global $wgMemc; - $key = wfMemcKey( 'valid-tags' ); + $key = wfMemcKey( 'valid-tags-db' ); $tags = $wgMemc->get( $key ); if ( $tags ) { return $tags; @@ -320,8 +788,33 @@ class ChangeTags { $emptyTags[] = $row->vt_tag; } - Hooks::run( 'ListDefinedTags', array( &$emptyTags ) ); + $emptyTags = array_filter( array_unique( $emptyTags ) ); + // Short-term caching. + $wgMemc->set( $key, $emptyTags, 300 ); + return $emptyTags; + } + + /** + * Lists tags defined by extensions using the ListDefinedTags hook. + * Extensions need only define those tags they deem to be in active use. + * + * Tries memcached first. + * + * @return string[] Array of strings: tags + * @since 1.25 + */ + public static function listExtensionDefinedTags() { + // Caching... + global $wgMemc; + $key = wfMemcKey( 'valid-tags-hook' ); + $tags = $wgMemc->get( $key ); + if ( $tags ) { + return $tags; + } + + $emptyTags = array(); + Hooks::run( 'ListDefinedTags', array( &$emptyTags ) ); $emptyTags = array_filter( array_unique( $emptyTags ) ); // Short-term caching. @@ -329,13 +822,46 @@ class ChangeTags { return $emptyTags; } + /** + * Invalidates the short-term cache of defined tags used by the + * list*DefinedTags functions, as well as the tag statistics cache. + * @since 1.25 + */ + public static function purgeTagCacheAll() { + global $wgMemc; + $wgMemc->delete( wfMemcKey( 'active-tags' ) ); + $wgMemc->delete( wfMemcKey( 'valid-tags-db' ) ); + $wgMemc->delete( wfMemcKey( 'valid-tags-hook' ) ); + self::purgeTagUsageCache(); + } + + /** + * Invalidates the tag statistics cache only. + * @since 1.25 + */ + public static function purgeTagUsageCache() { + global $wgMemc; + $wgMemc->delete( wfMemcKey( 'change-tag-statistics' ) ); + } + /** * Returns a map of any tags used on the wiki to number of edits * tagged with them, ordered descending by the hitcount. * + * Keeps a short-term cache in memory, so calling this multiple times in the + * same request should be fine. + * * @return array Array of string => int */ public static function tagUsageStatistics() { + // Caching... + global $wgMemc; + $key = wfMemcKey( 'change-tag-statistics' ); + $stats = $wgMemc->get( $key ); + if ( $stats ) { + return $stats; + } + $out = array(); $dbr = wfGetDB( DB_SLAVE ); @@ -356,6 +882,8 @@ class ChangeTags { } } + // Cache for a very short time + $wgMemc->set( $key, $out, 300 ); return $out; } } diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 1884b5feb5..d4cdf9e8c8 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -270,6 +270,16 @@ $wgFavicon = '/favicon.ico'; */ $wgAppleTouchIcon = false; +/** + * Value for the referrer policy meta tag. + * One of 'never', 'default', 'origin', 'always'. Setting it to false just + * prevents the meta tag from being output. + * See http://www.w3.org/TR/referrer-policy/ for details. + * + * @since 1.25 + */ +$wgReferrerPolicy = false; + /** * The local filesystem path to a temporary directory. This is not required to * be web accessible. @@ -941,12 +951,12 @@ $wgExiv2Command = '/usr/bin/exiv2'; * are passed as parameters after $srcPath, $dstPath, $width, $height */ $wgSVGConverters = array( - 'ImageMagick' => '$path/convert -background white -thumbnail $widthx$height\! $input PNG:$output', + 'ImageMagick' => '$path/convert -background "#ffffff00" -thumbnail $widthx$height\! $input PNG:$output', 'sodipodi' => '$path/sodipodi -z -w $width -f $input -e $output', 'inkscape' => '$path/inkscape -z -w $width -f $input -e $output', 'batik' => 'java -Djava.awt.headless=true -jar $path/batik-rasterizer.jar -w $width -d ' . '$output $input', - 'rsvg' => '$path/rsvg -w $width -h $height $input $output', + 'rsvg' => '$path/rsvg-convert -w $width -h $height -o $output $input', 'imgserv' => '$path/imgserv-wrapper -i svg -o png -w$width $input $output', 'ImagickExt' => array( 'SvgHandler::rasterizeImagickExt' ), ); @@ -1310,9 +1320,11 @@ $wgDirectoryMode = 0777; * Generate and use thumbnails suitable for screens with 1.5 and 2.0 pixel densities. * * This means a 320x240 use of an image on the wiki will also generate 480x360 and 640x480 - * thumbnails, output via data-src-1-5 and data-src-2-0. Runtime JavaScript switches the - * images in after loading the original low-resolution versions depending on the reported - * window.devicePixelRatio. + * thumbnails, output via the srcset attribute. + * + * On older browsers, a JavaScript polyfill switches the appropriate images in after loading + * the original low-resolution versions depending on the reported window.devicePixelRatio. + * The polyfill can be found in the jquery.hidpi module. */ $wgResponsiveImages = true; @@ -2101,17 +2113,17 @@ $wgLanguageConverterCacheType = CACHE_ANYTHING; */ $wgObjectCaches = array( CACHE_NONE => array( 'class' => 'EmptyBagOStuff' ), - CACHE_DB => array( 'class' => 'SqlBagOStuff' ), + CACHE_DB => array( 'class' => 'SqlBagOStuff', 'loggroup' => 'SQLBagOStuff' ), CACHE_ANYTHING => array( 'factory' => 'ObjectCache::newAnything' ), CACHE_ACCEL => array( 'factory' => 'ObjectCache::newAccelerator' ), - CACHE_MEMCACHED => array( 'factory' => 'ObjectCache::newMemcached' ), + CACHE_MEMCACHED => array( 'factory' => 'ObjectCache::newMemcached', 'loggroup' => 'memcached' ), 'apc' => array( 'class' => 'APCBagOStuff' ), 'xcache' => array( 'class' => 'XCacheBagOStuff' ), 'wincache' => array( 'class' => 'WinCacheBagOStuff' ), - 'memcached-php' => array( 'class' => 'MemcachedPhpBagOStuff' ), - 'memcached-pecl' => array( 'class' => 'MemcachedPeclBagOStuff' ), + 'memcached-php' => array( 'class' => 'MemcachedPhpBagOStuff', 'loggroup' => 'memcached' ), + 'memcached-pecl' => array( 'class' => 'MemcachedPeclBagOStuff', 'loggroup' => 'memcached' ), 'hash' => array( 'class' => 'HashBagOStuff' ), ); @@ -2348,6 +2360,23 @@ $wgClockSkewFudge = 5; */ $wgInvalidateCacheOnLocalSettingsChange = true; +/** + * When loading extensions through the extension registration system, this + * can be used to invalidate the cache. A good idea would be to set this to + * one file, you can just `touch` that one to invalidate the cache + * + * @par Example: + * @code + * $wgExtensionInfoMtime = filemtime( "$IP/LocalSettings.php" ); + * @endcode + * + * If set to false, the mtime for each individual JSON file will be checked, + * which can be slow if a large number of extensions are being loaded. + * + * @var int|bool + */ +$wgExtensionInfoMTime = false; + /** @} */ # end of cache settings /************************************************************************//** @@ -3127,6 +3156,7 @@ $wgExperimentalHtmlIds = false; * for the icon, the following keys are used: * - src: An absolute url to the image to use for the icon, this is recommended * but not required, however some skins will ignore icons without an image + * - srcset: optional additional-resolution images; see HTML5 specs * - url: The url to use in the a element around the text or icon, if not set an a element will * not be outputted * - alt: This is the text form of the icon, it will be displayed without an image in @@ -3143,7 +3173,9 @@ $wgFooterIcons = array( ), "poweredby" => array( "mediawiki" => array( - "src" => null, // Defaults to "$wgResourceBasePath/resources/assets/poweredby_mediawiki_88x31.png" + "src" => null, // Defaults to point at + // "$wgResourceBasePath/resources/assets/poweredby_mediawiki_88x31.png" + // plus srcset for 1.5x, 2x resolution variants. "url" => "//www.mediawiki.org/", "alt" => "Powered by MediaWiki", ) @@ -3511,6 +3543,9 @@ $wgResourceLoaderExperimentalAsyncLoading = false; * * Changes to LESS variables do not trigger cache invalidation. * + * If the LESS variables need to be dynamic, you can use the + * ResourceLoaderGetLessVars hook (since 1.25). + * * @par Example: * @code * $wgResourceLoaderLESSVars = array( @@ -3828,20 +3863,12 @@ $wgNamespacesWithSubpages = array( * A message with the suffix '-desc' should be added as a description message * to have extra information on Special:TrackingCategories. * + * @deprecated since 1.25 Extensions should now register tracking categories using + * the new extension registration system. + * * @since 1.23 */ -$wgTrackingCategories = array( - 'index-category', - 'noindex-category', - 'duplicate-args-category', - 'expensive-parserfunction-category', - 'post-expand-template-argument-category', - 'post-expand-template-inclusion-category', - 'hidden-category-category', - 'broken-file-category', - 'node-count-exceeded-category', - 'expansion-depth-exceeded-category', -); +$wgTrackingCategories = array(); /** * Array of namespaces which can be deemed to contain valid "content", as far @@ -4600,6 +4627,7 @@ $wgGroupPermissions['sysop']['suppressredirect'] = true; #$wgGroupPermissions['sysop']['pagelang'] = true; #$wgGroupPermissions['sysop']['upload_by_url'] = true; $wgGroupPermissions['sysop']['mergehistory'] = true; +$wgGroupPermissions['sysop']['managechangetags'] = true; // Permission to change users' group assignments $wgGroupPermissions['bureaucrat']['userrights'] = true; @@ -5262,16 +5290,16 @@ $wgDebugDumpSqlLength = 500; $wgDebugLogGroups = array(); /** - * Default service provider for creating MWLogger instances. + * Default service provider for creating Psr\Log\LoggerInterface instances. * * The value should be an array suitable for use with * ObjectFactory::getObjectFromSpec(). The created object is expected to * implement the MWLoggerSpi interface. See ObjectFactory for additional * details. * - * Alternately the MWLogger::registerProvider method can be called to inject - * an MWLoggerSpi instance into MWLogger and bypass the use of this - * configuration variable entirely. + * Alternately the MWLoggerFactory::registerProvider method can be called to + * inject an MWLoggerSpi instance into MWLoggerFactory and bypass the use of + * this configuration variable entirely. * * @since 1.25 * @var array $wgMWLoggerDefaultSpi @@ -5416,11 +5444,6 @@ $wgUDPProfilerPort = null; */ $wgUDPProfilerFormatString = null; -/** - * Output debug message on every wfProfileIn/wfProfileOut - */ -$wgDebugFunctionEntry = false; - /** * Destination for wfIncrStats() data... * 'cache' to go into the system cache, if enabled (memcached) @@ -6304,7 +6327,7 @@ $wgAutoloadAttemptLowercase = true; * 'version' => '1.9.0', * 'url' => 'http://example.org/example-extension/', * 'descriptionmsg' => 'exampleextension-desc', - * 'license-name' => 'GPL-2.0', + * 'license-name' => 'GPL-2.0+', * ); * @endcode * @@ -6338,7 +6361,7 @@ $wgAutoloadAttemptLowercase = true; * localizable message (omit in favour of 'descriptionmsg'). * * - license-name: Short name of the license (used as label for the link), such - * as "GPL-2.0" or "MIT" (https://spdx.org/licenses/ for a list of identifiers). + * as "GPL-2.0+" or "MIT" (https://spdx.org/licenses/ for a list of identifiers). */ $wgExtensionCredits = array(); @@ -6390,7 +6413,6 @@ $wgHooks = array(); */ $wgJobClasses = array( 'refreshLinks' => 'RefreshLinksJob', - 'refreshLinks2' => 'RefreshLinksJob2', // b/c 'htmlCacheUpdate' => 'HTMLCacheUpdateJob', 'sendMail' => 'EmaillingJob', 'enotifNotify' => 'EnotifNotifyJob', @@ -6399,6 +6421,7 @@ $wgJobClasses = array( 'AssembleUploadChunks' => 'AssembleUploadChunksJob', 'PublishStashedFile' => 'PublishStashedFileJob', 'ThumbnailRender' => 'ThumbnailRenderJob', + 'recentChangesUpdate' => 'RecentChangesUpdateJob', 'null' => 'NullJob' ); @@ -6548,6 +6571,7 @@ $wgLogTypes = array( 'patrol', 'merge', 'suppress', + 'managetags', ); /** @@ -6676,6 +6700,10 @@ $wgLogActionsHandlers = array( 'upload/overwrite' => 'LogFormatter', 'upload/revert' => 'LogFormatter', 'merge/merge' => 'MergeLogFormatter', + 'managetags/create' => 'LogFormatter', + 'managetags/delete' => 'LogFormatter', + 'managetags/activate' => 'LogFormatter', + 'managetags/deactivate' => 'LogFormatter', ); /** diff --git a/includes/Defines.php b/includes/Defines.php index d63d5ca395..8456c5dd6a 100644 --- a/includes/Defines.php +++ b/includes/Defines.php @@ -203,7 +203,7 @@ define( 'LIST_OR', 4 ); /** * Unicode and normalisation related */ -require_once __DIR__ . '/normal/UtfNormalDefines.php'; +require_once __DIR__ . '/libs/normal/UtfNormalDefines.php'; /**@{ * Hook support constants diff --git a/includes/EditPage.php b/includes/EditPage.php index 43702955d9..f5d98a7fbe 100644 --- a/includes/EditPage.php +++ b/includes/EditPage.php @@ -467,13 +467,11 @@ class EditPage { return; } - wfProfileIn( __METHOD__ ); wfDebug( __METHOD__ . ": enter\n" ); // If they used redlink=1 and the page exists, redirect to the main article if ( $wgRequest->getBool( 'redlink' ) && $this->mTitle->exists() ) { $wgOut->redirect( $this->mTitle->getFullURL() ); - wfProfileOut( __METHOD__ ); return; } @@ -482,7 +480,6 @@ class EditPage { if ( $this->live ) { $this->livePreview(); - wfProfileOut( __METHOD__ ); return; } @@ -507,7 +504,7 @@ class EditPage { } } - $permErrors = $this->getEditPermissionErrors(); + $permErrors = $this->getEditPermissionErrors( $this->save ? 'secure' : 'full' ); if ( $permErrors ) { wfDebug( __METHOD__ . ": User can't edit\n" ); // Auto-block user's IP if the account was "hard" blocked @@ -515,12 +512,9 @@ class EditPage { $this->displayPermissionsError( $permErrors ); - wfProfileOut( __METHOD__ ); return; } - wfProfileIn( __METHOD__ . "-business-end" ); - $this->isConflict = false; // css / js subpages of user pages get a special treatment $this->isCssJsSubpage = $this->mTitle->isCssJsSubpage(); @@ -541,8 +535,6 @@ class EditPage { if ( 'save' == $this->formtype ) { if ( !$this->attemptSave() ) { - wfProfileOut( __METHOD__ . "-business-end" ); - wfProfileOut( __METHOD__ ); return; } } @@ -552,8 +544,6 @@ class EditPage { if ( 'initial' == $this->formtype || $this->firsttime ) { if ( $this->initialiseForm() === false ) { $this->noSuchSectionPage(); - wfProfileOut( __METHOD__ . "-business-end" ); - wfProfileOut( __METHOD__ ); return; } @@ -566,20 +556,25 @@ class EditPage { } $this->showEditForm(); - wfProfileOut( __METHOD__ . "-business-end" ); - wfProfileOut( __METHOD__ ); } /** + * @param string $rigor Same format as Title::getUserPermissionErrors() * @return array */ - protected function getEditPermissionErrors() { + protected function getEditPermissionErrors( $rigor = 'secure' ) { global $wgUser; - $permErrors = $this->mTitle->getUserPermissionsErrors( 'edit', $wgUser ); + + $permErrors = $this->mTitle->getUserPermissionsErrors( 'edit', $wgUser, $rigor ); # Can this title be created? if ( !$this->mTitle->exists() ) { - $permErrors = array_merge( $permErrors, - wfArrayDiff2( $this->mTitle->getUserPermissionsErrors( 'create', $wgUser ), $permErrors ) ); + $permErrors = array_merge( + $permErrors, + wfArrayDiff2( + $this->mTitle->getUserPermissionsErrors( 'create', $wgUser, $rigor ), + $permErrors + ) + ); } # Ignore some permissions errors when a user is just previewing/viewing diffs $remove = array(); @@ -591,6 +586,7 @@ class EditPage { } } $permErrors = wfArrayDiff2( $permErrors, $remove ); + return $permErrors; } @@ -732,13 +728,10 @@ class EditPage { function importFormData( &$request ) { global $wgContLang, $wgUser; - wfProfileIn( __METHOD__ ); - # Section edit can come from either the form or a link $this->section = $request->getVal( 'wpSection', $request->getVal( 'section' ) ); if ( $this->section !== null && $this->section !== '' && !$this->isSectionEditSupported() ) { - wfProfileOut( __METHOD__ ); throw new ErrorPageError( 'sectioneditnotsupported-title', 'sectioneditnotsupported-text' ); } @@ -753,13 +746,10 @@ class EditPage { // Skip this if wpTextbox2 has input, it indicates that we came // from a conflict page with raw page text, not a custom form // modified by subclasses - wfProfileIn( get_class( $this ) . "::importContentFormData" ); $textbox1 = $this->importContentFormData( $request ); if ( $textbox1 !== null ) { $this->textbox1 = $textbox1; } - - wfProfileOut( get_class( $this ) . "::importContentFormData" ); } # Truncate for whole multibyte characters @@ -931,7 +921,6 @@ class EditPage { // Allow extensions to modify form data Hooks::run( 'EditPage::importFormData', array( $this, $request ) ); - wfProfileOut( __METHOD__ ); } /** @@ -992,8 +981,6 @@ class EditPage { protected function getContentObject( $def_content = null ) { global $wgOut, $wgRequest, $wgUser, $wgContLang; - wfProfileIn( __METHOD__ ); - $content = false; // For message page not locally set, use the i18n message. @@ -1105,7 +1092,6 @@ class EditPage { } } - wfProfileOut( __METHOD__ ); return $content; } @@ -1538,15 +1524,10 @@ class EditPage { $status = Status::newGood(); - wfProfileIn( __METHOD__ ); - wfProfileIn( __METHOD__ . '-checks' ); - if ( !Hooks::run( 'EditPage::attemptSave', array( $this ) ) ) { wfDebug( "Hook 'EditPage::attemptSave' aborted article saving\n" ); $status->fatal( 'hookaborted' ); $status->value = self::AS_HOOK_ERROR; - wfProfileOut( __METHOD__ . '-checks' ); - wfProfileOut( __METHOD__ ); return $status; } @@ -1563,8 +1544,6 @@ class EditPage { ); $status->fatal( 'spamprotectionmatch', false ); $status->value = self::AS_SPAM_ERROR; - wfProfileOut( __METHOD__ . '-checks' ); - wfProfileOut( __METHOD__ ); return $status; } @@ -1579,8 +1558,6 @@ class EditPage { $ex->getMessage() ); $status->value = self::AS_PARSE_ERROR; - wfProfileOut( __METHOD__ . '-checks' ); - wfProfileOut( __METHOD__ ); return $status; } @@ -1592,9 +1569,6 @@ class EditPage { $code = $wgUser->isAnon() ? self::AS_IMAGE_REDIRECT_ANON : self::AS_IMAGE_REDIRECT_LOGGED; $status->setResult( false, $code ); - wfProfileOut( __METHOD__ . '-checks' ); - wfProfileOut( __METHOD__ ); - return $status; } @@ -1623,8 +1597,6 @@ class EditPage { wfDebugLog( 'SpamRegex', "$ip spam regex hit [[$pdbk]]: \"$match\"" ); $status->fatal( 'spamprotectionmatch', $match ); $status->value = self::AS_SPAM_ERROR; - wfProfileOut( __METHOD__ . '-checks' ); - wfProfileOut( __METHOD__ ); return $status; } if ( !Hooks::run( @@ -1634,15 +1606,11 @@ class EditPage { # Error messages etc. could be handled within the hook... $status->fatal( 'hookaborted' ); $status->value = self::AS_HOOK_ERROR; - wfProfileOut( __METHOD__ . '-checks' ); - wfProfileOut( __METHOD__ ); return $status; } elseif ( $this->hookError != '' ) { # ...or the hook could be expecting us to produce an error $status->fatal( 'hookaborted' ); $status->value = self::AS_HOOK_ERROR_EXPECTED; - wfProfileOut( __METHOD__ . '-checks' ); - wfProfileOut( __METHOD__ ); return $status; } @@ -1651,8 +1619,6 @@ class EditPage { $wgUser->spreadAnyEditBlock(); # Check block state against master, thus 'false'. $status->setResult( false, self::AS_BLOCKED_PAGE_FOR_USER ); - wfProfileOut( __METHOD__ . '-checks' ); - wfProfileOut( __METHOD__ ); return $status; } @@ -1661,22 +1627,16 @@ class EditPage { // Error will be displayed by showEditForm() $this->tooBig = true; $status->setResult( false, self::AS_CONTENT_TOO_BIG ); - wfProfileOut( __METHOD__ . '-checks' ); - wfProfileOut( __METHOD__ ); return $status; } if ( !$wgUser->isAllowed( 'edit' ) ) { if ( $wgUser->isAnon() ) { $status->setResult( false, self::AS_READ_ONLY_PAGE_ANON ); - wfProfileOut( __METHOD__ . '-checks' ); - wfProfileOut( __METHOD__ ); return $status; } else { $status->fatal( 'readonlytext' ); $status->value = self::AS_READ_ONLY_PAGE_LOGGED; - wfProfileOut( __METHOD__ . '-checks' ); - wfProfileOut( __METHOD__ ); return $status; } } @@ -1685,23 +1645,17 @@ class EditPage { && !$wgUser->isAllowed( 'editcontentmodel' ) ) { $status->setResult( false, self::AS_NO_CHANGE_CONTENT_MODEL ); - wfProfileOut( __METHOD__ . '-checks' ); - wfProfileOut( __METHOD__ ); return $status; } if ( wfReadOnly() ) { $status->fatal( 'readonlytext' ); $status->value = self::AS_READ_ONLY_PAGE; - wfProfileOut( __METHOD__ . '-checks' ); - wfProfileOut( __METHOD__ ); return $status; } if ( $wgUser->pingLimiter() || $wgUser->pingLimiter( 'linkpurge', 0 ) ) { $status->fatal( 'actionthrottledtext' ); $status->value = self::AS_RATE_LIMITED; - wfProfileOut( __METHOD__ . '-checks' ); - wfProfileOut( __METHOD__ ); return $status; } @@ -1709,13 +1663,9 @@ class EditPage { # confirmation if ( $this->wasDeletedSinceLastEdit() && !$this->recreate ) { $status->setResult( false, self::AS_ARTICLE_WAS_DELETED ); - wfProfileOut( __METHOD__ . '-checks' ); - wfProfileOut( __METHOD__ ); return $status; } - wfProfileOut( __METHOD__ . '-checks' ); - # Load the page data from the master. If anything changes in the meantime, # we detect it by using page_latest like a token in a 1 try compare-and-swap. $this->mArticle->loadPageData( 'fromdbmaster' ); @@ -1727,7 +1677,6 @@ class EditPage { $status->fatal( 'nocreatetext' ); $status->value = self::AS_NO_CREATE_PERMISSION; wfDebug( __METHOD__ . ": no create permission\n" ); - wfProfileOut( __METHOD__ ); return $status; } @@ -1745,12 +1694,10 @@ class EditPage { $this->blankArticle = true; $status->fatal( 'blankarticle' ); $status->setResult( false, self::AS_BLANK_ARTICLE ); - wfProfileOut( __METHOD__ ); return $status; } if ( !$this->runPostMergeFilters( $textbox_content, $status, $wgUser ) ) { - wfProfileOut( __METHOD__ ); return $status; } @@ -1855,12 +1802,10 @@ class EditPage { if ( $this->isConflict ) { $status->setResult( false, self::AS_CONFLICT_DETECTED ); - wfProfileOut( __METHOD__ ); return $status; } if ( !$this->runPostMergeFilters( $content, $status, $wgUser ) ) { - wfProfileOut( __METHOD__ ); return $status; } @@ -1870,7 +1815,6 @@ class EditPage { $this->missingSummary = true; $status->fatal( 'missingsummary' ); // or 'missingcommentheader' if $section == 'new'. Blegh $status->value = self::AS_SUMMARY_NEEDED; - wfProfileOut( __METHOD__ ); return $status; } @@ -1879,7 +1823,6 @@ class EditPage { $this->missingComment = true; $status->fatal( 'missingcommenttext' ); $status->value = self::AS_TEXTBOX_EMPTY; - wfProfileOut( __METHOD__ ); return $status; } } elseif ( !$this->allowBlankSummary @@ -1890,12 +1833,10 @@ class EditPage { $this->missingSummary = true; $status->fatal( 'missingsummary' ); $status->value = self::AS_SUMMARY_NEEDED; - wfProfileOut( __METHOD__ ); return $status; } # All's well - wfProfileIn( __METHOD__ . '-sectionanchor' ); $sectionanchor = ''; if ( $this->section == 'new' ) { $this->summary = $this->newSectionSummary( $sectionanchor ); @@ -1912,7 +1853,6 @@ class EditPage { } } $result['sectionanchor'] = $sectionanchor; - wfProfileOut( __METHOD__ . '-sectionanchor' ); // Save errors may fall down to the edit form, but we've now // merged the section into full text. Clear the section field @@ -1934,7 +1874,6 @@ class EditPage { $this->selfRedirect = true; $status->fatal( 'selfredirect' ); $status->value = self::AS_SELF_REDIRECT; - wfProfileOut( __METHOD__ ); return $status; } } @@ -1944,7 +1883,6 @@ class EditPage { if ( $this->kblength > $wgMaxArticleSize ) { $this->tooBig = true; $status->setResult( false, self::AS_MAX_ARTICLE_SIZE_EXCEEDED ); - wfProfileOut( __METHOD__ ); return $status; } @@ -1974,7 +1912,6 @@ class EditPage { // Destroys data doEdit() put in $status->value but who cares $doEditStatus->value = self::AS_END; } - wfProfileOut( __METHOD__ ); return $doEditStatus; } @@ -1985,7 +1922,6 @@ class EditPage { } $result['redirect'] = $content->isRedirect(); $this->updateWatchlist(); - wfProfileOut( __METHOD__ ); return $status; } @@ -2022,7 +1958,6 @@ class EditPage { * @return bool */ private function mergeChangesIntoContent( &$editContent ) { - wfProfileIn( __METHOD__ ); $db = wfGetDB( DB_MASTER ); @@ -2031,7 +1966,6 @@ class EditPage { $baseContent = $baseRevision ? $baseRevision->getContent() : null; if ( is_null( $baseContent ) ) { - wfProfileOut( __METHOD__ ); return false; } @@ -2040,7 +1974,6 @@ class EditPage { $currentContent = $currentRevision ? $currentRevision->getContent() : null; if ( is_null( $currentContent ) ) { - wfProfileOut( __METHOD__ ); return false; } @@ -2050,11 +1983,9 @@ class EditPage { if ( $result ) { $editContent = $result; - wfProfileOut( __METHOD__ ); return true; } - wfProfileOut( __METHOD__ ); return false; } @@ -2373,8 +2304,6 @@ class EditPage { function showEditForm( $formCallback = null ) { global $wgOut, $wgUser; - wfProfileIn( __METHOD__ ); - # need to parse the preview early so that we know which templates are used, # otherwise users with "show preview after edit box" will get a blank list # we parse this near the beginning so that setHeaders can do the title @@ -2389,7 +2318,6 @@ class EditPage { $this->setHeaders(); if ( $this->showHeader() === false ) { - wfProfileOut( __METHOD__ ); return; } @@ -2593,7 +2521,6 @@ class EditPage { $this->displayPreviewArea( $previewOutput, false ); } - wfProfileOut( __METHOD__ ); } /** @@ -3250,8 +3177,6 @@ HTML return ''; } - wfProfileIn( __METHOD__ ); - $limitReport = Html::rawElement( 'div', array( 'class' => 'mw-limitReportExplanation' ), wfMessage( 'limitreport-title' )->parseAsBlock() ); @@ -3286,8 +3211,6 @@ HTML Html::closeElement( 'table' ) . Html::closeElement( 'div' ); - wfProfileOut( __METHOD__ ); - return $limitReport; } @@ -3473,8 +3396,6 @@ HTML global $wgOut, $wgUser, $wgRawHtml, $wgLang; global $wgAllowUserCss, $wgAllowUserJs; - wfProfileIn( __METHOD__ ); - if ( $wgRawHtml && !$this->mTokenOk ) { // Could be an offsite preview attempt. This is very unsafe if // HTML is enabled, as it could be an attack. @@ -3486,7 +3407,6 @@ HTML $parsedNote = $wgOut->parse( "
" . wfMessage( 'session_fail_preview_html' )->text() . "
", true, /* interface */true ); } - wfProfileOut( __METHOD__ ); return $parsedNote; } @@ -3500,7 +3420,6 @@ HTML 'AlternateEditPreview', array( $this, &$content, &$previewHTML, &$this->mParserOutput ) ) ) { - wfProfileOut( __METHOD__ ); return $previewHTML; } @@ -3619,7 +3538,6 @@ HTML 'class' => 'mw-content-' . $pageViewLang->getDir() ); $previewHTML = Html::rawElement( 'div', $attribs, $previewHTML ); - wfProfileOut( __METHOD__ ); return $previewhead . $previewHTML . $this->previewTextAfterContent; } diff --git a/includes/Export.php b/includes/Export.php index dd5cb0c29f..4600feb5ad 100644 --- a/includes/Export.php +++ b/includes/Export.php @@ -213,7 +213,6 @@ class WikiExporter { * @param array $cond */ protected function do_list_authors( $cond ) { - wfProfileIn( __METHOD__ ); $this->author_list = ""; // rev_deleted @@ -239,7 +238,6 @@ class WikiExporter { ""; } $this->author_list .= ""; - wfProfileOut( __METHOD__ ); } /** @@ -248,7 +246,6 @@ class WikiExporter { * @throws Exception */ protected function dumpFrom( $cond = '' ) { - wfProfileIn( __METHOD__ ); # For logging dumps... if ( $this->history & self::LOGS ) { $where = array( 'user_id = log_user' ); @@ -304,7 +301,6 @@ class WikiExporter { } // Inform caller about problem - wfProfileOut( __METHOD__ ); throw $e; } # For page dumps... @@ -349,7 +345,6 @@ class WikiExporter { $join['revision'] = array( 'INNER JOIN', 'page_id=rev_page AND page_latest=rev_id' ); # One, and only one hook should set this, and return false if ( Hooks::run( 'WikiExporter::dumpStableQuery', array( &$tables, &$opts, &$join ) ) ) { - wfProfileOut( __METHOD__ ); throw new MWException( __METHOD__ . " given invalid history dump type." ); } } elseif ( $this->history & WikiExporter::RANGE ) { @@ -358,7 +353,6 @@ class WikiExporter { $opts['ORDER BY'] = array( 'rev_page ASC', 'rev_id ASC' ); } else { # Unknown history specification parameter? - wfProfileOut( __METHOD__ ); throw new MWException( __METHOD__ . " given invalid history dump type." ); } # Query optimization hacks @@ -417,7 +411,6 @@ class WikiExporter { throw $e; } } - wfProfileOut( __METHOD__ ); } /** @@ -651,7 +644,6 @@ class XmlDumpWriter { * @access private */ function writeRevision( $row ) { - wfProfileIn( __METHOD__ ); $out = " \n"; $out .= " " . Xml::element( 'id', null, strval( $row->rev_id ) ) . "\n"; @@ -726,7 +718,6 @@ class XmlDumpWriter { $out .= " \n"; - wfProfileOut( __METHOD__ ); return $out; } @@ -739,7 +730,6 @@ class XmlDumpWriter { * @access private */ function writeLogItem( $row ) { - wfProfileIn( __METHOD__ ); $out = " \n"; $out .= " " . Xml::element( 'id', null, strval( $row->log_id ) ) . "\n"; @@ -773,7 +763,6 @@ class XmlDumpWriter { $out .= " \n"; - wfProfileOut( __METHOD__ ); return $out; } diff --git a/includes/Feed.php b/includes/Feed.php index 2fdfa424c4..9be3f577cb 100644 --- a/includes/Feed.php +++ b/includes/Feed.php @@ -184,7 +184,8 @@ class FeedItem { } /** - * @todo document (needs one-sentence top-level class description). + * Class to support the outputting of syndication feeds in Atom and RSS format. + * * @ingroup Feed */ abstract class ChannelFeed extends FeedItem { @@ -338,13 +339,14 @@ class RSSFeed extends ChannelFeed { */ class AtomFeed extends ChannelFeed { /** - * @todo document - * @param string|int $ts + * Format a date given timestamp. + * + * @param string|int $timestamp * @return string */ - function formatTime( $ts ) { + function formatTime( $timestamp ) { // need to use RFC 822 time format at least for rss2.0 - return gmdate( 'Y-m-d\TH:i:s', wfTimestamp( TS_UNIX, $ts ) ); + return gmdate( 'Y-m-d\TH:i:s', wfTimestamp( TS_UNIX, $timestamp ) ); } /** diff --git a/includes/FeedUtils.php b/includes/FeedUtils.php index 6937c32d92..15fdbc59cc 100644 --- a/includes/FeedUtils.php +++ b/includes/FeedUtils.php @@ -106,7 +106,6 @@ class FeedUtils { $comment, $actiontext = '' ) { global $wgFeedDiffCutoff, $wgLang; - wfProfileIn( __METHOD__ ); // log entries $completeText = '

' . implode( ' ', @@ -124,12 +123,10 @@ class FeedUtils { // Can't diff special pages, unreadable pages or pages with no new revision // to compare against: just return the text. if ( $title->getNamespace() < 0 || $accErrors || !$newid ) { - wfProfileOut( __METHOD__ ); return $completeText; } if ( $oldid ) { - wfProfileIn( __METHOD__ . "-dodiff" ); #$diffText = $de->getDiff( wfMessage( 'revisionasof', # $wgLang->timeanddate( $timestamp ), @@ -170,7 +167,6 @@ class FeedUtils { $diffText = UtfNormal::cleanUp( $diffText ); $diffText = self::applyDiffStyle( $diffText ); } - wfProfileOut( __METHOD__ . "-dodiff" ); } else { $rev = Revision::newFromId( $newid ); if ( $wgFeedDiffCutoff <= 0 || is_null( $rev ) ) { @@ -208,7 +204,6 @@ class FeedUtils { } $completeText .= $diffText; - wfProfileOut( __METHOD__ ); return $completeText; } diff --git a/includes/FileDeleteForm.php b/includes/FileDeleteForm.php index 1c709e6253..c1d14db0a6 100644 --- a/includes/FileDeleteForm.php +++ b/includes/FileDeleteForm.php @@ -201,7 +201,7 @@ class FileDeleteForm { $dbw->rollback( __METHOD__ ); } } - } catch ( MWException $e ) { + } catch ( Exception $e ) { // Rollback before returning to prevent UI from displaying // incorrect "View or restore N deleted edits?" $dbw->rollback( __METHOD__ ); diff --git a/includes/GlobalFunctions.php b/includes/GlobalFunctions.php index 403566e1e1..5232413fdd 100644 --- a/includes/GlobalFunctions.php +++ b/includes/GlobalFunctions.php @@ -160,6 +160,80 @@ if ( !function_exists( 'hash_equals' ) ) { } /// @endcond +/** + * Load an extension + * + * This is the closest equivalent to: + * require_once "$IP/extensions/$name/$name.php"; + * as it will process and load the extension immediately. + * + * However, batch loading with wfLoadExtensions will + * be more performant. + * + * @param string $name Name of the extension to load + * @param string|null $path Absolute path of where to find the extension.json file + */ +function wfLoadExtension( $name, $path = null ) { + if ( !$path ) { + global $IP; + $path = "$IP/extensions/$name/extension.json"; + } + ExtensionRegistry::getInstance()->load( $path ); +} + +/** + * Load multiple extensions at once + * + * Same as wfLoadExtension, but more efficient if you + * are loading multiple extensions. + * + * If you want to specify custom paths, you should interact with + * ExtensionRegistry directly. + * + * @see wfLoadExtension + * @param string[] $exts Array of extension names to load + */ +function wfLoadExtensions( array $exts ) { + global $IP; + $registry = ExtensionRegistry::getInstance(); + foreach ( $exts as $ext ) { + $registry->queue( "$IP/extensions/$ext/extension.json" ); + } + + $registry->loadFromQueue(); +} + +/** + * Load a skin + * + * @see wfLoadExtension + * @param string $name Name of the extension to load + * @param string|null $path Absolute path of where to find the skin.json file + */ +function wfLoadSkin( $name, $path = null ) { + if ( !$path ) { + global $IP; + $path = "$IP/skins/$name/skin.json"; + } + ExtensionRegistry::getInstance()->load( $path ); +} + +/** + * Load multiple skins at once + * + * @see wfLoadExtensions + * @param string[] $skins Array of extension names to load + */ +function wfLoadSkins( array $skins ) { + global $IP; + $registry = ExtensionRegistry::getInstance(); + foreach ( $skins as $skin ) { + $registry->queue( "$IP/skins/$skin/skin.json" ); + } + + $registry->loadFromQueue(); +} + /** * Like array_diff( $a, $b ) except that it works with two-dimensional arrays. * @param array $a @@ -1004,7 +1078,7 @@ function wfDebug( $text, $dest = 'all', array $context = array() ) { $context['prefix'] = $wgDebugLogPrefix; } - $logger = MWLogger::getInstance( 'wfDebug' ); + $logger = MWLoggerFactory::getInstance( 'wfDebug' ); $logger->debug( $text, $context ); } @@ -1108,7 +1182,7 @@ function wfDebugLog( MWDebug::debugMsg( "[{$logGroup}] {$text}\n" ); } - $logger = MWLogger::getInstance( $logGroup ); + $logger = MWLoggerFactory::getInstance( $logGroup ); $context['private'] = ( $dest === 'private' ); $logger->info( $text, $context ); } @@ -1122,7 +1196,7 @@ function wfDebugLog( * @param array $context Additional logging context data */ function wfLogDBError( $text, array $context = array() ) { - $logger = MWLogger::getInstance( 'wfLogDBError' ); + $logger = MWLoggerFactory::getInstance( 'wfLogDBError' ); $logger->error( trim( $text ), $context ); } @@ -1185,7 +1259,7 @@ function wfLogWarning( $msg, $callerOffset = 1, $level = E_USER_WARNING ) { */ function wfErrorLog( $text, $file, array $context = array() ) { wfDeprecated( __METHOD__, '1.25' ); - $logger = MWLogger::getInstance( 'wfErrorLog' ); + $logger = MWLoggerFactory::getInstance( 'wfErrorLog' ); $context['destination'] = $file; $logger->info( trim( $text ), $context ); } @@ -1254,13 +1328,13 @@ function wfLogProfilingData() { // any knowledge about an URL and throw an exception instead. try { $ctx['url'] = urldecode( $wgRequest->getRequestURL() ); - } catch ( MWException $ignored ) { + } catch ( Exception $ignored ) { // no-op } $ctx['output'] = $profiler->getOutput(); - $log = MWLogger::getInstance( 'profileoutput' ); + $log = MWLoggerFactory::getInstance( 'profileoutput' ); $log->info( "Elapsed: {elapsed}; URL: <{url}>\n{output}", $ctx ); } @@ -1515,10 +1589,8 @@ function wfMsgForContentNoTrans( $key ) { function wfMsgReal( $key, $args, $useDB = true, $forContent = false, $transform = true ) { wfDeprecated( __METHOD__, '1.21' ); - wfProfileIn( __METHOD__ ); $message = wfMsgGetKey( $key, $useDB, $forContent, $transform ); $message = wfMsgReplaceArgs( $message, $args ); - wfProfileOut( __METHOD__ ); return $message; } @@ -2075,10 +2147,12 @@ function wfVarDump( $var ) { */ function wfHttpError( $code, $label, $desc ) { global $wgOut; - $wgOut->disable(); header( "HTTP/1.0 $code $label" ); header( "Status: $code $label" ); - $wgOut->sendCacheControl(); + if ( $wgOut ) { + $wgOut->disable(); + $wgOut->sendCacheControl(); + } header( 'Content-type: text/html; charset=utf-8' ); print "" . @@ -3952,7 +4026,7 @@ function wfGetLangConverterCacheStorage() { * @param string|null $deprecatedVersion Optionally mark hook as deprecated with version number * * @return bool True if no handler aborted the hook - * @deprecated 1.25 + * @deprecated 1.25 - use Hooks::run */ function wfRunHooks( $event, array $args = array(), $deprecatedVersion = null ) { return Hooks::run( $event, $args, $deprecatedVersion ); @@ -4010,7 +4084,6 @@ function wfUnpack( $format, $data, $length = false ) { */ function wfIsBadImage( $name, $contextTitle = false, $blacklist = null ) { static $badImageCache = null; // based on bad_image_list msg - wfProfileIn( __METHOD__ ); # Handle redirects $redirectTitle = RepoGroup::singleton()->checkRedirect( Title::makeTitle( NS_FILE, $name ) ); @@ -4021,7 +4094,6 @@ function wfIsBadImage( $name, $contextTitle = false, $blacklist = null ) { # Run the extension hook $bad = false; if ( !Hooks::run( 'BadImage', array( $name, &$bad ) ) ) { - wfProfileOut( __METHOD__ ); return $bad; } @@ -4071,7 +4143,6 @@ function wfIsBadImage( $name, $contextTitle = false, $blacklist = null ) { $contextKey = $contextTitle ? $contextTitle->getPrefixedDBkey() : false; $bad = isset( $badImages[$name] ) && !isset( $badImages[$name][$contextKey] ); - wfProfileOut( __METHOD__ ); return $bad; } @@ -4127,3 +4198,91 @@ function wfIsConfiguredProxy( $ip ) { wfDeprecated( __METHOD__, '1.24' ); return IP::isConfiguredProxy( $ip ); } + +/** + * Returns true if these thumbnail parameters match one that MediaWiki + * requests from file description pages and/or parser output. + * + * $params is considered non-standard if they involve a non-standard + * width or any non-default parameters aside from width and page number. + * The number of possible files with standard parameters is far less than + * that of all combinations; rate-limiting for them can thus be more generious. + * + * @param File $file + * @param array $params + * @return bool + * @since 1.24 Moved from thumb.php to GlobalFunctions in 1.25 + */ +function wfThumbIsStandard( File $file, array $params ) { + global $wgThumbLimits, $wgImageLimits, $wgResponsiveImages; + + $multipliers = array( 1 ); + if ( $wgResponsiveImages ) { + // These available sizes are hardcoded currently elsewhere in MediaWiki. + // @see Linker::processResponsiveImages + $multipliers[] = 1.5; + $multipliers[] = 2; + } + + $handler = $file->getHandler(); + if ( !$handler || !isset( $params['width'] ) ) { + return false; + } + + $basicParams = array(); + if ( isset( $params['page'] ) ) { + $basicParams['page'] = $params['page']; + } + + $thumbLimits = array(); + $imageLimits = array(); + // Expand limits to account for multipliers + foreach ( $multipliers as $multiplier ) { + $thumbLimits = array_merge( $thumbLimits, array_map( + function ( $width ) use ( $multiplier ) { + return round( $width * $multiplier ); + }, $wgThumbLimits ) + ); + $imageLimits = array_merge( $imageLimits, array_map( + function ( $pair ) use ( $multiplier ) { + return array( + round( $pair[0] * $multiplier ), + round( $pair[1] * $multiplier ), + ); + }, $wgImageLimits ) + ); + } + + // Check if the width matches one of $wgThumbLimits + if ( in_array( $params['width'], $thumbLimits ) ) { + $normalParams = $basicParams + array( 'width' => $params['width'] ); + // Append any default values to the map (e.g. "lossy", "lossless", ...) + $handler->normaliseParams( $file, $normalParams ); + } else { + // If not, then check if the width matchs one of $wgImageLimits + $match = false; + foreach ( $imageLimits as $pair ) { + $normalParams = $basicParams + array( 'width' => $pair[0], 'height' => $pair[1] ); + // Decide whether the thumbnail should be scaled on width or height. + // Also append any default values to the map (e.g. "lossy", "lossless", ...) + $handler->normaliseParams( $file, $normalParams ); + // Check if this standard thumbnail size maps to the given width + if ( $normalParams['width'] == $params['width'] ) { + $match = true; + break; + } + } + if ( !$match ) { + return false; // not standard for description pages + } + } + + // Check that the given values for non-page, non-width, params are just defaults + foreach ( $params as $key => $value ) { + if ( !isset( $normalParams[$key] ) || $normalParams[$key] != $value ) { + return false; + } + } + + return true; +} diff --git a/includes/Html.php b/includes/Html.php index e0337463fd..93a1a044e9 100644 --- a/includes/Html.php +++ b/includes/Html.php @@ -198,8 +198,7 @@ class Html { /** * Returns an HTML element in a string. The major advantage here over * manually typing out the HTML is that it will escape all attribute - * values. If you're hardcoding all the attributes, or there are none, you - * should probably just type out the html element yourself. + * values. * * This is quite similar to Xml::tags(), but it implements some useful * HTML-specific logic. For instance, there is no $allowShortTag diff --git a/includes/HtmlFormatter.php b/includes/HtmlFormatter.php index f74c15a37e..b2926d17bc 100644 --- a/includes/HtmlFormatter.php +++ b/includes/HtmlFormatter.php @@ -133,7 +133,6 @@ class HtmlFormatter { * @return array Array of removed DOMElements */ public function filterContent() { - wfProfileIn( __METHOD__ ); $removals = $this->parseItemsToRemove(); // Bail out early if nothing to do @@ -143,7 +142,6 @@ class HtmlFormatter { }, true ) ) { - wfProfileOut( __METHOD__ ); return array(); } @@ -202,7 +200,6 @@ class HtmlFormatter { $removed = array_merge( $removed, $this->removeElements( $elements ) ); } - wfProfileOut( __METHOD__ ); return $removed; } @@ -235,7 +232,6 @@ class HtmlFormatter { * @return string */ private function fixLibXML( $html ) { - wfProfileIn( __METHOD__ ); static $replacements; if ( !$replacements ) { // We don't include rules like '"' => '&quot;' because entities had already been @@ -249,7 +245,6 @@ class HtmlFormatter { } $html = $replacements->replace( $html ); $html = mb_convert_encoding( $html, 'UTF-8', 'HTML-ENTITIES' ); - wfProfileOut( __METHOD__ ); return $html; } @@ -264,10 +259,8 @@ class HtmlFormatter { * @return string Processed HTML */ public function getText( $element = null ) { - wfProfileIn( __METHOD__ ); if ( $this->doc ) { - wfProfileIn( __METHOD__ . '-dom' ); if ( $element !== null && !( $element instanceof DOMElement ) ) { $element = $this->doc->getElementById( $element ); } @@ -283,9 +276,7 @@ class HtmlFormatter { $body->appendChild( $element ); } $html = $this->doc->saveHTML(); - wfProfileOut( __METHOD__ . '-dom' ); - wfProfileIn( __METHOD__ . '-fixes' ); $html = $this->fixLibXml( $html ); if ( wfIsWindows() ) { // Cleanup for CRLF misprocessing of unknown origin on Windows. @@ -294,7 +285,6 @@ class HtmlFormatter { // XML code paths if possible and fix there. $html = str_replace( ' ', '', $html ); } - wfProfileOut( __METHOD__ . '-fixes' ); } else { $html = $this->html; } @@ -302,14 +292,11 @@ class HtmlFormatter { $html = preg_replace( '/|^.*?|<\/body>.*$/s', '', $html ); $html = $this->onHtmlReady( $html ); - wfProfileIn( __METHOD__ . '-flatten' ); if ( $this->elementsToFlatten ) { $elements = implode( '|', $this->elementsToFlatten ); $html = preg_replace( "#]*>#is", '', $html ); } - wfProfileOut( __METHOD__ . '-flatten' ); - wfProfileOut( __METHOD__ ); return $html; } @@ -350,7 +337,6 @@ class HtmlFormatter { * @return array */ protected function parseItemsToRemove() { - wfProfileIn( __METHOD__ ); $removals = array( 'ID' => array(), 'TAG' => array(), @@ -372,7 +358,6 @@ class HtmlFormatter { $removals['TAG'][] = 'video'; } - wfProfileOut( __METHOD__ ); return $removals; } } diff --git a/includes/HttpFunctions.php b/includes/HttpFunctions.php index f9ee14bbd9..d066df89e6 100644 --- a/includes/HttpFunctions.php +++ b/includes/HttpFunctions.php @@ -59,7 +59,6 @@ class Http { */ public static function request( $method, $url, $options = array() ) { wfDebug( "HTTP: $method: $url\n" ); - wfProfileIn( __METHOD__ . "-$method" ); $options['method'] = strtoupper( $method ); @@ -77,7 +76,6 @@ class Http { if ( $status->isOK() ) { $content = $req->getContent(); } - wfProfileOut( __METHOD__ . "-$method" ); return $content; } @@ -436,7 +434,6 @@ class MWHttpRequest { * @return Status */ public function execute() { - wfProfileIn( __METHOD__ ); $this->content = ""; @@ -454,7 +451,6 @@ class MWHttpRequest { $this->setUserAgent( Http::userAgent() ); } - wfProfileOut( __METHOD__ ); } /** @@ -463,7 +459,6 @@ class MWHttpRequest { * found in an array in the member variable headerList. */ protected function parseHeader() { - wfProfileIn( __METHOD__ ); $lastname = ""; @@ -482,7 +477,6 @@ class MWHttpRequest { $this->parseCookies(); - wfProfileOut( __METHOD__ ); } /** @@ -616,7 +610,6 @@ class MWHttpRequest { * Parse the cookies in the response headers and store them in the cookie jar. */ protected function parseCookies() { - wfProfileIn( __METHOD__ ); if ( !$this->cookieJar ) { $this->cookieJar = new CookieJar; @@ -629,7 +622,6 @@ class MWHttpRequest { } } - wfProfileOut( __METHOD__ ); } /** @@ -717,12 +709,10 @@ class CurlHttpRequest extends MWHttpRequest { } public function execute() { - wfProfileIn( __METHOD__ ); parent::execute(); if ( !$this->status->isOK() ) { - wfProfileOut( __METHOD__ ); return $this->status; } @@ -768,7 +758,6 @@ class CurlHttpRequest extends MWHttpRequest { $curlHandle = curl_init( $this->url ); if ( !curl_setopt_array( $curlHandle, $this->curlOptions ) ) { - wfProfileOut( __METHOD__ ); throw new MWException( "Error setting curl options." ); } @@ -797,8 +786,6 @@ class CurlHttpRequest extends MWHttpRequest { $this->parseHeader(); $this->setStatus(); - wfProfileOut( __METHOD__ ); - return $this->status; } @@ -834,7 +821,6 @@ class PhpHttpRequest extends MWHttpRequest { } public function execute() { - wfProfileIn( __METHOD__ ); parent::execute(); @@ -940,13 +926,11 @@ class PhpHttpRequest extends MWHttpRequest { if ( $fh === false ) { $this->status->fatal( 'http-request-error' ); - wfProfileOut( __METHOD__ ); return $this->status; } if ( $result['timed_out'] ) { $this->status->fatal( 'http-timed-out', $this->url ); - wfProfileOut( __METHOD__ ); return $this->status; } @@ -968,8 +952,6 @@ class PhpHttpRequest extends MWHttpRequest { } fclose( $fh ); - wfProfileOut( __METHOD__ ); - return $this->status; } } diff --git a/includes/Import.php b/includes/Import.php index daefb8804a..eb2ca778fe 100644 --- a/includes/Import.php +++ b/includes/Import.php @@ -32,20 +32,25 @@ */ class WikiImporter { private $reader = null; + private $foreignNamespaces = null; private $mLogItemCallback, $mUploadCallback, $mRevisionCallback, $mPageCallback; - private $mSiteInfoCallback, $mTargetNamespace, $mTargetRootPage, $mPageOutCallback; + private $mSiteInfoCallback, $mTargetNamespace, $mPageOutCallback; private $mNoticeCallback, $mDebug; private $mImportUploads, $mImageBasePath; private $mNoUpdates = false; /** @var Config */ private $config; + /** @var ImportTitleFactory */ + private $importTitleFactory; + /** @var array */ + private $countableCache = array(); /** * Creates an ImportXMLReader drawing from the source provided - * @param ImportStreamSource $source + * @param ImportSource $source * @param Config $config */ - function __construct( ImportStreamSource $source, Config $config = null ) { + function __construct( ImportSource $source, Config $config = null ) { $this->reader = new XMLReader(); if ( !$config ) { wfDeprecated( __METHOD__ . ' without a Config instance', '1.25' ); @@ -64,10 +69,13 @@ class WikiImporter { } // Default callbacks + $this->setPageCallback( array( $this, 'beforeImportPage' ) ); $this->setRevisionCallback( array( $this, "importRevision" ) ); $this->setUploadCallback( array( $this, 'importUpload' ) ); $this->setLogItemCallback( array( $this, 'importLogItem' ) ); $this->setPageOutCallback( array( $this, 'finishImportPage' ) ); + + $this->importTitleFactory = new NaiveImportTitleFactory(); } /** @@ -199,6 +207,15 @@ class WikiImporter { return $previous; } + /** + * Sets the factory object to use to convert ForeignTitle objects into local + * Title objects + * @param ImportTitleFactory $factory + */ + public function setImportTitleFactory( $factory ) { + $this->importTitleFactory = $factory; + } + /** * Set a target namespace to override the defaults * @param null|int $namespace @@ -208,9 +225,16 @@ class WikiImporter { if ( is_null( $namespace ) ) { // Don't override namespaces $this->mTargetNamespace = null; - } elseif ( $namespace >= 0 ) { - // @todo FIXME: Check for validity - $this->mTargetNamespace = intval( $namespace ); + $this->setImportTitleFactory( new NaiveImportTitleFactory() ); + return true; + } elseif ( + $namespace >= 0 && + MWNamespace::exists( intval( $namespace ) ) + ) { + $namespace = intval( $namespace ); + $this->mTargetNamespace = $namespace; + $this->setImportTitleFactory( new NamespaceImportTitleFactory( $namespace ) ); + return true; } else { return false; } @@ -225,7 +249,7 @@ class WikiImporter { $status = Status::newGood(); if ( is_null( $rootpage ) ) { // No rootpage - $this->mTargetRootPage = null; + $this->setImportTitleFactory( new NaiveImportTitleFactory() ); } elseif ( $rootpage !== '' ) { $rootpage = rtrim( $rootpage, '/' ); //avoid double slashes $title = Title::newFromText( $rootpage, !is_null( $this->mTargetNamespace ) @@ -244,9 +268,9 @@ class WikiImporter { : $wgContLang->getNsText( $title->getNamespace() ); $status->fatal( 'import-rootpage-nosubpage', $displayNSText ); } else { - // set namespace to 'all', so the namespace check in processTitle() can passed + // set namespace to 'all', so the namespace check in processTitle() can pass $this->setTargetNamespace( null ); - $this->mTargetRootPage = $title->getPrefixedDBkey(); + $this->setImportTitleFactory( new SubpageImportTitleFactory( $title ) ); } } } @@ -267,6 +291,19 @@ class WikiImporter { $this->mImportUploads = $import; } + /** + * Default per-page callback. Sets up some things related to site statistics + * @param array $titleAndForeignTitle Two-element array, with Title object at + * index 0 and ForeignTitle object at index 1 + * @return bool + */ + public function beforeImportPage( $titleAndForeignTitle ) { + $title = $titleAndForeignTitle[0]; + $page = WikiPage::factory( $title ); + $this->countableCache['title_' . $title->getPrefixedText()] = $page->isCountable(); + return true; + } + /** * Default per-revision callback, performs the import. * @param WikiRevision $revision @@ -320,13 +357,34 @@ class WikiImporter { /** * Mostly for hook use * @param Title $title - * @param string $origTitle + * @param ForeignTitle $foreignTitle * @param int $revCount * @param int $sRevCount * @param array $pageInfo * @return bool */ - public function finishImportPage( $title, $origTitle, $revCount, $sRevCount, $pageInfo ) { + public function finishImportPage( $title, $foreignTitle, $revCount, + $sRevCount, $pageInfo ) { + + // Update article count statistics (T42009) + // The normal counting logic in WikiPage->doEditUpdates() is designed for + // one-revision-at-a-time editing, not bulk imports. In this situation it + // suffers from issues of slave lag. We let WikiPage handle the total page + // and revision count, and we implement our own custom logic for the + // article (content page) count. + $page = WikiPage::factory( $title ); + $page->loadPageData( 'fromdbmaster' ); + $content = $page->getContent(); + $editInfo = $page->prepareContentForEdit( $content ); + + $countable = $page->isCountable( $editInfo ); + $oldcountable = $this->countableCache['title_' . $title->getPrefixedText()]; + if ( isset( $oldcountable ) && $countable != $oldcountable ) { + DeferredUpdates::addUpdate( SiteStatsUpdate::factory( array( + 'articles' => ( (int)$countable - (int)$oldcountable ) + ) ) ); + } + $args = func_get_args(); return Hooks::run( 'AfterImportPage', $args ); } @@ -348,6 +406,20 @@ class WikiImporter { $this->debug( "-- Text: " . $revision->text ); } + /** + * Notify the callback function of site info + * @param array $siteInfo + * @return bool|mixed + */ + private function siteInfoCallback( $siteInfo ) { + if ( isset( $this->mSiteInfoCallback ) ) { + return call_user_func_array( $this->mSiteInfoCallback, + array( $siteInfo, $this ) ); + } else { + return false; + } + } + /** * Notify the callback function when a new "" is reached. * @param Title $title @@ -361,12 +433,13 @@ class WikiImporter { /** * Notify the callback function when a "" is closed. * @param Title $title - * @param Title $origTitle + * @param ForeignTitle $foreignTitle * @param int $revCount * @param int $sucCount Number of revisions for which callback returned true * @param array $pageInfo Associative array of page information */ - private function pageOutCallback( $title, $origTitle, $revCount, $sucCount, $pageInfo ) { + private function pageOutCallback( $title, $foreignTitle, $revCount, + $sucCount, $pageInfo ) { if ( isset( $this->mPageOutCallback ) ) { $args = func_get_args(); call_user_func_array( $this->mPageOutCallback, $args ); @@ -460,51 +533,76 @@ class WikiImporter { $keepReading = $this->reader->read(); $skip = false; - while ( $keepReading ) { - $tag = $this->reader->name; - $type = $this->reader->nodeType; - - if ( !Hooks::run( 'ImportHandleToplevelXMLTag', array( $this ) ) ) { - // Do nothing - } elseif ( $tag == 'mediawiki' && $type == XMLReader::END_ELEMENT ) { - break; - } elseif ( $tag == 'siteinfo' ) { - $this->handleSiteInfo(); - } elseif ( $tag == 'page' ) { - $this->handlePage(); - } elseif ( $tag == 'logitem' ) { - $this->handleLogItem(); - } elseif ( $tag != '#text' ) { - $this->warn( "Unhandled top-level XML tag $tag" ); - - $skip = true; - } + $rethrow = null; + try { + while ( $keepReading ) { + $tag = $this->reader->name; + $type = $this->reader->nodeType; + + if ( !Hooks::run( 'ImportHandleToplevelXMLTag', array( $this ) ) ) { + // Do nothing + } elseif ( $tag == 'mediawiki' && $type == XMLReader::END_ELEMENT ) { + break; + } elseif ( $tag == 'siteinfo' ) { + $this->handleSiteInfo(); + } elseif ( $tag == 'page' ) { + $this->handlePage(); + } elseif ( $tag == 'logitem' ) { + $this->handleLogItem(); + } elseif ( $tag != '#text' ) { + $this->warn( "Unhandled top-level XML tag $tag" ); + + $skip = true; + } - if ( $skip ) { - $keepReading = $this->reader->next(); - $skip = false; - $this->debug( "Skip" ); - } else { - $keepReading = $this->reader->read(); + if ( $skip ) { + $keepReading = $this->reader->next(); + $skip = false; + $this->debug( "Skip" ); + } else { + $keepReading = $this->reader->read(); + } } + } catch ( Exception $ex ) { + $rethrow = $ex; } + // finally libxml_disable_entity_loader( $oldDisable ); + $this->reader->close(); + + if ( $rethrow ) { + throw $rethrow; + } + return true; } - /** - * @return bool - * @throws MWException - */ private function handleSiteInfo() { - // Site info is useful, but not actually used for dump imports. - // Includes a quick short-circuit to save performance. - if ( !$this->mSiteInfoCallback ) { - $this->reader->next(); - return true; + $this->debug( "Enter site info handler." ); + $siteInfo = array(); + + // Fields that can just be stuffed in the siteInfo object + $normalFields = array( 'sitename', 'base', 'generator', 'case' ); + + while ( $this->reader->read() ) { + if ( $this->reader->nodeType == XmlReader::END_ELEMENT && + $this->reader->name == 'siteinfo' ) { + break; + } + + $tag = $this->reader->name; + + if ( $tag == 'namespace' ) { + $this->foreignNamespaces[ $this->nodeAttribute( 'key' ) ] = + $this->nodeContents(); + } elseif ( in_array( $tag, $normalFields ) ) { + $siteInfo[$tag] = $this->nodeContents(); + } } - throw new MWException( "SiteInfo tag is not yet handled, do not set mSiteInfoCallback" ); + + $siteInfo['_namespaces'] = $this->foreignNamespaces; + $this->siteInfoCallback( $siteInfo ); } private function handleLogItem() { @@ -574,7 +672,7 @@ class WikiImporter { $pageInfo = array( 'revisionCount' => 0, 'successfulRevisionCount' => 0 ); // Fields that can just be stuffed in the pageInfo object - $normalFields = array( 'title', 'id', 'redirect', 'restrictions' ); + $normalFields = array( 'title', 'ns', 'id', 'redirect', 'restrictions' ); $skip = false; $badTitle = false; @@ -585,6 +683,8 @@ class WikiImporter { break; } + $skip = false; + $tag = $this->reader->name; if ( $badTitle ) { @@ -605,29 +705,35 @@ class WikiImporter { $pageInfo[$tag] = $this->nodeAttribute( 'title' ); } else { $pageInfo[$tag] = $this->nodeContents(); - if ( $tag == 'title' ) { - $title = $this->processTitle( $pageInfo['title'] ); + } + } elseif ( $tag == 'revision' || $tag == 'upload' ) { + if ( !isset( $title ) ) { + $title = $this->processTitle( $pageInfo['title'], + isset( $pageInfo['ns'] ) ? $pageInfo['ns'] : null ); + + if ( !$title ) { + $badTitle = true; + $skip = true; + } - if ( !$title ) { - $badTitle = true; - $skip = true; - } + $this->pageCallback( $title ); + list( $pageInfo['_title'], $foreignTitle ) = $title; + } - $this->pageCallback( $title ); - list( $pageInfo['_title'], $origTitle ) = $title; + if ( $title ) { + if ( $tag == 'revision' ) { + $this->handleRevision( $pageInfo ); + } else { + $this->handleUpload( $pageInfo ); } } - } elseif ( $tag == 'revision' ) { - $this->handleRevision( $pageInfo ); - } elseif ( $tag == 'upload' ) { - $this->handleUpload( $pageInfo ); } elseif ( $tag != '#text' ) { $this->warn( "Unhandled page XML tag $tag" ); $skip = true; } } - $this->pageOutCallback( $pageInfo['_title'], $origTitle, + $this->pageOutCallback( $pageInfo['_title'], $foreignTitle, $pageInfo['revisionCount'], $pageInfo['successfulRevisionCount'], $pageInfo ); @@ -852,28 +958,27 @@ class WikiImporter { /** * @param string $text + * @param string|null $ns * @return array|bool */ - private function processTitle( $text ) { - $workTitle = $text; - $origTitle = Title::newFromText( $workTitle ); - - if ( !is_null( $this->mTargetNamespace ) && !is_null( $origTitle ) ) { - # makeTitleSafe, because $origTitle can have a interwiki (different setting of interwiki map) - # and than dbKey can begin with a lowercase char - $title = Title::makeTitleSafe( $this->mTargetNamespace, - $origTitle->getDBkey() ); + private function processTitle( $text, $ns = null ) { + if ( is_null( $this->foreignNamespaces ) ) { + $foreignTitleFactory = new NaiveForeignTitleFactory(); } else { - if ( !is_null( $this->mTargetRootPage ) ) { - $workTitle = $this->mTargetRootPage . '/' . $workTitle; - } - $title = Title::newFromText( $workTitle ); + $foreignTitleFactory = new NamespaceAwareForeignTitleFactory( + $this->foreignNamespaces ); } + $foreignTitle = $foreignTitleFactory->createForeignTitle( $text, + intval( $ns ) ); + + $title = $this->importTitleFactory->createTitleFromForeignTitle( + $foreignTitle ); + $commandLineMode = $this->config->get( 'CommandLineMode' ); if ( is_null( $title ) ) { # Invalid page title? Ignore the page - $this->notice( 'import-error-invalid', $workTitle ); + $this->notice( 'import-error-invalid', $foreignTitle->getFullText() ); return false; } elseif ( $title->isExternal() ) { $this->notice( 'import-error-interwiki', $title->getPrefixedText() ); @@ -891,7 +996,7 @@ class WikiImporter { return false; } - return array( $title, $origTitle ); + return array( $title, $foreignTitle ); } } @@ -910,10 +1015,10 @@ class UploadSourceAdapter { private $mPosition; /** - * @param ImportStreamSource $source + * @param ImportSource $source * @return string */ - static function registerSource( ImportStreamSource $source ) { + static function registerSource( ImportSource $source ) { $id = wfRandomString(); self::$sourceRegistrations[$id] = $source; @@ -1475,7 +1580,6 @@ class WikiRevision { $this->title->getPrefixedText() . "]], timestamp " . $this->timestamp . "\n" ); return false; } - $oldcountable = $page->isCountable(); } # @todo FIXME: Use original rev_id optionally (better for backups) @@ -1498,10 +1602,11 @@ class WikiRevision { if ( $changed !== false && !$this->mNoUpdates ) { wfDebug( __METHOD__ . ": running updates\n" ); + // countable/oldcountable stuff is handled in WikiImporter::finishImportPage $page->doEditUpdates( $revision, $userObj, - array( 'created' => $created, 'oldcountable' => $oldcountable ) + array( 'created' => $created, 'oldcountable' => 'no-change' ) ); } @@ -1613,7 +1718,7 @@ class WikiRevision { wfDebug( __METHOD__ . ": Successful\n" ); return true; } else { - wfDebug( __METHOD__ . ': failed: ' . $status->getXml() . "\n" ); + wfDebug( __METHOD__ . ': failed: ' . $status->getHTML() . "\n" ); return false; } } @@ -1651,6 +1756,30 @@ class WikiRevision { } +/** + * Source interface for XML import. + */ +interface ImportSource { + + /** + * Indicates whether the end of the input has been reached. + * Will return true after a finite number of calls to readChunk. + * + * @return bool true if there is no more input, false otherwise. + */ + function atEnd(); + + /** + * Return a chunk of the input, as a (possibly empty) string. + * When the end of input is reached, readChunk() returns false. + * If atEnd() returns false, readChunk() will return a string. + * If atEnd() returns true, readChunk() will return false. + * + * @return bool|string + */ + function readChunk(); +} + /** * Used for importing XML dumps where the content of the dump is in a string. * This class is ineffecient, and should only be used for small dumps. @@ -1658,7 +1787,7 @@ class WikiRevision { * * @ingroup SpecialPage */ -class ImportStringSource { +class ImportStringSource implements ImportSource { function __construct( $string ) { $this->mString = $string; $this->mRead = false; @@ -1687,7 +1816,7 @@ class ImportStringSource { * Imports a XML dump from a file (either from file upload, files on disk, or HTTP) * @ingroup SpecialPage */ -class ImportStreamSource { +class ImportStreamSource implements ImportSource { function __construct( $handle ) { $this->mHandle = $handle; } diff --git a/includes/Linker.php b/includes/Linker.php index 2bc36b1ab9..238bb5348a 100644 --- a/includes/Linker.php +++ b/includes/Linker.php @@ -197,7 +197,6 @@ class Linker { wfWarn( __METHOD__ . ': Requires $target to be a Title object.', 2 ); return "$html"; } - wfProfileIn( __METHOD__ ); if ( is_string( $query ) ) { // some functions withing core using this still hand over query strings @@ -212,7 +211,6 @@ class Linker { if ( !Hooks::run( 'LinkBegin', array( $dummy, $target, &$html, &$customAttribs, &$query, &$options, &$ret ) ) ) { - wfProfileOut( __METHOD__ ); return $ret; } @@ -220,7 +218,6 @@ class Linker { $target = self::normaliseSpecialPage( $target ); # If we don't know whether the page exists, let's find out. - wfProfileIn( __METHOD__ . '-checkPageExistence' ); if ( !in_array( 'known', $options ) && !in_array( 'broken', $options ) ) { if ( $target->isKnown() ) { $options[] = 'known'; @@ -228,7 +225,6 @@ class Linker { $options[] = 'broken'; } } - wfProfileOut( __METHOD__ . '-checkPageExistence' ); $oldquery = array(); if ( in_array( "forcearticlepath", $options ) && $query ) { @@ -255,7 +251,6 @@ class Linker { $ret = Html::rawElement( 'a', $attribs, $html ); } - wfProfileOut( __METHOD__ ); return $ret; } @@ -280,7 +275,6 @@ class Linker { * @return string */ private static function linkUrl( $target, $query, $options ) { - wfProfileIn( __METHOD__ ); # We don't want to include fragments for broken links, because they # generally make no sense. if ( in_array( 'broken', $options ) && $target->hasFragment() ) { @@ -306,7 +300,6 @@ class Linker { } $ret = $target->getLinkURL( $query, false, $proto ); - wfProfileOut( __METHOD__ ); return $ret; } @@ -320,12 +313,10 @@ class Linker { * @return array */ private static function linkAttribs( $target, $attribs, $options ) { - wfProfileIn( __METHOD__ ); global $wgUser; $defaults = array(); if ( !in_array( 'noclasses', $options ) ) { - wfProfileIn( __METHOD__ . '-getClasses' ); # Now build the classes. $classes = array(); @@ -346,7 +337,6 @@ class Linker { if ( $classes != array() ) { $defaults['class'] = implode( ' ', $classes ); } - wfProfileOut( __METHOD__ . '-getClasses' ); } # Get a default title attribute. @@ -370,7 +360,6 @@ class Linker { $ret[$key] = $val; } } - wfProfileOut( __METHOD__ ); return $ret; } @@ -933,7 +922,6 @@ class Linker { } global $wgEnableUploads, $wgUploadMissingFileUrl, $wgUploadNavigationUrl; - wfProfileIn( __METHOD__ ); if ( $label == '' ) { $label = $title->getPrefixedText(); } @@ -946,19 +934,16 @@ class Linker { $redir = RepoGroup::singleton()->getLocalRepo()->checkRedirect( $title ); if ( $redir ) { - wfProfileOut( __METHOD__ ); return self::linkKnown( $title, $encLabel, array(), wfCgiToArray( $query ) ); } $href = self::getUploadUrl( $title, $query ); - wfProfileOut( __METHOD__ ); return '' . $encLabel . ''; } - wfProfileOut( __METHOD__ ); return self::linkKnown( $title, $encLabel, array(), wfCgiToArray( $query ) ); } @@ -1295,7 +1280,6 @@ class Linker { * @return mixed|string */ public static function formatComment( $comment, $title = null, $local = false ) { - wfProfileIn( __METHOD__ ); # Sanitize text a bit: $comment = str_replace( "\n", " ", $comment ); @@ -1306,7 +1290,6 @@ class Linker { $comment = self::formatAutocomments( $comment, $title, $local ); $comment = self::formatLinksInComment( $comment, $title, $local ); - wfProfileOut( __METHOD__ ); return $comment; } @@ -1402,9 +1385,11 @@ class Linker { * @param string $comment Text to format links in * @param Title|null $title An optional title object used to links to sections * @param bool $local Whether section links should refer to local page + * @param string|null $wikiId Id of the wiki to link to (if not the local wiki), as used by WikiMap + * * @return string */ - public static function formatLinksInComment( $comment, $title = null, $local = false ) { + public static function formatLinksInComment( $comment, $title = null, $local = false, $wikiId = null ) { return preg_replace_callback( '/ \[\[ @@ -1418,7 +1403,7 @@ class Linker { \]\] ([^[]*) # 3. link trail (the text up until the next link) /x', - function ( $match ) use ( $title, $local ) { + function ( $match ) use ( $title, $local, $wikiId ) { global $wgContLang; $medians = '(?:' . preg_quote( MWNamespace::getCanonicalName( NS_MEDIA ), '/' ) . '|'; @@ -1474,11 +1459,22 @@ class Linker { $newTarget = clone ( $title ); $newTarget->setFragment( '#' . $target->getFragment() ); $target = $newTarget; + } - $thelink = Linker::link( - $target, - $linkText . $inside - ) . $trail; + + if ( $wikiId !== null ) { + $thelink = Linker::makeExternalLink( + WikiMap::getForeignURL( $wikiId, $target->getFullText() ), + $linkText . $inside, + /* escape = */ false // Already escaped + ) . $trail; + } else { + $thelink = Linker::link( + $target, + $linkText . $inside + ) . $trail; + } + } } if ( $thelink ) { @@ -1515,7 +1511,6 @@ class Linker { # ../Foobar/ -- convert to CurrentPage/Foobar, use 'Foobar' as text # (from CurrentPage/CurrentSubPage) - wfProfileIn( __METHOD__ ); $ret = $target; # default return value is no change # Some namespaces don't allow subpages, @@ -1574,7 +1569,6 @@ class Linker { } } - wfProfileOut( __METHOD__ ); return $ret; } @@ -1611,7 +1605,7 @@ class Linker { * @return string HTML fragment */ public static function revComment( Revision $rev, $local = false, $isPublic = false ) { - if ( $rev->getRawComment() == "" ) { + if ( $rev->getComment( Revision::RAW ) == "" ) { return ""; } if ( $rev->isDeleted( Revision::DELETED_COMMENT ) && $isPublic ) { @@ -1876,7 +1870,7 @@ class Linker { $editCount = 0; $moreRevs = false; foreach ( $res as $row ) { - if ( $rev->getRawUserText() != $row->rev_user_text ) { + if ( $rev->getUserText( Revision::RAW ) != $row->rev_user_text ) { if ( $verify && ( $row->rev_deleted & Revision::DELETED_TEXT || $row->rev_deleted & Revision::DELETED_USER @@ -1997,7 +1991,6 @@ class Linker { $section = false, $more = null ) { global $wgLang; - wfProfileIn( __METHOD__ ); $outText = ''; if ( count( $templates ) > 0 ) { @@ -2050,14 +2043,14 @@ class Linker { if ( $titleObj->quickUserCan( 'edit' ) ) { $editLink = self::link( $titleObj, - wfMessage( 'editlink' )->text(), + wfMessage( 'editlink' )->escaped(), array(), array( 'action' => 'edit' ) ); } else { $editLink = self::link( $titleObj, - wfMessage( 'viewsourcelink' )->text(), + wfMessage( 'viewsourcelink' )->escaped(), array(), array( 'action' => 'edit' ) ); @@ -2077,7 +2070,6 @@ class Linker { $outText .= ''; } - wfProfileOut( __METHOD__ ); return $outText; } @@ -2089,7 +2081,6 @@ class Linker { * @return string HTML output */ public static function formatHiddenCategories( $hiddencats ) { - wfProfileIn( __METHOD__ ); $outText = ''; if ( count( $hiddencats ) > 0 ) { @@ -2106,7 +2097,6 @@ class Linker { } $outText .= ''; } - wfProfileOut( __METHOD__ ); return $outText; } @@ -2135,7 +2125,6 @@ class Linker { * escape), or false for no title attribute */ public static function titleAttrib( $name, $options = null ) { - wfProfileIn( __METHOD__ ); $message = wfMessage( "tooltip-$name" ); @@ -2164,7 +2153,6 @@ class Linker { } } - wfProfileOut( __METHOD__ ); return $tooltip; } @@ -2184,7 +2172,6 @@ class Linker { if ( isset( self::$accesskeycache[$name] ) ) { return self::$accesskeycache[$name]; } - wfProfileIn( __METHOD__ ); $message = wfMessage( "accesskey-$name" ); @@ -2200,7 +2187,6 @@ class Linker { } } - wfProfileOut( __METHOD__ ); self::$accesskeycache[$name] = $accesskey; return self::$accesskeycache[$name]; } @@ -2308,7 +2294,6 @@ class Linker { static function makeLinkObj( $nt, $text = '', $query = '', $trail = '', $prefix = '' ) { wfDeprecated( __METHOD__, '1.21' ); - wfProfileIn( __METHOD__ ); $query = wfCgiToArray( $query ); list( $inside, $trail ) = self::splitTrail( $trail ); if ( $text === '' ) { @@ -2317,7 +2302,6 @@ class Linker { $ret = self::link( $nt, "$prefix$text$inside", array(), $query ) . $trail; - wfProfileOut( __METHOD__ ); return $ret; } @@ -2342,8 +2326,6 @@ class Linker { ) { wfDeprecated( __METHOD__, '1.21' ); - wfProfileIn( __METHOD__ ); - if ( $text == '' ) { $text = self::linkText( $title ); } @@ -2357,7 +2339,6 @@ class Linker { $ret = self::link( $title, "$prefix$text$inside", $attribs, $query, array( 'known', 'noclasses' ) ) . $trail; - wfProfileOut( __METHOD__ ); return $ret; } diff --git a/includes/MagicWord.php b/includes/MagicWord.php index 4b24a00d86..186821de39 100644 --- a/includes/MagicWord.php +++ b/includes/MagicWord.php @@ -330,15 +330,12 @@ class MagicWord { */ function load( $id ) { global $wgContLang; - wfProfileIn( __METHOD__ ); $this->mId = $id; $wgContLang->getMagic( $this ); if ( !$this->mSynonyms ) { $this->mSynonyms = array( 'brionmademeputthishere' ); - wfProfileOut( __METHOD__ ); throw new MWException( "Error: invalid magic word '$id'" ); } - wfProfileOut( __METHOD__ ); } /** @@ -655,7 +652,7 @@ class MagicWord { * This method uses the php feature to do several replacements at the same time, * thereby gaining some efficiency. The result is placed in the out variable * $result. The return value is true if something was replaced. - * @todo Should this be static? It doesn't seem to be used at all + * @deprecated since 1.25, unused * * @param array $magicarr * @param string $subject @@ -664,6 +661,7 @@ class MagicWord { * @return bool */ function replaceMultiple( $magicarr, $subject, &$result ) { + wfDeprecated( __METHOD__, '1.25' ); $search = array(); $replace = array(); foreach ( $magicarr as $id => $replacement ) { diff --git a/includes/MediaWiki.php b/includes/MediaWiki.php index 53b4d20bb8..c21f5e9696 100644 --- a/includes/MediaWiki.php +++ b/includes/MediaWiki.php @@ -157,8 +157,6 @@ class MediaWiki { private function performRequest() { global $wgTitle; - wfProfileIn( __METHOD__ ); - $request = $this->context->getRequest(); $requestTitle = $title = $this->context->getTitle(); $output = $this->context->getOutput(); @@ -176,7 +174,6 @@ class MediaWiki { || $title->isSpecial( 'Badtitle' ) ) { $this->context->setTitle( SpecialPage::getTitleFor( 'Badtitle' ) ); - wfProfileOut( __METHOD__ ); throw new BadTitleError(); } @@ -201,7 +198,6 @@ class MediaWiki { $this->context->setTitle( $badTitle ); $wgTitle = $badTitle; - wfProfileOut( __METHOD__ ); throw new PermissionsError( 'read', $permErrors ); } @@ -225,7 +221,6 @@ class MediaWiki { $output->redirect( $url, 301 ); } else { $this->context->setTitle( SpecialPage::getTitleFor( 'Badtitle' ) ); - wfProfileOut( __METHOD__ ); throw new BadTitleError(); } // Redirect loops, no title in URL, $wgUsePathInfo URLs, and URLs with a variant @@ -283,7 +278,6 @@ class MediaWiki { } elseif ( is_string( $article ) ) { $output->redirect( $article ); } else { - wfProfileOut( __METHOD__ ); throw new MWException( "Shouldn't happen: MediaWiki::initializeArticle()" . " returned neither an object nor a URL" ); } @@ -294,7 +288,6 @@ class MediaWiki { $user->addAutopromoteOnceGroups( 'onView' ); } - wfProfileOut( __METHOD__ ); } /** @@ -304,7 +297,6 @@ class MediaWiki { * @return mixed An Article, or a string to redirect to another URL */ private function initializeArticle() { - wfProfileIn( __METHOD__ ); $title = $this->context->getTitle(); if ( $this->context->canUseWikiPage() ) { @@ -322,7 +314,6 @@ class MediaWiki { // NS_MEDIAWIKI has no redirects. // It is also used for CSS/JS, so performance matters here... if ( $title->getNamespace() == NS_MEDIAWIKI ) { - wfProfileOut( __METHOD__ ); return $article; } @@ -353,7 +344,6 @@ class MediaWiki { if ( is_string( $target ) ) { if ( !$this->config->get( 'DisableHardRedirects' ) ) { // we'll need to redirect - wfProfileOut( __METHOD__ ); return $target; } } @@ -374,7 +364,6 @@ class MediaWiki { } } - wfProfileOut( __METHOD__ ); return $article; } @@ -385,7 +374,6 @@ class MediaWiki { * @param Title $requestTitle The original title, before any redirects were applied */ private function performAction( Page $page, Title $requestTitle ) { - wfProfileIn( __METHOD__ ); $request = $this->context->getRequest(); $output = $this->context->getOutput(); @@ -395,7 +383,6 @@ class MediaWiki { if ( !Hooks::run( 'MediaWikiPerformAction', array( $output, $page, $title, $user, $request, $this ) ) ) { - wfProfileOut( __METHOD__ ); return; } @@ -406,13 +393,16 @@ class MediaWiki { if ( $action instanceof Action ) { # Let Squid cache things if we can purge them. if ( $this->config->get( 'UseSquid' ) && - in_array( $request->getFullRequestURL(), $requestTitle->getSquidURLs() ) + in_array( + // Use PROTO_INTERNAL because that's what getSquidURLs() uses + wfExpandUrl( $request->getRequestURL(), PROTO_INTERNAL ), + $requestTitle->getSquidURLs() + ) ) { $output->setSquidMaxage( $this->config->get( 'SquidMaxage' ) ); } $action->show(); - wfProfileOut( __METHOD__ ); return; } @@ -421,7 +411,6 @@ class MediaWiki { $output->showErrorPage( 'nosuchaction', 'nosuchactiontext' ); } - wfProfileOut( __METHOD__ ); } /** @@ -456,7 +445,6 @@ class MediaWiki { * @return bool */ private function checkMaxLag() { - wfProfileIn( __METHOD__ ); $maxLag = $this->context->getRequest()->getVal( 'maxlag' ); if ( !is_null( $maxLag ) ) { list( $host, $lag ) = wfGetLB()->getMaxLag(); @@ -472,20 +460,15 @@ class MediaWiki { echo "Waiting for a database server: $lag seconds lagged\n"; } - wfProfileOut( __METHOD__ ); - exit; } } - wfProfileOut( __METHOD__ ); return true; } private function main() { global $wgTitle; - wfProfileIn( __METHOD__ ); - $request = $this->context->getRequest(); // Send Ajax requests to the Ajax dispatcher. @@ -497,7 +480,6 @@ class MediaWiki { $dispatcher = new AjaxDispatcher( $this->config ); $dispatcher->performAction( $this->context->getUser() ); - wfProfileOut( __METHOD__ ); return; } @@ -507,6 +489,17 @@ class MediaWiki { $action = $this->getAction(); $wgTitle = $title; + // Aside from rollback, master queries should not happen on GET requests. + // Periodic or "in passing" updates on GET should use the job queue. + if ( !$request->wasPosted() + && in_array( $action, array( 'view', 'edit', 'history' ) ) + ) { + $trxProfiler = Profiler::instance()->getTransactionProfiler(); + $trxProfiler->setExpectation( 'masterConns', 0, __METHOD__ ); + $trxProfiler->setExpectation( 'writes', 0, __METHOD__ ); + $trxProfiler->setExpectation( 'maxAffected', 500, __METHOD__ ); + } + // If the user has forceHTTPS set to true, or if the user // is in a group requiring HTTPS, or if they have the HTTPS // preference set, redirect them to HTTPS. @@ -550,13 +543,11 @@ class MediaWiki { $output->addVaryHeader( 'X-Forwarded-Proto' ); $output->redirect( $redirUrl ); $output->output(); - wfProfileOut( __METHOD__ ); return; } } if ( $this->config->get( 'UseFileCache' ) && $title->getNamespace() >= 0 ) { - wfProfileIn( 'main-try-filecache' ); if ( HTMLFileCache::useFileCache( $this->context ) ) { // Try low-level file cache hit $cache = new HTMLFileCache( $title, $action ); @@ -571,12 +562,9 @@ class MediaWiki { $this->context->getWikiPage()->doViewUpdates( $this->context->getUser() ); // Tell OutputPage that output is taken care of $this->context->getOutput()->disable(); - wfProfileOut( 'main-try-filecache' ); - wfProfileOut( __METHOD__ ); return; } } - wfProfileOut( 'main-try-filecache' ); } // Actually do the work of the request and build up any output @@ -592,13 +580,16 @@ class MediaWiki { // Output everything! $this->context->getOutput()->output(); - wfProfileOut( __METHOD__ ); } /** * Ends this task peacefully */ public function restInPeace() { + // Ignore things like master queries/connections on GET requests + // as long as they are in deferred updates (which catch errors). + Profiler::instance()->getTransactionProfiler()->resetExpectations(); + // Do any deferred jobs DeferredUpdates::doUpdates( 'commit' ); @@ -626,8 +617,6 @@ class MediaWiki { return; // recursion guard } - $section = new ProfileSection( __METHOD__ ); - if ( $jobRunRate < 1 ) { $max = mt_getrandmax(); if ( mt_rand( 0, $max ) > $max * $jobRunRate ) { @@ -638,9 +627,11 @@ class MediaWiki { $n = intval( $jobRunRate ); } + $runJobsLogger = MWLoggerFactory::getInstance( 'runJobs' ); + if ( !$this->config->get( 'RunJobsAsync' ) ) { // Fall back to running the job here while the user waits - $runner = new JobRunner(); + $runner = new JobRunner( $runJobsLogger ); $runner->run( array( 'maxJobs' => $n ) ); return; } @@ -673,9 +664,9 @@ class MediaWiki { ); wfRestoreWarnings(); if ( !$sock ) { - wfDebugLog( 'runJobs', "Failed to start cron API (socket error $errno): $errstr\n" ); + $runJobsLogger->error( "Failed to start cron API (socket error $errno): $errstr" ); // Fall back to running the job here while the user waits - $runner = new JobRunner(); + $runner = new JobRunner( $runJobsLogger ); $runner->run( array( 'maxJobs' => $n ) ); return; } @@ -683,19 +674,19 @@ class MediaWiki { $url = wfAppendQuery( wfScript( 'index' ), $query ); $req = "POST $url HTTP/1.1\r\nHost: {$info['host']}\r\nConnection: Close\r\nContent-Length: 0\r\n\r\n"; - wfDebugLog( 'runJobs', "Running $n job(s) via '$url'\n" ); + $runJobsLogger->info( "Running $n job(s) via '$url'" ); // Send a cron API request to be performed in the background. // Give up if this takes too long to send (which should be rare). stream_set_timeout( $sock, 1 ); $bytes = fwrite( $sock, $req ); if ( $bytes !== strlen( $req ) ) { - wfDebugLog( 'runJobs', "Failed to start cron API (socket write error)\n" ); + $runJobsLogger->error( "Failed to start cron API (socket write error)" ); } else { // Do not wait for the response (the script should handle client aborts). // Make sure that we don't close before that script reaches ignore_user_abort(). $status = fgets( $sock ); if ( !preg_match( '#^HTTP/\d\.\d 202 #', $status ) ) { - wfDebugLog( 'runJobs', "Failed to start cron API: received '$status'\n" ); + $runJobsLogger->error( "Failed to start cron API: received '$status'" ); } } fclose( $sock ); diff --git a/includes/Message.php b/includes/Message.php index 93a37cbb4d..49437f49ae 100644 --- a/includes/Message.php +++ b/includes/Message.php @@ -156,7 +156,7 @@ * * @since 1.17 */ -class Message { +class Message implements MessageSpecifier { /** * In which language to get this message. True, which is the default, @@ -276,7 +276,7 @@ class Message { * Returns the message key. * * If a list of multiple possible keys was supplied to the constructor, this method may - * return any of these keys. After the message ahs been fetched, this method will return + * return any of these keys. After the message has been fetched, this method will return * the key that was actually used to fetch the message. * * @since 1.21 diff --git a/includes/MessageBlobStore.php b/includes/MessageBlobStore.php index e3b4dbe8fe..6f7e8e5774 100644 --- a/includes/MessageBlobStore.php +++ b/includes/MessageBlobStore.php @@ -56,9 +56,7 @@ class MessageBlobStore { * @return array An array mapping module names to message blobs */ public function get( ResourceLoader $resourceLoader, $modules, $lang ) { - wfProfileIn( __METHOD__ ); if ( !count( $modules ) ) { - wfProfileOut( __METHOD__ ); return array(); } // Try getting from the DB first @@ -73,7 +71,6 @@ class MessageBlobStore { } } - wfProfileOut( __METHOD__ ); return $blobs; } diff --git a/includes/OutputPage.php b/includes/OutputPage.php index 07fa94bfa5..5c146e4d25 100644 --- a/includes/OutputPage.php +++ b/includes/OutputPage.php @@ -1685,8 +1685,6 @@ class OutputPage extends ContextSource { ) { global $wgParser; - wfProfileIn( __METHOD__ ); - $popts = $this->parserOptions(); $oldTidy = $popts->setTidy( $tidy ); $popts->setInterfaceMessage( (bool)$interface ); @@ -1700,7 +1698,6 @@ class OutputPage extends ContextSource { $this->addParserOutput( $parserOutput ); - wfProfileOut( __METHOD__ ); } /** @@ -2173,8 +2170,6 @@ class OutputPage extends ContextSource { return; } - wfProfileIn( __METHOD__ ); - $response = $this->getRequest()->response(); $config = $this->getConfig(); @@ -2209,7 +2204,6 @@ class OutputPage extends ContextSource { } } - wfProfileOut( __METHOD__ ); return; } elseif ( $this->mStatusCode ) { $message = HttpStatus::getMessage( $this->mStatusCode ); @@ -2264,9 +2258,7 @@ class OutputPage extends ContextSource { // adding of CSS or Javascript by extensions. Hooks::run( 'BeforePageDisplay', array( &$this, &$sk ) ); - wfProfileIn( 'Output-skin' ); $sk->outputPage(); - wfProfileOut( 'Output-skin' ); } // This hook allows last minute changes to final overall output by modifying output buffer @@ -2276,7 +2268,6 @@ class OutputPage extends ContextSource { ob_end_flush(); - wfProfileOut( __METHOD__ ); } /** @@ -2626,8 +2617,6 @@ class OutputPage extends ContextSource { public function headElement( Skin $sk, $includeStyle = true ) { global $wgContLang; - $section = new ProfileSection( __METHOD__ ); - $userdir = $this->getLanguage()->getDir(); $sitedir = $wgContLang->getDir(); @@ -2729,7 +2718,7 @@ class OutputPage extends ContextSource { * call rather than a " + - - + + ` - * tags. Because `@import` is blocking, Chrome won't add the stylesheet to document.styleSheets - * until the `@import` has finished, and Firefox won't allow .cssRules to be accessed until the - * `@import` has finished. And because the contents of the ``, then create `

` - * and wait for its font-family to change to someValue. Because `@import` is blocking, the - * font-family rule is not applied until after the `@import` finishes. - * - * All this stylesheet injection and polling magic is in #transplantStyles. - * - * @return {jQuery.Promise} Promise resolved when loading is complete - */ -OO.ui.Window.prototype.load = function () { - var sub, doc, loading, - win = this; - - this.$element.addClass( 'oo-ui-window-load' ); - - // Non-isolated windows are already "loaded" - if ( !this.loading && !this.isolated ) { - this.loading = $.Deferred().resolve(); - this.initialize(); - // Set initialized state after so sub-classes aren't confused by it being set by calling - // their parent initialize method - this.initialized = true; - } - - // Return existing promise if already loading or loaded - if ( this.loading ) { - return this.loading.promise(); - } - - // Load the frame - loading = this.loading = $.Deferred(); - sub = this.$iframe.prop( 'contentWindow' ); - doc = sub.document; - - // Initialize contents - doc.open(); - doc.write( - '' + - '' + - '' + - '
' + - '' + - '' - ); - doc.close(); - - // Properties - this.$ = OO.ui.Element.static.getJQuery( doc, this.$iframe ); - this.$content = this.$( '.oo-ui-window-content' ).attr( 'tabIndex', 0 ); - this.$document = this.$( doc ); - - // Initialization - this.constructor.static.transplantStyles( this.getElementDocument(), this.$document[0] ) - .always( function () { - // Initialize isolated windows - win.initialize(); - // Set initialized state after so sub-classes aren't confused by it being set by calling - // their parent initialize method - win.initialized = true; - // Undo the visibility: hidden; hack and apply display: none; - // We can do this safely now that the iframe has initialized - // (don't do this from within #initialize because it has to happen - // after the all subclasses have been handled as well). - win.toggle( win.isVisible() ); - - loading.resolve(); + var win = this; + + return this.getTeardownProcess( data ).execute() + .done( function () { + // Force redraw by asking the browser to measure the elements' widths + win.$element.removeClass( 'oo-ui-window-active oo-ui-window-setup' ).width(); + win.$content.removeClass( 'oo-ui-window-content-setup' ).width(); + win.toggle( false ); } ); - - return loading.promise(); }; /** - * Base class for all dialogs. - * - * Logic: - * - Manage the window (open and close, etc.). - * - Store the internal name and display title. - * - A stack to track one or more pending actions. - * - Manage a set of actions that can be performed. - * - Configure and create action widgets. - * - * User interface: - * - Close the dialog with Escape key. - * - Visually lock the dialog while an action is in - * progress (aka "pending"). - * - * Subclass responsibilities: - * - Display the title somewhere. - * - Add content to the dialog. - * - Provide a UI to close the dialog. - * - Display the action widgets somewhere. + * The Dialog class serves as the base class for the other types of dialogs. + * Unless extended to include controls, the rendered dialog box is a simple window + * that users can close by hitting the ‘Esc’ key. Dialog windows are used with OO.ui.WindowManager, + * which opens, closes, and controls the presentation of the window. See the + * [OOjs UI documentation on MediaWiki] [1] for more information. + * + * @example + * // A simple dialog window. + * function MyDialog( config ) { + * MyDialog.super.call( this, config ); + * } + * OO.inheritClass( MyDialog, OO.ui.Dialog ); + * MyDialog.prototype.initialize = function () { + * MyDialog.super.prototype.initialize.call( this ); + * this.content = new OO.ui.PanelLayout( { padded: true, expanded: false } ); + * this.content.$element.append( '

A simple dialog window. Press \'Esc\' to close.

' ); + * this.$body.append( this.content.$element ); + * }; + * MyDialog.prototype.getBodyHeight = function () { + * return this.content.$element.outerHeight( true ); + * }; + * var myDialog = new MyDialog( { + * size: 'medium' + * } ); + * // Create and append a window manager, which opens and closes the window. + * var windowManager = new OO.ui.WindowManager(); + * $( 'body' ).append( windowManager.$element ); + * windowManager.addWindows( [ myDialog ] ); + * // Open the window! + * windowManager.openWindow( myDialog ); + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Dialogs * * @abstract * @class @@ -2467,7 +2357,7 @@ OO.ui.Dialog.prototype.getSetupProcess = function ( data ) { ); for ( i = 0, len = actions.length; i < len; i++ ) { items.push( - new OO.ui.ActionWidget( $.extend( { $: this.$ }, actions[i] ) ) + new OO.ui.ActionWidget( actions[ i ] ) ); } this.actions.add( items ); @@ -2502,7 +2392,7 @@ OO.ui.Dialog.prototype.initialize = function () { OO.ui.Dialog.super.prototype.initialize.call( this ); // Properties - this.title = new OO.ui.LabelWidget( { $: this.$ } ); + this.title = new OO.ui.LabelWidget(); // Initialization this.$content.addClass( 'oo-ui-dialog-content' ); @@ -2527,7 +2417,7 @@ OO.ui.Dialog.prototype.detachActions = function () { // Detach all actions that may have been previously attached for ( i = 0, len = this.attachedActions.length; i < len; i++ ) { - this.attachedActions[i].$element.detach(); + this.attachedActions[ i ].$element.detach(); } this.attachedActions = []; }; @@ -2545,46 +2435,56 @@ OO.ui.Dialog.prototype.executeAction = function ( action ) { }; /** - * Collection of windows. + * Window managers are used to open and close {@link OO.ui.Window windows} and control their presentation. + * Managed windows are mutually exclusive. If a new window is opened while a current window is opening + * or is opened, the current window will be closed and any ongoing {@link OO.ui.Process process} will be cancelled. Windows + * themselves are persistent and—rather than being torn down when closed—can be repopulated with the + * pertinent data and reused. + * + * Over the lifecycle of a window, the window manager makes available three promises: `opening`, + * `opened`, and `closing`, which represent the primary stages of the cycle: + * + * **Opening**: the opening stage begins when the window manager’s #openWindow or a window’s + * {@link OO.ui.Window#open open} method is used, and the window manager begins to open the window. + * + * - an `opening` event is emitted with an `opening` promise + * - the #getSetupDelay method is called and the returned value is used to time a pause in execution before + * the window’s {@link OO.ui.Window#getSetupProcess getSetupProcess} method is called on the + * window and its result executed + * - a `setup` progress notification is emitted from the `opening` promise + * - the #getReadyDelay method is called the returned value is used to time a pause in execution before + * the window’s {@link OO.ui.Window#getReadyProcess getReadyProcess} method is called on the + * window and its result executed + * - a `ready` progress notification is emitted from the `opening` promise + * - the `opening` promise is resolved with an `opened` promise + * + * **Opened**: the window is now open. + * + * **Closing**: the closing stage begins when the window manager's #closeWindow or the + * window's {@link OO.ui.Window#close close} methods is used, and the window manager begins + * to close the window. + * + * - the `opened` promise is resolved with `closing` promise and a `closing` event is emitted + * - the #getHoldDelay method is called and the returned value is used to time a pause in execution before + * the window's {@link OO.ui.Window#getHoldProcess getHoldProces} method is called on the + * window and its result executed + * - a `hold` progress notification is emitted from the `closing` promise + * - the #getTeardownDelay() method is called and the returned value is used to time a pause in execution before + * the window's {@link OO.ui.Window#getTeardownProcess getTeardownProcess} method is called on the + * window and its result executed + * - a `teardown` progress notification is emitted from the `closing` promise + * - the `closing` promise is resolved. The window is now closed + * + * See the [OOjs UI documentation on MediaWiki][1] for more information. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Window_managers * * @class * @extends OO.ui.Element * @mixins OO.EventEmitter * - * Managed windows are mutually exclusive. If a window is opened while there is a current window - * already opening or opened, the current window will be closed without data. Empty closing data - * should always result in the window being closed without causing constructive or destructive - * action. - * - * As a window is opened and closed, it passes through several stages and the manager emits several - * corresponding events. - * - * - {@link #openWindow} or {@link OO.ui.Window#open} methods are used to start opening - * - {@link #event-opening} is emitted with `opening` promise - * - {@link #getSetupDelay} is called the returned value is used to time a pause in execution - * - {@link OO.ui.Window#getSetupProcess} method is called on the window and its result executed - * - `setup` progress notification is emitted from opening promise - * - {@link #getReadyDelay} is called the returned value is used to time a pause in execution - * - {@link OO.ui.Window#getReadyProcess} method is called on the window and its result executed - * - `ready` progress notification is emitted from opening promise - * - `opening` promise is resolved with `opened` promise - * - Window is now open - * - * - {@link #closeWindow} or {@link OO.ui.Window#close} methods are used to start closing - * - `opened` promise is resolved with `closing` promise - * - {@link #event-closing} is emitted with `closing` promise - * - {@link #getHoldDelay} is called the returned value is used to time a pause in execution - * - {@link OO.ui.Window#getHoldProcess} method is called on the window and its result executed - * - `hold` progress notification is emitted from opening promise - * - {@link #getTeardownDelay} is called the returned value is used to time a pause in execution - * - {@link OO.ui.Window#getTeardownProcess} method is called on the window and its result executed - * - `teardown` progress notification is emitted from opening promise - * - Closing promise is resolved - * - Window is now closed - * * @constructor * @param {Object} [config] Configuration options - * @cfg {boolean} [isolate] Configure managed windows to isolate their content using inline frames * @cfg {OO.Factory} [factory] Window factory to use for automatic instantiation * @cfg {boolean} [modal=true] Prevent interaction outside the dialog */ @@ -2601,22 +2501,17 @@ OO.ui.WindowManager = function OoUiWindowManager( config ) { // Properties this.factory = config.factory; this.modal = config.modal === undefined || !!config.modal; - this.isolate = !!config.isolate; this.windows = {}; this.opening = null; this.opened = null; this.closing = null; this.preparingToOpen = null; this.preparingToClose = null; - this.size = null; this.currentWindow = null; this.$ariaHidden = null; - this.requestedSize = null; this.onWindowResizeTimeout = null; this.onWindowResizeHandler = this.onWindowResize.bind( this ); this.afterWindowResizeHandler = this.afterWindowResize.bind( this ); - this.onWindowMouseWheelHandler = this.onWindowMouseWheel.bind( this ); - this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this ); // Initialization this.$element @@ -2684,6 +2579,9 @@ OO.ui.WindowManager.static.sizes = { large: { width: 700 }, + larger: { + width: 900 + }, full: { // These can be non-numeric because they are never used in calculations width: '100%', @@ -2725,36 +2623,6 @@ OO.ui.WindowManager.prototype.afterWindowResize = function () { } }; -/** - * Handle window mouse wheel events. - * - * @param {jQuery.Event} e Mouse wheel event - */ -OO.ui.WindowManager.prototype.onWindowMouseWheel = function () { - // Kill all events in the parent window if the child window is isolated - return !this.shouldIsolate(); -}; - -/** - * Handle document key down events. - * - * @param {jQuery.Event} e Key down event - */ -OO.ui.WindowManager.prototype.onDocumentKeyDown = function ( e ) { - switch ( e.which ) { - case OO.ui.Keys.PAGEUP: - case OO.ui.Keys.PAGEDOWN: - case OO.ui.Keys.END: - case OO.ui.Keys.HOME: - case OO.ui.Keys.LEFT: - case OO.ui.Keys.UP: - case OO.ui.Keys.RIGHT: - case OO.ui.Keys.DOWN: - // Kill all events in the parent window if the child window is isolated - return !this.shouldIsolate(); - } -}; - /** * Check if window is opening. * @@ -2782,17 +2650,6 @@ OO.ui.WindowManager.prototype.isOpened = function ( win ) { return win === this.currentWindow && !!this.opened && this.opened.state() === 'pending'; }; -/** - * Check if window contents should be isolated. - * - * Window content isolation is done using inline frames. - * - * @return {boolean} Window contents should be isolated - */ -OO.ui.WindowManager.prototype.shouldIsolate = function () { - return this.isolate; -}; - /** * Check if a window is being managed. * @@ -2803,7 +2660,7 @@ OO.ui.WindowManager.prototype.hasWindow = function ( win ) { var name; for ( name in this.windows ) { - if ( this.windows[name] === win ) { + if ( this.windows[ name ] === win ) { return true; } } @@ -2867,7 +2724,7 @@ OO.ui.WindowManager.prototype.getTeardownDelay = function () { */ OO.ui.WindowManager.prototype.getWindow = function ( name ) { var deferred = $.Deferred(), - win = this.windows[name]; + win = this.windows[ name ]; if ( !( win instanceof OO.ui.Window ) ) { if ( this.factory ) { @@ -2876,7 +2733,7 @@ OO.ui.WindowManager.prototype.getWindow = function ( name ) { 'Cannot auto-instantiate window: symbolic name is unrecognized by the factory' ) ); } else { - win = this.factory.create( name, this, { $: this.$ } ); + win = this.factory.create( name, this ); this.addWindows( [ win ] ); deferred.resolve( win ); } @@ -2912,7 +2769,6 @@ OO.ui.WindowManager.prototype.getCurrentWindow = function () { */ OO.ui.WindowManager.prototype.openWindow = function ( win, data ) { var manager = this, - preparing = [], opening = $.Deferred(); // Argument handling @@ -2935,17 +2791,8 @@ OO.ui.WindowManager.prototype.openWindow = function ( win, data ) { // Window opening if ( opening.state() !== 'rejected' ) { - if ( !win.getManager() ) { - win.setManager( this ); - } - preparing.push( win.load() ); - - if ( this.closing ) { - // If a window is currently closing, wait for it to complete - preparing.push( this.closing ); - } - - this.preparingToOpen = $.when.apply( $, preparing ); + // If a window is currently closing, wait for it to complete + this.preparingToOpen = $.when( this.closing ); // Ensure handlers get called after preparingToOpen is set this.preparingToOpen.done( function () { if ( manager.modal ) { @@ -2988,13 +2835,12 @@ OO.ui.WindowManager.prototype.openWindow = function ( win, data ) { */ OO.ui.WindowManager.prototype.closeWindow = function ( win, data ) { var manager = this, - preparing = [], closing = $.Deferred(), opened; // Argument handling if ( typeof win === 'string' ) { - win = this.windows[win]; + win = this.windows[ win ]; } else if ( !this.hasWindow( win ) ) { win = null; } @@ -3016,12 +2862,8 @@ OO.ui.WindowManager.prototype.closeWindow = function ( win, data ) { // Window closing if ( closing.state() !== 'rejected' ) { - if ( this.opening ) { - // If the window is currently opening, close it when it's done - preparing.push( this.opening ); - } - - this.preparingToClose = $.when.apply( $, preparing ); + // If the window is currently opening, close it when it's done + this.preparingToClose = $.when( this.opening ); // Ensure handlers get called after preparingToClose is set this.preparingToClose.done( function () { manager.closing = closing; @@ -3063,15 +2905,15 @@ OO.ui.WindowManager.prototype.closeWindow = function ( win, data ) { OO.ui.WindowManager.prototype.addWindows = function ( windows ) { var i, len, win, name, list; - if ( $.isArray( windows ) ) { + if ( Array.isArray( windows ) ) { // Convert to map of windows by looking up symbolic names from static configuration list = {}; for ( i = 0, len = windows.length; i < len; i++ ) { - name = windows[i].constructor.static.name; + name = windows[ i ].constructor.static.name; if ( typeof name !== 'string' ) { throw new Error( 'Cannot add window' ); } - list[name] = windows[i]; + list[ name ] = windows[ i ]; } } else if ( $.isPlainObject( windows ) ) { list = windows; @@ -3079,9 +2921,10 @@ OO.ui.WindowManager.prototype.addWindows = function ( windows ) { // Add windows for ( name in list ) { - win = list[name]; - this.windows[name] = win; + win = list[ name ]; + this.windows[ name ] = win.toggle( false ); this.$element.append( win.$element ); + win.setManager( this ); } }; @@ -3090,7 +2933,7 @@ OO.ui.WindowManager.prototype.addWindows = function ( windows ) { * * Windows will be closed before they are removed. * - * @param {string} name Symbolic name of window to remove + * @param {string[]} names Symbolic names of windows to remove * @return {jQuery.Promise} Promise resolved when window is closed and removed * @throws {Error} If windows being removed are not being managed */ @@ -3099,13 +2942,13 @@ OO.ui.WindowManager.prototype.removeWindows = function ( names ) { manager = this, promises = [], cleanup = function ( name, win ) { - delete manager.windows[name]; + delete manager.windows[ name ]; win.$element.detach(); }; for ( i = 0, len = names.length; i < len; i++ ) { - name = names[i]; - win = this.windows[name]; + name = names[ i ]; + win = this.windows[ name ]; if ( !win ) { throw new Error( 'Cannot remove window' ); } @@ -3144,16 +2987,16 @@ OO.ui.WindowManager.prototype.updateWindowSize = function ( win ) { sizes = this.constructor.static.sizes, size = win.getSize(); - if ( !sizes[size] ) { + if ( !sizes[ size ] ) { size = this.constructor.static.defaultSize; } - if ( size !== 'full' && viewport.rect.right - viewport.rect.left < sizes[size].width ) { + if ( size !== 'full' && viewport.rect.right - viewport.rect.left < sizes[ size ].width ) { size = 'full'; } this.$element.toggleClass( 'oo-ui-windowManager-fullscreen', size === 'full' ); this.$element.toggleClass( 'oo-ui-windowManager-floating', size !== 'full' ); - win.setDimensions( sizes[size] ); + win.setDimensions( sizes[ size ] ); this.emit( 'resize', win ); @@ -3171,37 +3014,19 @@ OO.ui.WindowManager.prototype.toggleGlobalEvents = function ( on ) { if ( on ) { if ( !this.globalEvents ) { - this.$( this.getElementDocument() ).on( { - // Prevent scrolling by keys in top-level window - keydown: this.onDocumentKeyDownHandler - } ); - this.$( this.getElementWindow() ).on( { - // Prevent scrolling by wheel in top-level window - mousewheel: this.onWindowMouseWheelHandler, + $( this.getElementWindow() ).on( { // Start listening for top-level window dimension changes 'orientationchange resize': this.onWindowResizeHandler } ); - // Disable window scrolling in isolated windows - if ( !this.shouldIsolate() ) { - $( this.getElementDocument().body ).css( 'overflow', 'hidden' ); - } + $( this.getElementDocument().body ).css( 'overflow', 'hidden' ); this.globalEvents = true; } } else if ( this.globalEvents ) { - // Unbind global events - this.$( this.getElementDocument() ).off( { - // Allow scrolling by keys in top-level window - keydown: this.onDocumentKeyDownHandler - } ); - this.$( this.getElementWindow() ).off( { - // Allow scrolling by wheel in top-level window - mousewheel: this.onWindowMouseWheelHandler, + $( this.getElementWindow() ).off( { // Stop listening for top-level window dimension changes 'orientationchange resize': this.onWindowResizeHandler } ); - if ( !this.shouldIsolate() ) { - $( this.getElementDocument().body ).css( 'overflow', '' ); - } + $( this.getElementDocument().body ).css( 'overflow', '' ); this.globalEvents = false; } @@ -3236,17 +3061,15 @@ OO.ui.WindowManager.prototype.toggleAriaIsolation = function ( isolate ) { /** * Destroy window manager. - * - * Windows will not be closed, only removed from the DOM. */ OO.ui.WindowManager.prototype.destroy = function () { this.toggleGlobalEvents( false ); this.toggleAriaIsolation( false ); + this.clearWindows(); this.$element.remove(); }; /** - * @abstract * @class * * @constructor @@ -3381,7 +3204,7 @@ OO.ui.Process.prototype.execute = function () { // Use rejected promise for error return $.Deferred().reject( [ result ] ).promise(); } - if ( $.isArray( result ) && result.length && result[0] instanceof OO.ui.Error ) { + if ( Array.isArray( result ) && result.length && result[ 0 ] instanceof OO.ui.Error ) { // Use rejected promise for list of errors return $.Deferred().reject( result ).promise(); } @@ -3397,9 +3220,9 @@ OO.ui.Process.prototype.execute = function () { if ( this.steps.length ) { // Generate a chain reaction of promises - promise = proceed( this.steps[0] )(); + promise = proceed( this.steps[ 0 ] )(); for ( i = 1, len = this.steps.length; i < len; i++ ) { - promise = promise.then( proceed( this.steps[i] ) ); + promise = promise.then( proceed( this.steps[ i ] ) ); } } else { promise = $.Deferred().resolve().promise(); @@ -3509,8 +3332,8 @@ OO.ui.ToolFactory.prototype.getTools = function ( include, exclude, promote, dem // Auto for ( i = 0, len = included.length; i < len; i++ ) { - if ( !used[included[i]] ) { - auto.push( included[i] ); + if ( !used[ included[ i ] ] ) { + auto.push( included[ i ] ); } } @@ -3538,22 +3361,22 @@ OO.ui.ToolFactory.prototype.extract = function ( collection, used ) { if ( collection === '*' ) { for ( name in this.registry ) { - tool = this.registry[name]; + tool = this.registry[ name ]; if ( // Only add tools by group name when auto-add is enabled tool.static.autoAddToCatchall && // Exclude already used tools - ( !used || !used[name] ) + ( !used || !used[ name ] ) ) { names.push( name ); if ( used ) { - used[name] = true; + used[ name ] = true; } } } - } else if ( $.isArray( collection ) ) { + } else if ( Array.isArray( collection ) ) { for ( i = 0, len = collection.length; i < len; i++ ) { - item = collection[i]; + item = collection[ i ]; // Allow plain strings as shorthand for named tools if ( typeof item === 'string' ) { item = { name: item }; @@ -3561,26 +3384,26 @@ OO.ui.ToolFactory.prototype.extract = function ( collection, used ) { if ( OO.isPlainObject( item ) ) { if ( item.group ) { for ( name in this.registry ) { - tool = this.registry[name]; + tool = this.registry[ name ]; if ( // Include tools with matching group tool.static.group === item.group && // Only add tools by group name when auto-add is enabled tool.static.autoAddToGroup && // Exclude already used tools - ( !used || !used[name] ) + ( !used || !used[ name ] ) ) { names.push( name ); if ( used ) { - used[name] = true; + used[ name ] = true; } } } // Include tools with matching name and exclude already used tools - } else if ( item.name && ( !used || !used[item.name] ) ) { + } else if ( item.name && ( !used || !used[ item.name ] ) ) { names.push( item.name ); if ( used ) { - used[item.name] = true; + used[ item.name ] = true; } } } @@ -3605,7 +3428,7 @@ OO.ui.ToolGroupFactory = function OoUiToolGroupFactory() { // Register default toolgroups for ( i = 0, l = defaultClasses.length; i < l; i++ ) { - this.register( defaultClasses[i] ); + this.register( defaultClasses[ i ] ); } }; @@ -3678,95 +3501,219 @@ OO.ui.Theme.prototype.updateElementClasses = function ( element ) { }; /** - * Element with a button. - * - * Buttons are used for controls which can be clicked. They can be configured to use tab indexing - * and access keys for accessibility purposes. + * Element supporting "sequential focus navigation" using the 'tabindex' attribute. * * @abstract * @class * * @constructor * @param {Object} [config] Configuration options - * @cfg {jQuery} [$button] Button node, assigned to #$button, omit to use a generated `` - * @cfg {boolean} [framed=true] Render button with a frame - * @cfg {number} [tabIndex=0] Button's tab index. Use 0 to use default ordering, use -1 to prevent - * tab focusing. - * @cfg {string} [accessKey] Button's access key + * @cfg {jQuery} [$tabIndexed] tabIndexed node, assigned to #$tabIndexed, omit to use #$element + * @cfg {number|null} [tabIndex=0] Tab index value. Use 0 to use default ordering, use -1 to + * prevent tab focusing, use null to suppress the `tabindex` attribute. */ -OO.ui.ButtonElement = function OoUiButtonElement( config ) { +OO.ui.TabIndexedElement = function OoUiTabIndexedElement( config ) { // Configuration initialization - config = config || {}; + config = $.extend( { tabIndex: 0 }, config ); // Properties - this.$button = null; - this.framed = null; + this.$tabIndexed = null; this.tabIndex = null; - this.accessKey = null; - this.active = false; - this.onMouseUpHandler = this.onMouseUp.bind( this ); - this.onMouseDownHandler = this.onMouseDown.bind( this ); + + // Events + this.connect( this, { disable: 'onDisable' } ); // Initialization - this.$element.addClass( 'oo-ui-buttonElement' ); - this.toggleFramed( config.framed === undefined || config.framed ); - this.setTabIndex( config.tabIndex || 0 ); - this.setAccessKey( config.accessKey ); - this.setButtonElement( config.$button || this.$( '' ) ); + this.setTabIndex( config.tabIndex ); + this.setTabIndexedElement( config.$tabIndexed || this.$element ); }; /* Setup */ -OO.initClass( OO.ui.ButtonElement ); +OO.initClass( OO.ui.TabIndexedElement ); -/* Static Properties */ +/* Methods */ /** - * Cancel mouse down events. + * Set the element with `tabindex` attribute. * - * @static - * @inheritable - * @property {boolean} + * If an element is already set, it will be cleaned up before setting up the new element. + * + * @param {jQuery} $tabIndexed Element to set tab index on + * @chainable */ -OO.ui.ButtonElement.static.cancelButtonMouseDownEvents = true; - -/* Methods */ +OO.ui.TabIndexedElement.prototype.setTabIndexedElement = function ( $tabIndexed ) { + var tabIndex = this.tabIndex; + // Remove attributes from old $tabIndexed + this.setTabIndex( null ); + // Force update of new $tabIndexed + this.$tabIndexed = $tabIndexed; + this.tabIndex = tabIndex; + return this.updateTabIndex(); +}; /** - * Set the button element. - * - * If an element is already set, it will be cleaned up before setting up the new element. + * Set tab index value. * - * @param {jQuery} $button Element to use as button + * @param {number|null} tabIndex Tab index value or null for no tab index + * @chainable */ -OO.ui.ButtonElement.prototype.setButtonElement = function ( $button ) { - if ( this.$button ) { - this.$button - .removeClass( 'oo-ui-buttonElement-button' ) - .removeAttr( 'role accesskey tabindex' ) - .off( 'mousedown', this.onMouseDownHandler ); +OO.ui.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) { + tabIndex = typeof tabIndex === 'number' ? tabIndex : null; + + if ( this.tabIndex !== tabIndex ) { + this.tabIndex = tabIndex; + this.updateTabIndex(); } - this.$button = $button - .addClass( 'oo-ui-buttonElement-button' ) - .attr( { role: 'button', accesskey: this.accessKey, tabindex: this.tabIndex } ) - .on( 'mousedown', this.onMouseDownHandler ); + return this; }; /** - * Handles mouse down events. + * Update the `tabindex` attribute, in case of changes to tab index or + * disabled state. * - * @param {jQuery.Event} e Mouse down event + * @chainable */ -OO.ui.ButtonElement.prototype.onMouseDown = function ( e ) { - if ( this.isDisabled() || e.which !== 1 ) { - return false; +OO.ui.TabIndexedElement.prototype.updateTabIndex = function () { + if ( this.$tabIndexed ) { + if ( this.tabIndex !== null ) { + // Do not index over disabled elements + this.$tabIndexed.attr( { + tabindex: this.isDisabled() ? -1 : this.tabIndex, + // ChromeVox and NVDA do not seem to inherit this from parent elements + 'aria-disabled': this.isDisabled().toString() + } ); + } else { + this.$tabIndexed.removeAttr( 'tabindex aria-disabled' ); + } } - // Remove the tab-index while the button is down to prevent the button from stealing focus - this.$button.removeAttr( 'tabindex' ); - this.$element.addClass( 'oo-ui-buttonElement-pressed' ); - // Run the mouseup handler no matter where the mouse is when the button is let go, so we can - // reliably reapply the tabindex and remove the pressed class + return this; +}; + +/** + * Handle disable events. + * + * @param {boolean} disabled Element is disabled + */ +OO.ui.TabIndexedElement.prototype.onDisable = function () { + this.updateTabIndex(); +}; + +/** + * Get tab index value. + * + * @return {number|null} Tab index value + */ +OO.ui.TabIndexedElement.prototype.getTabIndex = function () { + return this.tabIndex; +}; + +/** + * ButtonElement is often mixed into other classes to generate a button, which is a clickable + * interface element that can be configured with access keys for accessibility. + * See the [OOjs UI documentation on MediaWiki] [1] for examples. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches#Buttons + * @abstract + * @class + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {jQuery} [$button] Button node, assigned to #$button, omit to use a generated `` + * @cfg {boolean} [framed=true] Render button with a frame + * @cfg {string} [accessKey] Button's access key + */ +OO.ui.ButtonElement = function OoUiButtonElement( config ) { + // Configuration initialization + config = config || {}; + + // Properties + this.$button = config.$button || $( '' ); + this.framed = null; + this.accessKey = null; + this.active = false; + this.onMouseUpHandler = this.onMouseUp.bind( this ); + this.onMouseDownHandler = this.onMouseDown.bind( this ); + this.onKeyDownHandler = this.onKeyDown.bind( this ); + this.onKeyUpHandler = this.onKeyUp.bind( this ); + this.onClickHandler = this.onClick.bind( this ); + this.onKeyPressHandler = this.onKeyPress.bind( this ); + + // Initialization + this.$element.addClass( 'oo-ui-buttonElement' ); + this.toggleFramed( config.framed === undefined || config.framed ); + this.setAccessKey( config.accessKey ); + this.setButtonElement( this.$button ); +}; + +/* Setup */ + +OO.initClass( OO.ui.ButtonElement ); + +/* Static Properties */ + +/** + * Cancel mouse down events. + * + * @static + * @inheritable + * @property {boolean} + */ +OO.ui.ButtonElement.static.cancelButtonMouseDownEvents = true; + +/* Events */ + +/** + * @event click + */ + +/* Methods */ + +/** + * Set the button element. + * + * If an element is already set, it will be cleaned up before setting up the new element. + * + * @param {jQuery} $button Element to use as button + */ +OO.ui.ButtonElement.prototype.setButtonElement = function ( $button ) { + if ( this.$button ) { + this.$button + .removeClass( 'oo-ui-buttonElement-button' ) + .removeAttr( 'role accesskey' ) + .off( { + mousedown: this.onMouseDownHandler, + keydown: this.onKeyDownHandler, + click: this.onClickHandler, + keypress: this.onKeyPressHandler + } ); + } + + this.$button = $button + .addClass( 'oo-ui-buttonElement-button' ) + .attr( { role: 'button', accesskey: this.accessKey } ) + .on( { + mousedown: this.onMouseDownHandler, + keydown: this.onKeyDownHandler, + click: this.onClickHandler, + keypress: this.onKeyPressHandler + } ); +}; + +/** + * Handles mouse down events. + * + * @protected + * @param {jQuery.Event} e Mouse down event + */ +OO.ui.ButtonElement.prototype.onMouseDown = function ( e ) { + if ( this.isDisabled() || e.which !== 1 ) { + return; + } + this.$element.addClass( 'oo-ui-buttonElement-pressed' ); + // Run the mouseup handler no matter where the mouse is when the button is let go, so we can + // reliably remove the pressed class this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true ); // Prevent change of focus unless specifically configured otherwise if ( this.constructor.static.cancelButtonMouseDownEvents ) { @@ -3777,19 +3724,77 @@ OO.ui.ButtonElement.prototype.onMouseDown = function ( e ) { /** * Handles mouse up events. * + * @protected * @param {jQuery.Event} e Mouse up event */ OO.ui.ButtonElement.prototype.onMouseUp = function ( e ) { if ( this.isDisabled() || e.which !== 1 ) { - return false; + return; } - // Restore the tab-index after the button is up to restore the button's accessibility - this.$button.attr( 'tabindex', this.tabIndex ); this.$element.removeClass( 'oo-ui-buttonElement-pressed' ); // Stop listening for mouseup, since we only needed this once this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true ); }; +/** + * Handles mouse click events. + * + * @protected + * @param {jQuery.Event} e Mouse click event + * @fires click + */ +OO.ui.ButtonElement.prototype.onClick = function ( e ) { + if ( !this.isDisabled() && e.which === 1 ) { + this.emit( 'click' ); + } + return false; +}; + +/** + * Handles key down events. + * + * @protected + * @param {jQuery.Event} e Key down event + */ +OO.ui.ButtonElement.prototype.onKeyDown = function ( e ) { + if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) { + return; + } + this.$element.addClass( 'oo-ui-buttonElement-pressed' ); + // Run the keyup handler no matter where the key is when the button is let go, so we can + // reliably remove the pressed class + this.getElementDocument().addEventListener( 'keyup', this.onKeyUpHandler, true ); +}; + +/** + * Handles key up events. + * + * @protected + * @param {jQuery.Event} e Key up event + */ +OO.ui.ButtonElement.prototype.onKeyUp = function ( e ) { + if ( this.isDisabled() || ( e.which !== OO.ui.Keys.SPACE && e.which !== OO.ui.Keys.ENTER ) ) { + return; + } + this.$element.removeClass( 'oo-ui-buttonElement-pressed' ); + // Stop listening for keyup, since we only needed this once + this.getElementDocument().removeEventListener( 'keyup', this.onKeyUpHandler, true ); +}; + +/** + * Handles key press events. + * + * @protected + * @param {jQuery.Event} e Key press event + * @fires click + */ +OO.ui.ButtonElement.prototype.onKeyPress = function ( e ) { + if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) { + this.emit( 'click' ); + } + return false; +}; + /** * Check if button has a frame. * @@ -3818,29 +3823,6 @@ OO.ui.ButtonElement.prototype.toggleFramed = function ( framed ) { return this; }; -/** - * Set tab index. - * - * @param {number|null} tabIndex Button's tab index, use null to remove - * @chainable - */ -OO.ui.ButtonElement.prototype.setTabIndex = function ( tabIndex ) { - tabIndex = typeof tabIndex === 'number' && tabIndex >= 0 ? tabIndex : null; - - if ( this.tabIndex !== tabIndex ) { - if ( this.$button ) { - if ( tabIndex !== null ) { - this.$button.attr( 'tabindex', tabIndex ); - } else { - this.$button.removeAttr( 'tabindex' ); - } - } - this.tabIndex = tabIndex; - } - - return this; -}; - /** * Set access key. * @@ -3876,7 +3858,12 @@ OO.ui.ButtonElement.prototype.setActive = function ( value ) { }; /** - * Element containing a sequence of child elements. + * Any OOjs UI widget that contains other widgets (such as {@link OO.ui.ButtonWidget buttons} or + * {@link OO.ui.OptionWidget options}) mixes in GroupElement. Adding, removing, and clearing + * items from the group is done through the interface the class provides. + * For more information, please see the [OOjs UI documentation on MediaWiki] [1]. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Groups * * @abstract * @class @@ -3895,7 +3882,7 @@ OO.ui.GroupElement = function OoUiGroupElement( config ) { this.aggregateItemEvents = {}; // Initialization - this.setGroupElement( config.$group || this.$( '
' ) ); + this.setGroupElement( config.$group || $( '
' ) ); }; /* Methods */ @@ -3912,7 +3899,7 @@ OO.ui.GroupElement.prototype.setGroupElement = function ( $group ) { this.$group = $group; for ( i = 0, len = this.items.length; i < len; i++ ) { - this.$group.append( this.items[i].$element ); + this.$group.append( this.items[ i ].$element ); } }; @@ -3947,7 +3934,7 @@ OO.ui.GroupElement.prototype.getItemFromData = function ( data ) { hash = OO.getHash( data ); for ( i = 0, len = this.items.length; i < len; i++ ) { - item = this.items[i]; + item = this.items[ i ]; if ( hash === OO.getHash( item.getData() ) ) { return item; } @@ -3970,7 +3957,7 @@ OO.ui.GroupElement.prototype.getItemsFromData = function ( data ) { items = []; for ( i = 0, len = this.items.length; i < len; i++ ) { - item = this.items[i]; + item = this.items[ i ]; if ( hash === OO.getHash( item.getData() ) ) { items.push( item ); } @@ -3994,7 +3981,7 @@ OO.ui.GroupElement.prototype.aggregate = function ( events ) { var i, len, item, add, remove, itemEvent, groupEvent; for ( itemEvent in events ) { - groupEvent = events[itemEvent]; + groupEvent = events[ itemEvent ]; // Remove existing aggregated event if ( Object.prototype.hasOwnProperty.call( this.aggregateItemEvents, itemEvent ) ) { @@ -4004,27 +3991,27 @@ OO.ui.GroupElement.prototype.aggregate = function ( events ) { } // Remove event aggregation from existing items for ( i = 0, len = this.items.length; i < len; i++ ) { - item = this.items[i]; + item = this.items[ i ]; if ( item.connect && item.disconnect ) { remove = {}; - remove[itemEvent] = [ 'emit', groupEvent, item ]; + remove[ itemEvent ] = [ 'emit', groupEvent, item ]; item.disconnect( this, remove ); } } // Prevent future items from aggregating event - delete this.aggregateItemEvents[itemEvent]; + delete this.aggregateItemEvents[ itemEvent ]; } // Add new aggregate event if ( groupEvent ) { // Make future items aggregate event - this.aggregateItemEvents[itemEvent] = groupEvent; + this.aggregateItemEvents[ itemEvent ] = groupEvent; // Add event aggregation to existing items for ( i = 0, len = this.items.length; i < len; i++ ) { - item = this.items[i]; + item = this.items[ i ]; if ( item.connect && item.disconnect ) { add = {}; - add[itemEvent] = [ 'emit', groupEvent, item ]; + add[ itemEvent ] = [ 'emit', groupEvent, item ]; item.connect( this, add ); } } @@ -4046,7 +4033,7 @@ OO.ui.GroupElement.prototype.addItems = function ( items, index ) { itemElements = []; for ( i = 0, len = items.length; i < len; i++ ) { - item = items[i]; + item = items[ i ]; // Check if item exists then remove it first, effectively "moving" it currentIndex = $.inArray( item, this.items ); @@ -4061,7 +4048,7 @@ OO.ui.GroupElement.prototype.addItems = function ( items, index ) { if ( item.connect && item.disconnect && !$.isEmptyObject( this.aggregateItemEvents ) ) { events = {}; for ( event in this.aggregateItemEvents ) { - events[event] = [ 'emit', this.aggregateItemEvents[event], item ]; + events[ event ] = [ 'emit', this.aggregateItemEvents[ event ], item ]; } item.connect( this, events ); } @@ -4076,7 +4063,7 @@ OO.ui.GroupElement.prototype.addItems = function ( items, index ) { this.$group.prepend( itemElements ); this.items.unshift.apply( this.items, items ); } else { - this.items[index].$element.before( itemElements ); + this.items[ index ].$element.before( itemElements ); this.items.splice.apply( this.items, [ index, 0 ].concat( items ) ); } @@ -4096,7 +4083,7 @@ OO.ui.GroupElement.prototype.removeItems = function ( items ) { // Remove specific items for ( i = 0, len = items.length; i < len; i++ ) { - item = items[i]; + item = items[ i ]; index = $.inArray( item, this.items ); if ( index !== -1 ) { if ( @@ -4105,7 +4092,7 @@ OO.ui.GroupElement.prototype.removeItems = function ( items ) { ) { remove = {}; if ( Object.prototype.hasOwnProperty.call( this.aggregateItemEvents, itemEvent ) ) { - remove[itemEvent] = [ 'emit', this.aggregateItemEvents[itemEvent], item ]; + remove[ itemEvent ] = [ 'emit', this.aggregateItemEvents[ itemEvent ], item ]; } item.disconnect( this, remove ); } @@ -4130,14 +4117,14 @@ OO.ui.GroupElement.prototype.clearItems = function () { // Remove all items for ( i = 0, len = this.items.length; i < len; i++ ) { - item = this.items[i]; + item = this.items[ i ]; if ( item.connect && item.disconnect && !$.isEmptyObject( this.aggregateItemEvents ) ) { remove = {}; if ( Object.prototype.hasOwnProperty.call( this.aggregateItemEvents, itemEvent ) ) { - remove[itemEvent] = [ 'emit', this.aggregateItemEvents[itemEvent], item ]; + remove[ itemEvent ] = [ 'emit', this.aggregateItemEvents[ itemEvent ], item ]; } item.disconnect( this, remove ); } @@ -4150,8 +4137,10 @@ OO.ui.GroupElement.prototype.clearItems = function () { }; /** - * A mixin for an element that can be dragged and dropped. - * Use in conjunction with DragGroupWidget + * DraggableElement is a mixin class used to create elements that can be clicked + * and dragged by a mouse to a new position within a group. This class must be used + * in conjunction with OO.ui.DraggableGroupElement, which provides a container for + * the draggable elements. * * @abstract * @class @@ -4174,6 +4163,8 @@ OO.ui.DraggableElement = function OoUiDraggableElement() { } ); }; +OO.initClass( OO.ui.DraggableElement ); + /* Events */ /** @@ -4189,6 +4180,13 @@ OO.ui.DraggableElement = function OoUiDraggableElement() { * @event drop */ +/* Static Properties */ + +/** + * @inheritdoc OO.ui.ButtonElement + */ +OO.ui.DraggableElement.static.cancelButtonMouseDownEvents = false; + /* Methods */ /** @@ -4264,8 +4262,9 @@ OO.ui.DraggableElement.prototype.getIndex = function () { }; /** - * Element containing a sequence of child elements that can be dragged - * and dropped. + * DraggableGroupElement is a mixin class used to create a group element to + * contain draggable elements, which are items that can be clicked and dragged by a mouse. + * The class is used with OO.ui.DraggableElement. * * @abstract * @class @@ -4306,7 +4305,7 @@ OO.ui.DraggableGroupElement = function OoUiDraggableGroupElement( config ) { } ); // Initialize - if ( $.isArray( config.items ) ) { + if ( Array.isArray( config.items ) ) { this.addItems( config.items ); } this.$placeholder = $( '
' ) @@ -4340,7 +4339,7 @@ OO.ui.DraggableGroupElement.prototype.onItemDragStart = function ( item ) { // Map the index of each object for ( i = 0, len = this.items.length; i < len; i++ ) { - this.items[i].setIndex( i ); + this.items[ i ].setIndex( i ); } if ( this.orientation === 'horizontal' ) { @@ -4386,6 +4385,7 @@ OO.ui.DraggableGroupElement.prototype.onItemDrop = function ( item ) { // Emit change event this.emit( 'reorder', this.getDragItem(), toIndex ); } + this.unsetDragItem(); // Return false to prevent propogation return false; }; @@ -4397,7 +4397,7 @@ OO.ui.DraggableGroupElement.prototype.onDragLeave = function () { // This means the item was dragged outside the widget this.$placeholder .css( 'left', 0 ) - .hide(); + .addClass( 'oo-ui-element-hidden' ); }; /** @@ -4413,9 +4413,9 @@ OO.ui.DraggableGroupElement.prototype.onDragOver = function ( e ) { // Get the OptionWidget item we are dragging over dragOverObj = this.getElementDocument().elementFromPoint( clientX, clientY ); $optionWidget = $( dragOverObj ).closest( '.oo-ui-draggableElement' ); - if ( $optionWidget[0] ) { + if ( $optionWidget[ 0 ] ) { itemOffset = $optionWidget.offset(); - itemBoundingRect = $optionWidget[0].getBoundingClientRect(); + itemBoundingRect = $optionWidget[ 0 ].getBoundingClientRect(); itemPosition = $optionWidget.position(); itemIndex = $optionWidget.data( 'index' ); } @@ -4458,23 +4458,14 @@ OO.ui.DraggableGroupElement.prototype.onDragOver = function ( e ) { this.sideInsertion = dragPosition < itemMidpoint ? 'before' : 'after'; } // Add drop indicator between objects - if ( this.sideInsertion ) { - this.$placeholder - .css( cssOutput ) - .show(); - } else { - this.$placeholder - .css( { - left: 0, - top: 0 - } ) - .hide(); - } + this.$placeholder + .css( cssOutput ) + .removeClass( 'oo-ui-element-hidden' ); } else { // This means the item was dragged outside the widget this.$placeholder .css( 'left', 0 ) - .hide(); + .addClass( 'oo-ui-element-hidden' ); } // Prevent default e.preventDefault(); @@ -4494,7 +4485,7 @@ OO.ui.DraggableGroupElement.prototype.setDragItem = function ( item ) { OO.ui.DraggableGroupElement.prototype.unsetDragItem = function () { this.dragItem = null; this.itemDragOver = null; - this.$placeholder.hide(); + this.$placeholder.addClass( 'oo-ui-element-hidden' ); this.sideInsertion = ''; }; @@ -4515,12 +4506,13 @@ OO.ui.DraggableGroupElement.prototype.isDragging = function () { }; /** - * Element containing an icon. + * IconElement is often mixed into other classes to generate an icon. + * Icons are graphics, about the size of normal text. They are used to aid the user + * in locating a control or to convey information in a space-efficient way. See the + * [OOjs UI documentation on MediaWiki] [1] for a list of icons + * included in the library. * - * Icons are graphics, about the size of normal text. They can be used to aid the user in locating - * a control or convey information in a more space efficient way. Icons should rarely be used - * without labels; such as in a toolbar where space is at a premium or within a context where the - * meaning is very clear to the user. + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons * * @abstract * @class @@ -4545,7 +4537,7 @@ OO.ui.IconElement = function OoUiIconElement( config ) { // Initialization this.setIcon( config.icon || this.constructor.static.icon ); this.setIconTitle( config.iconTitle || this.constructor.static.iconTitle ); - this.setIconElement( config.$icon || this.$( '' ) ); + this.setIconElement( config.$icon || $( '' ) ); }; /* Setup */ @@ -4555,31 +4547,31 @@ OO.initClass( OO.ui.IconElement ); /* Static Properties */ /** - * Icon. + * The symbolic name of the icon (e.g., ‘remove’ or ‘menu’), or a map of symbolic names. A map is used + * for i18n purposes and contains a `default` icon name and additional names keyed by + * language code. The `default` name is used when no icon is keyed by the user's language. * - * Value should be the unique portion of an icon CSS class name, such as 'up' for 'oo-ui-icon-up'. + * Example of an i18n map: * - * For i18n purposes, this property can be an object containing a `default` icon name property and - * additional icon names keyed by language code. - * - * Example of i18n icon definition: * { default: 'bold-a', en: 'bold-b', de: 'bold-f' } * + * Note: the static property will be overridden if the #icon configuration is used. + * * @static * @inheritable - * @property {Object|string} Symbolic icon name, or map of icon names keyed by language ID; - * use the 'default' key to specify the icon to be used when there is no icon in the user's - * language + * @property {Object|string} */ OO.ui.IconElement.static.icon = null; /** - * Icon title. + * The icon title, displayed when users move the mouse over the icon. The value can be text, a + * function that returns title text, or `null` for no title. + * + * The static property will be overridden if the #iconTitle configuration is used. * * @static * @inheritable - * @property {string|Function|null} Icon title text, a function that returns text or null for no - * icon title + * @property {string|Function|null} */ OO.ui.IconElement.static.iconTitle = null; @@ -4682,12 +4674,18 @@ OO.ui.IconElement.prototype.getIconTitle = function () { }; /** - * Element containing an indicator. + * IndicatorElement is often mixed into other classes to generate an indicator. + * Indicators are small graphics that are generally used in two ways: + * + * - To draw attention to the status of an item. For example, an indicator might be + * used to show that an item in a list has errors that need to be resolved. + * - To clarify the function of a control that acts in an exceptional way (a button + * that opens a menu instead of performing an action directly, for example). * - * Indicators are graphics, smaller than normal text. They can be used to describe unique status or - * behavior. Indicators should only be used in exceptional cases; such as a button that opens a menu - * instead of performing an action directly, or an item in a list which has errors that need to be - * resolved. + * For a list of indicators included in the library, please see the + * [OOjs UI documentation on MediaWiki] [1]. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Indicators * * @abstract * @class @@ -4711,7 +4709,7 @@ OO.ui.IndicatorElement = function OoUiIndicatorElement( config ) { // Initialization this.setIndicator( config.indicator || this.constructor.static.indicator ); this.setIndicatorTitle( config.indicatorTitle || this.constructor.static.indicatorTitle ); - this.setIndicatorElement( config.$indicator || this.$( '' ) ); + this.setIndicatorElement( config.$indicator || $( '' ) ); }; /* Setup */ @@ -4759,7 +4757,7 @@ OO.ui.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) { .addClass( 'oo-ui-indicatorElement-indicator' ) .toggleClass( 'oo-ui-indicator-' + this.indicator, !!this.indicator ); if ( this.indicatorTitle !== null ) { - this.$indicatorTitle.attr( 'title', this.indicatorTitle ); + this.$indicator.attr( 'title', this.indicatorTitle ); } }; @@ -4857,13 +4855,20 @@ OO.ui.LabelElement = function OoUiLabelElement( config ) { // Initialization this.setLabel( config.label || this.constructor.static.label ); - this.setLabelElement( config.$label || this.$( '' ) ); + this.setLabelElement( config.$label || $( '' ) ); }; /* Setup */ OO.initClass( OO.ui.LabelElement ); +/* Events */ + +/** + * @event labelChange + * @param {string} value + */ + /* Static Properties */ /** @@ -4908,15 +4913,16 @@ OO.ui.LabelElement.prototype.setLabel = function ( label ) { label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label; label = ( typeof label === 'string' && label.length ) || label instanceof jQuery ? label : null; + this.$element.toggleClass( 'oo-ui-labelElement', !!label ); + if ( this.label !== label ) { if ( this.$label ) { this.setLabelContent( label ); } this.label = label; + this.emit( 'labelChange' ); } - this.$element.toggleClass( 'oo-ui-labelElement', !!this.label ); - return this; }; @@ -4968,234 +4974,573 @@ OO.ui.LabelElement.prototype.setLabelContent = function ( label ) { }; /** - * Element containing an OO.ui.PopupWidget object. + * Mixin that adds a menu showing suggested values for a OO.ui.TextInputWidget. + * + * Subclasses that set the value of #lookupInput from #onLookupMenuItemChoose should + * be aware that this will cause new suggestions to be looked up for the new value. If this is + * not desired, disable lookups with #setLookupsDisabled, then set the value, then re-enable lookups. * - * @abstract * @class + * @abstract * * @constructor * @param {Object} [config] Configuration options - * @cfg {Object} [popup] Configuration to pass to popup - * @cfg {boolean} [autoClose=true] Popup auto-closes when it loses focus + * @cfg {jQuery} [$overlay] Overlay for dropdown; defaults to relative positioning + * @cfg {jQuery} [$container=this.$element] Element to render menu under */ -OO.ui.PopupElement = function OoUiPopupElement( config ) { +OO.ui.LookupElement = function OoUiLookupElement( config ) { // Configuration initialization config = config || {}; // Properties - this.popup = new OO.ui.PopupWidget( $.extend( - { autoClose: true }, - config.popup, - { $: this.$, $autoCloseIgnore: this.$element } - ) ); + this.$overlay = config.$overlay || this.$element; + this.lookupMenu = new OO.ui.TextInputMenuSelectWidget( this, { + widget: this, + input: this, + $container: config.$container + } ); + this.lookupCache = {}; + this.lookupQuery = null; + this.lookupRequest = null; + this.lookupsDisabled = false; + this.lookupInputFocused = false; + + // Events + this.$input.on( { + focus: this.onLookupInputFocus.bind( this ), + blur: this.onLookupInputBlur.bind( this ), + mousedown: this.onLookupInputMouseDown.bind( this ) + } ); + this.connect( this, { change: 'onLookupInputChange' } ); + this.lookupMenu.connect( this, { + toggle: 'onLookupMenuToggle', + choose: 'onLookupMenuItemChoose' + } ); + + // Initialization + this.$element.addClass( 'oo-ui-lookupElement' ); + this.lookupMenu.$element.addClass( 'oo-ui-lookupElement-menu' ); + this.$overlay.append( this.lookupMenu.$element ); }; /* Methods */ /** - * Get popup. + * Handle input focus event. * - * @return {OO.ui.PopupWidget} Popup widget + * @param {jQuery.Event} e Input focus event */ -OO.ui.PopupElement.prototype.getPopup = function () { - return this.popup; +OO.ui.LookupElement.prototype.onLookupInputFocus = function () { + this.lookupInputFocused = true; + this.populateLookupMenu(); }; /** - * Element with named flags that can be added, removed, listed and checked. - * - * A flag, when set, adds a CSS class on the `$element` by combining `oo-ui-flaggedElement-` with - * the flag name. Flags are primarily useful for styling. - * - * @abstract - * @class + * Handle input blur event. * - * @constructor - * @param {Object} [config] Configuration options - * @cfg {string|string[]} [flags] Flags describing importance and functionality, e.g. 'primary', - * 'safe', 'progressive', 'destructive' or 'constructive' - * @cfg {jQuery} [$flagged] Flagged node, assigned to #$flagged, omit to use #$element + * @param {jQuery.Event} e Input blur event */ -OO.ui.FlaggedElement = function OoUiFlaggedElement( config ) { - // Configuration initialization - config = config || {}; - - // Properties - this.flags = {}; - this.$flagged = null; - - // Initialization - this.setFlags( config.flags ); - this.setFlaggedElement( config.$flagged || this.$element ); +OO.ui.LookupElement.prototype.onLookupInputBlur = function () { + this.closeLookupMenu(); + this.lookupInputFocused = false; }; -/* Events */ - /** - * @event flag - * @param {Object.} changes Object keyed by flag name containing boolean - * added/removed properties + * Handle input mouse down event. + * + * @param {jQuery.Event} e Input mouse down event */ - -/* Methods */ +OO.ui.LookupElement.prototype.onLookupInputMouseDown = function () { + // Only open the menu if the input was already focused. + // This way we allow the user to open the menu again after closing it with Esc + // by clicking in the input. Opening (and populating) the menu when initially + // clicking into the input is handled by the focus handler. + if ( this.lookupInputFocused && !this.lookupMenu.isVisible() ) { + this.populateLookupMenu(); + } +}; /** - * Set the flagged element. + * Handle input change event. * - * If an element is already set, it will be cleaned up before setting up the new element. + * @param {string} value New input value + */ +OO.ui.LookupElement.prototype.onLookupInputChange = function () { + if ( this.lookupInputFocused ) { + this.populateLookupMenu(); + } +}; + +/** + * Handle the lookup menu being shown/hidden. * - * @param {jQuery} $flagged Element to add flags to + * @param {boolean} visible Whether the lookup menu is now visible. */ -OO.ui.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) { - var classNames = Object.keys( this.flags ).map( function ( flag ) { - return 'oo-ui-flaggedElement-' + flag; - } ).join( ' ' ); +OO.ui.LookupElement.prototype.onLookupMenuToggle = function ( visible ) { + if ( !visible ) { + // When the menu is hidden, abort any active request and clear the menu. + // This has to be done here in addition to closeLookupMenu(), because + // MenuSelectWidget will close itself when the user presses Esc. + this.abortLookupRequest(); + this.lookupMenu.clearItems(); + } +}; - if ( this.$flagged ) { - this.$flagged.removeClass( classNames ); +/** + * Handle menu item 'choose' event, updating the text input value to the value of the clicked item. + * + * @param {OO.ui.MenuOptionWidget|null} item Selected item + */ +OO.ui.LookupElement.prototype.onLookupMenuItemChoose = function ( item ) { + if ( item ) { + this.setValue( item.getData() ); } +}; - this.$flagged = $flagged.addClass( classNames ); +/** + * Get lookup menu. + * + * @return {OO.ui.TextInputMenuSelectWidget} + */ +OO.ui.LookupElement.prototype.getLookupMenu = function () { + return this.lookupMenu; }; /** - * Check if a flag is set. + * Disable or re-enable lookups. * - * @param {string} flag Name of flag - * @return {boolean} Has flag + * When lookups are disabled, calls to #populateLookupMenu will be ignored. + * + * @param {boolean} disabled Disable lookups */ -OO.ui.FlaggedElement.prototype.hasFlag = function ( flag ) { - return flag in this.flags; +OO.ui.LookupElement.prototype.setLookupsDisabled = function ( disabled ) { + this.lookupsDisabled = !!disabled; }; /** - * Get the names of all flags set. + * Open the menu. If there are no entries in the menu, this does nothing. * - * @return {string[]} Flag names + * @chainable */ -OO.ui.FlaggedElement.prototype.getFlags = function () { - return Object.keys( this.flags ); +OO.ui.LookupElement.prototype.openLookupMenu = function () { + if ( !this.lookupMenu.isEmpty() ) { + this.lookupMenu.toggle( true ); + } + return this; }; /** - * Clear all flags. + * Close the menu, empty it, and abort any pending request. * * @chainable - * @fires flag */ -OO.ui.FlaggedElement.prototype.clearFlags = function () { - var flag, className, - changes = {}, - remove = [], - classPrefix = 'oo-ui-flaggedElement-'; +OO.ui.LookupElement.prototype.closeLookupMenu = function () { + this.lookupMenu.toggle( false ); + this.abortLookupRequest(); + this.lookupMenu.clearItems(); + return this; +}; - for ( flag in this.flags ) { - className = classPrefix + flag; - changes[flag] = false; - delete this.flags[flag]; - remove.push( className ); - } +/** + * Request menu items based on the input's current value, and when they arrive, + * populate the menu with these items and show the menu. + * + * If lookups have been disabled with #setLookupsDisabled, this function does nothing. + * + * @chainable + */ +OO.ui.LookupElement.prototype.populateLookupMenu = function () { + var widget = this, + value = this.getValue(); - if ( this.$flagged ) { - this.$flagged.removeClass( remove.join( ' ' ) ); + if ( this.lookupsDisabled ) { + return; } - this.updateThemeClasses(); - this.emit( 'flag', changes ); + // If the input is empty, clear the menu + if ( value === '' ) { + this.closeLookupMenu(); + // Skip population if there is already a request pending for the current value + } else if ( value !== this.lookupQuery ) { + this.getLookupMenuItems() + .done( function ( items ) { + widget.lookupMenu.clearItems(); + if ( items.length ) { + widget.lookupMenu + .addItems( items ) + .toggle( true ); + widget.initializeLookupMenuSelection(); + } else { + widget.lookupMenu.toggle( false ); + } + } ) + .fail( function () { + widget.lookupMenu.clearItems(); + } ); + } return this; }; /** - * Add one or more flags. + * Select and highlight the first selectable item in the menu. * - * @param {string|string[]|Object.} flags One or more flags to add, or an object - * keyed by flag name containing boolean set/remove instructions. * @chainable - * @fires flag */ -OO.ui.FlaggedElement.prototype.setFlags = function ( flags ) { - var i, len, flag, className, - changes = {}, - add = [], - remove = [], - classPrefix = 'oo-ui-flaggedElement-'; +OO.ui.LookupElement.prototype.initializeLookupMenuSelection = function () { + if ( !this.lookupMenu.getSelectedItem() ) { + this.lookupMenu.selectItem( this.lookupMenu.getFirstSelectableItem() ); + } + this.lookupMenu.highlightItem( this.lookupMenu.getSelectedItem() ); +}; - if ( typeof flags === 'string' ) { - className = classPrefix + flags; - // Set - if ( !this.flags[flags] ) { - this.flags[flags] = true; - add.push( className ); - } - } else if ( $.isArray( flags ) ) { - for ( i = 0, len = flags.length; i < len; i++ ) { - flag = flags[i]; - className = classPrefix + flag; - // Set - if ( !this.flags[flag] ) { - changes[flag] = true; - this.flags[flag] = true; - add.push( className ); - } - } - } else if ( OO.isPlainObject( flags ) ) { - for ( flag in flags ) { - className = classPrefix + flag; - if ( flags[flag] ) { - // Set - if ( !this.flags[flag] ) { - changes[flag] = true; - this.flags[flag] = true; - add.push( className ); +/** + * Get lookup menu items for the current query. + * + * @return {jQuery.Promise} Promise object which will be passed menu items as the first argument of + * the done event. If the request was aborted to make way for a subsequent request, this promise + * will not be rejected: it will remain pending forever. + */ +OO.ui.LookupElement.prototype.getLookupMenuItems = function () { + var widget = this, + value = this.getValue(), + deferred = $.Deferred(), + ourRequest; + + this.abortLookupRequest(); + if ( Object.prototype.hasOwnProperty.call( this.lookupCache, value ) ) { + deferred.resolve( this.getLookupMenuOptionsFromData( this.lookupCache[ value ] ) ); + } else { + this.pushPending(); + this.lookupQuery = value; + ourRequest = this.lookupRequest = this.getLookupRequest(); + ourRequest + .always( function () { + // We need to pop pending even if this is an old request, otherwise + // the widget will remain pending forever. + // TODO: this assumes that an aborted request will fail or succeed soon after + // being aborted, or at least eventually. It would be nice if we could popPending() + // at abort time, but only if we knew that we hadn't already called popPending() + // for that request. + widget.popPending(); + } ) + .done( function ( data ) { + // If this is an old request (and aborting it somehow caused it to still succeed), + // ignore its success completely + if ( ourRequest === widget.lookupRequest ) { + widget.lookupQuery = null; + widget.lookupRequest = null; + widget.lookupCache[ value ] = widget.getLookupCacheDataFromResponse( data ); + deferred.resolve( widget.getLookupMenuOptionsFromData( widget.lookupCache[ value ] ) ); } - } else { - // Remove - if ( this.flags[flag] ) { - changes[flag] = false; - delete this.flags[flag]; - remove.push( className ); + } ) + .fail( function () { + // If this is an old request (or a request failing because it's being aborted), + // ignore its failure completely + if ( ourRequest === widget.lookupRequest ) { + widget.lookupQuery = null; + widget.lookupRequest = null; + deferred.reject(); } - } - } + } ); } + return deferred.promise(); +}; - if ( this.$flagged ) { - this.$flagged - .addClass( add.join( ' ' ) ) - .removeClass( remove.join( ' ' ) ); +/** + * Abort the currently pending lookup request, if any. + */ +OO.ui.LookupElement.prototype.abortLookupRequest = function () { + var oldRequest = this.lookupRequest; + if ( oldRequest ) { + // First unset this.lookupRequest to the fail handler will notice + // that the request is no longer current + this.lookupRequest = null; + this.lookupQuery = null; + oldRequest.abort(); } +}; - this.updateThemeClasses(); - this.emit( 'flag', changes ); +/** + * Get a new request object of the current lookup query value. + * + * @abstract + * @return {jQuery.Promise} jQuery AJAX object, or promise object with an .abort() method + */ +OO.ui.LookupElement.prototype.getLookupRequest = function () { + // Stub, implemented in subclass + return null; +}; - return this; +/** + * Pre-process data returned by the request from #getLookupRequest. + * + * The return value of this function will be cached, and any further queries for the given value + * will use the cache rather than doing API requests. + * + * @abstract + * @param {Mixed} data Response from server + * @return {Mixed} Cached result data + */ +OO.ui.LookupElement.prototype.getLookupCacheDataFromResponse = function () { + // Stub, implemented in subclass + return []; }; /** - * Element with a title. + * Get a list of menu option widgets from the (possibly cached) data returned by + * #getLookupCacheDataFromResponse. * - * Titles are rendered by the browser and are made visible when hovering the element. Titles are - * not visible on touch devices. + * @abstract + * @param {Mixed} data Cached result data, usually an array + * @return {OO.ui.MenuOptionWidget[]} Menu items + */ +OO.ui.LookupElement.prototype.getLookupMenuOptionsFromData = function () { + // Stub, implemented in subclass + return []; +}; + +/** + * Element containing an OO.ui.PopupWidget object. * * @abstract * @class * * @constructor * @param {Object} [config] Configuration options - * @cfg {jQuery} [$titled] Titled node, assigned to #$titled, omit to use #$element - * @cfg {string|Function} [title] Title text or a function that returns text. If not provided, the - * static property 'title' is used. + * @cfg {Object} [popup] Configuration to pass to popup + * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus */ -OO.ui.TitledElement = function OoUiTitledElement( config ) { +OO.ui.PopupElement = function OoUiPopupElement( config ) { // Configuration initialization config = config || {}; // Properties - this.$titled = null; - this.title = null; - - // Initialization + this.popup = new OO.ui.PopupWidget( $.extend( + { autoClose: true }, + config.popup, + { $autoCloseIgnore: this.$element } + ) ); +}; + +/* Methods */ + +/** + * Get popup. + * + * @return {OO.ui.PopupWidget} Popup widget + */ +OO.ui.PopupElement.prototype.getPopup = function () { + return this.popup; +}; + +/** + * The FlaggedElement class is an attribute mixin, meaning that it is used to add + * additional functionality to an element created by another class. The class provides + * a ‘flags’ property assigned the name (or an array of names) of styling flags, + * which are used to customize the look and feel of a widget to better describe its + * importance and functionality. + * + * The library currently contains the following styling flags for general use: + * + * - **progressive**: Progressive styling is applied to convey that the widget will move the user forward in a process. + * - **destructive**: Destructive styling is applied to convey that the widget will remove something. + * - **constructive**: Constructive styling is applied to convey that the widget will create something. + * + * {@link OO.ui.ActionWidget ActionWidgets}, which are a special kind of button that execute an action, use these flags: **primary** and **safe**. + * Please see the [OOjs UI documentation on MediaWiki] [1] for more information. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Flagged + * + * @abstract + * @class + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {string|string[]} [flags] Flags describing importance and functionality, e.g. 'primary', + * 'safe', 'progressive', 'destructive' or 'constructive' + * @cfg {jQuery} [$flagged] Flagged node, assigned to #$flagged, omit to use #$element + */ +OO.ui.FlaggedElement = function OoUiFlaggedElement( config ) { + // Configuration initialization + config = config || {}; + + // Properties + this.flags = {}; + this.$flagged = null; + + // Initialization + this.setFlags( config.flags ); + this.setFlaggedElement( config.$flagged || this.$element ); +}; + +/* Events */ + +/** + * @event flag + * @param {Object.} changes Object keyed by flag name containing boolean + * added/removed properties + */ + +/* Methods */ + +/** + * Set the flagged element. + * + * If an element is already set, it will be cleaned up before setting up the new element. + * + * @param {jQuery} $flagged Element to add flags to + */ +OO.ui.FlaggedElement.prototype.setFlaggedElement = function ( $flagged ) { + var classNames = Object.keys( this.flags ).map( function ( flag ) { + return 'oo-ui-flaggedElement-' + flag; + } ).join( ' ' ); + + if ( this.$flagged ) { + this.$flagged.removeClass( classNames ); + } + + this.$flagged = $flagged.addClass( classNames ); +}; + +/** + * Check if a flag is set. + * + * @param {string} flag Name of flag + * @return {boolean} Has flag + */ +OO.ui.FlaggedElement.prototype.hasFlag = function ( flag ) { + return flag in this.flags; +}; + +/** + * Get the names of all flags set. + * + * @return {string[]} Flag names + */ +OO.ui.FlaggedElement.prototype.getFlags = function () { + return Object.keys( this.flags ); +}; + +/** + * Clear all flags. + * + * @chainable + * @fires flag + */ +OO.ui.FlaggedElement.prototype.clearFlags = function () { + var flag, className, + changes = {}, + remove = [], + classPrefix = 'oo-ui-flaggedElement-'; + + for ( flag in this.flags ) { + className = classPrefix + flag; + changes[ flag ] = false; + delete this.flags[ flag ]; + remove.push( className ); + } + + if ( this.$flagged ) { + this.$flagged.removeClass( remove.join( ' ' ) ); + } + + this.updateThemeClasses(); + this.emit( 'flag', changes ); + + return this; +}; + +/** + * Add one or more flags. + * + * @param {string|string[]|Object.} flags One or more flags to add, or an object + * keyed by flag name containing boolean set/remove instructions. + * @chainable + * @fires flag + */ +OO.ui.FlaggedElement.prototype.setFlags = function ( flags ) { + var i, len, flag, className, + changes = {}, + add = [], + remove = [], + classPrefix = 'oo-ui-flaggedElement-'; + + if ( typeof flags === 'string' ) { + className = classPrefix + flags; + // Set + if ( !this.flags[ flags ] ) { + this.flags[ flags ] = true; + add.push( className ); + } + } else if ( Array.isArray( flags ) ) { + for ( i = 0, len = flags.length; i < len; i++ ) { + flag = flags[ i ]; + className = classPrefix + flag; + // Set + if ( !this.flags[ flag ] ) { + changes[ flag ] = true; + this.flags[ flag ] = true; + add.push( className ); + } + } + } else if ( OO.isPlainObject( flags ) ) { + for ( flag in flags ) { + className = classPrefix + flag; + if ( flags[ flag ] ) { + // Set + if ( !this.flags[ flag ] ) { + changes[ flag ] = true; + this.flags[ flag ] = true; + add.push( className ); + } + } else { + // Remove + if ( this.flags[ flag ] ) { + changes[ flag ] = false; + delete this.flags[ flag ]; + remove.push( className ); + } + } + } + } + + if ( this.$flagged ) { + this.$flagged + .addClass( add.join( ' ' ) ) + .removeClass( remove.join( ' ' ) ); + } + + this.updateThemeClasses(); + this.emit( 'flag', changes ); + + return this; +}; + +/** + * Element with a title. + * + * Titles are rendered by the browser and are made visible when hovering the element. Titles are + * not visible on touch devices. + * + * @abstract + * @class + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {jQuery} [$titled] Titled node, assigned to #$titled, omit to use #$element + * @cfg {string|Function} [title] Title text or a function that returns text. If not provided, the + * static property 'title' is used. + */ +OO.ui.TitledElement = function OoUiTitledElement( config ) { + // Configuration initialization + config = config || {}; + + // Properties + this.$titled = null; + this.title = null; + + // Initialization this.setTitle( config.title || this.constructor.static.title ); this.setTitledElement( config.$titled || this.$element ); }; @@ -5313,9 +5658,8 @@ OO.ui.ClippableElement = function OoUiClippableElement( config ) { OO.ui.ClippableElement.prototype.setClippableElement = function ( $clippable ) { if ( this.$clippable ) { this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' ); - this.$clippable.css( { width: '', height: '' } ); - this.$clippable.width(); // Force reflow for https://code.google.com/p/chromium/issues/detail?id=387290 - this.$clippable.css( { overflowX: '', overflowY: '' } ); + this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } ); + OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] ); } this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' ); @@ -5336,21 +5680,20 @@ OO.ui.ClippableElement.prototype.toggleClipping = function ( clipping ) { if ( this.clipping !== clipping ) { this.clipping = clipping; if ( clipping ) { - this.$clippableContainer = this.$( this.getClosestScrollableElementContainer() ); + this.$clippableContainer = $( this.getClosestScrollableElementContainer() ); // If the clippable container is the root, we have to listen to scroll events and check // jQuery.scrollTop on the window because of browser inconsistencies this.$clippableScroller = this.$clippableContainer.is( 'html, body' ) ? - this.$( OO.ui.Element.static.getWindow( this.$clippableContainer ) ) : + $( OO.ui.Element.static.getWindow( this.$clippableContainer ) ) : this.$clippableContainer; this.$clippableScroller.on( 'scroll', this.onClippableContainerScrollHandler ); - this.$clippableWindow = this.$( this.getElementWindow() ) + this.$clippableWindow = $( this.getElementWindow() ) .on( 'resize', this.onClippableWindowResizeHandler ); // Initial clip after visible this.clip(); } else { - this.$clippable.css( { width: '', height: '' } ); - this.$clippable.width(); // Force reflow for https://code.google.com/p/chromium/issues/detail?id=387290 - this.$clippable.css( { overflowX: '', overflowY: '' } ); + this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } ); + OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] ); this.$clippableContainer = null; this.$clippableScroller.off( 'scroll', this.onClippableContainerScrollHandler ); @@ -5456,16 +5799,17 @@ OO.ui.ClippableElement.prototype.clip = function () { if ( clipWidth ) { this.$clippable.css( { overflowX: 'scroll', width: desiredWidth } ); } else { - this.$clippable.css( 'width', this.idealWidth || '' ); - this.$clippable.width(); // Force reflow for https://code.google.com/p/chromium/issues/detail?id=387290 - this.$clippable.css( 'overflowX', '' ); + this.$clippable.css( { width: this.idealWidth || '', overflowX: '' } ); } if ( clipHeight ) { this.$clippable.css( { overflowY: 'scroll', height: desiredHeight } ); } else { - this.$clippable.css( 'height', this.idealHeight || '' ); - this.$clippable.height(); // Force reflow for https://code.google.com/p/chromium/issues/detail?id=387290 - this.$clippable.css( 'overflowY', '' ); + this.$clippable.css( { height: this.idealHeight || '', overflowY: '' } ); + } + + // If we stopped clipping in at least one of the dimensions + if ( !clipWidth || !clipHeight ) { + OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] ); } this.clippedHorizontally = clipWidth; @@ -5503,9 +5847,9 @@ OO.ui.Tool = function OoUiTool( toolGroup, config ) { this.toolGroup = toolGroup; this.toolbar = this.toolGroup.getToolbar(); this.active = false; - this.$title = this.$( '' ); - this.$accel = this.$( '' ); - this.$link = this.$( '' ); + this.$title = $( '' ); + this.$accel = $( '' ); + this.$link = $( '' ); this.title = null; // Events @@ -5766,8 +6110,8 @@ OO.ui.Toolbar = function OoUiToolbar( toolFactory, toolGroupFactory, config ) { this.toolGroupFactory = toolGroupFactory; this.groups = []; this.tools = {}; - this.$bar = this.$( '
' ); - this.$actions = this.$( '
' ); + this.$bar = $( '
' ); + this.$actions = $( '
' ); this.initialized = false; // Events @@ -5821,16 +6165,16 @@ OO.ui.Toolbar.prototype.getToolGroupFactory = function () { * @param {jQuery.Event} e Mouse down event */ OO.ui.Toolbar.prototype.onPointerDown = function ( e ) { - var $closestWidgetToEvent = this.$( e.target ).closest( '.oo-ui-widget' ), + var $closestWidgetToEvent = $( e.target ).closest( '.oo-ui-widget' ), $closestWidgetToToolbar = this.$element.closest( '.oo-ui-widget' ); - if ( !$closestWidgetToEvent.length || $closestWidgetToEvent[0] === $closestWidgetToToolbar[0] ) { + if ( !$closestWidgetToEvent.length || $closestWidgetToEvent[ 0 ] === $closestWidgetToToolbar[ 0 ] ) { return false; } }; /** * Sets up handles and preloads required information for the toolbar to work. - * This must be called immediately after it is attached to a visible document. + * This must be called after it is attached to a visible document and before doing anything else. */ OO.ui.Toolbar.prototype.initialize = function () { this.initialized = true; @@ -5861,7 +6205,7 @@ OO.ui.Toolbar.prototype.setup = function ( groups ) { // Build out new groups for ( i = 0, len = groups.length; i < len; i++ ) { - group = groups[i]; + group = groups[ i ]; if ( group.include === '*' ) { // Apply defaults to catch-all groups if ( group.type === undefined ) { @@ -5874,7 +6218,7 @@ OO.ui.Toolbar.prototype.setup = function ( groups ) { // Check type has been registered type = this.getToolGroupFactory().lookup( group.type ) ? group.type : defaultType; items.push( - this.getToolGroupFactory().create( type, this, $.extend( { $: this.$ }, group ) ) + this.getToolGroupFactory().create( type, this, group ) ); } this.addItems( items ); @@ -5889,7 +6233,7 @@ OO.ui.Toolbar.prototype.reset = function () { this.groups = []; this.tools = {}; for ( i = 0, len = this.items.length; i < len; i++ ) { - this.items[i].destroy(); + this.items[ i ].destroy(); } this.clearItems(); }; @@ -5911,7 +6255,7 @@ OO.ui.Toolbar.prototype.destroy = function () { * @return {boolean} Tool is available */ OO.ui.Toolbar.prototype.isToolAvailable = function ( name ) { - return !this.tools[name]; + return !this.tools[ name ]; }; /** @@ -5920,7 +6264,7 @@ OO.ui.Toolbar.prototype.isToolAvailable = function ( name ) { * @param {OO.ui.Tool} tool Tool to reserve */ OO.ui.Toolbar.prototype.reserveTool = function ( tool ) { - this.tools[tool.getName()] = tool; + this.tools[ tool.getName() ] = tool; }; /** @@ -5929,7 +6273,7 @@ OO.ui.Toolbar.prototype.reserveTool = function ( tool ) { * @param {OO.ui.Tool} tool Tool to release */ OO.ui.Toolbar.prototype.releaseTool = function ( tool ) { - delete this.tools[tool.getName()]; + delete this.tools[ tool.getName() ]; }; /** @@ -6063,7 +6407,7 @@ OO.ui.ToolGroup.prototype.updateDisabled = function () { if ( this.constructor.static.autoDisable ) { for ( i = this.items.length - 1; i >= 0; i-- ) { - item = this.items[i]; + item = this.items[ i ]; if ( !item.isDisabled() ) { allDisabled = false; break; @@ -6160,7 +6504,7 @@ OO.ui.ToolGroup.prototype.onMouseOut = function ( e ) { */ OO.ui.ToolGroup.prototype.getTargetTool = function ( e ) { var tool, - $item = this.$( e.target ).closest( '.oo-ui-tool-link' ); + $item = $( e.target ).closest( '.oo-ui-tool-link' ); if ( $item.length ) { tool = $item.parent().data( 'oo-ui-tool' ); @@ -6207,31 +6551,31 @@ OO.ui.ToolGroup.prototype.populate = function () { // Build a list of needed tools for ( i = 0, len = list.length; i < len; i++ ) { - name = list[i]; + name = list[ i ]; if ( // Tool exists toolFactory.lookup( name ) && // Tool is available or is already in this group - ( this.toolbar.isToolAvailable( name ) || this.tools[name] ) + ( this.toolbar.isToolAvailable( name ) || this.tools[ name ] ) ) { - tool = this.tools[name]; + tool = this.tools[ name ]; if ( !tool ) { // Auto-initialize tools on first use - this.tools[name] = tool = toolFactory.create( name, this ); + this.tools[ name ] = tool = toolFactory.create( name, this ); tool.updateTitle(); } this.toolbar.reserveTool( tool ); add.push( tool ); - names[name] = true; + names[ name ] = true; } } // Remove tools that are no longer needed for ( name in this.tools ) { - if ( !names[name] ) { - this.tools[name].destroy(); - this.toolbar.releaseTool( this.tools[name] ); - remove.push( this.tools[name] ); - delete this.tools[name]; + if ( !names[ name ] ) { + this.tools[ name ].destroy(); + this.toolbar.releaseTool( this.tools[ name ] ); + remove.push( this.tools[ name ] ); + delete this.tools[ name ]; } } if ( remove.length ) { @@ -6258,9 +6602,9 @@ OO.ui.ToolGroup.prototype.destroy = function () { this.clearItems(); this.toolbar.getToolFactory().disconnect( this ); for ( name in this.tools ) { - this.toolbar.releaseTool( this.tools[name] ); - this.tools[name].disconnect( this ).destroy(); - delete this.tools[name]; + this.toolbar.releaseTool( this.tools[ name ] ); + this.tools[ name ].disconnect( this ).destroy(); + delete this.tools[ name ]; } this.$element.remove(); }; @@ -6431,16 +6775,13 @@ OO.ui.MessageDialog.prototype.getBodyHeight = function () { var bodyHeight, oldOverflow, $scrollable = this.container.$element; - oldOverflow = $scrollable[0].style.overflow; - $scrollable[0].style.overflow = 'hidden'; + oldOverflow = $scrollable[ 0 ].style.overflow; + $scrollable[ 0 ].style.overflow = 'hidden'; - // Force… ugh… something to happen - $scrollable.contents().hide(); - $scrollable.height(); - $scrollable.contents().show(); + OO.ui.Element.static.reconsiderScrollbars( $scrollable[ 0 ] ); - bodyHeight = Math.round( this.text.$element.outerHeight( true ) ); - $scrollable[0].style.overflow = oldOverflow; + bodyHeight = this.text.$element.outerHeight( true ); + $scrollable[ 0 ].style.overflow = oldOverflow; return bodyHeight; }; @@ -6455,15 +6796,12 @@ OO.ui.MessageDialog.prototype.setDimensions = function ( dim ) { // Twiddle the overflow property, otherwise an unnecessary scrollbar will be produced. // Need to do it after transition completes (250ms), add 50ms just in case. setTimeout( function () { - var oldOverflow = $scrollable[0].style.overflow; - $scrollable[0].style.overflow = 'hidden'; + var oldOverflow = $scrollable[ 0 ].style.overflow; + $scrollable[ 0 ].style.overflow = 'hidden'; - // Force… ugh… something to happen - $scrollable.contents().hide(); - $scrollable.height(); - $scrollable.contents().show(); + OO.ui.Element.static.reconsiderScrollbars( $scrollable[ 0 ] ); - $scrollable[0].style.overflow = oldOverflow; + $scrollable[ 0 ].style.overflow = oldOverflow; }, 300 ); return this; @@ -6477,15 +6815,15 @@ OO.ui.MessageDialog.prototype.initialize = function () { OO.ui.MessageDialog.super.prototype.initialize.call( this ); // Properties - this.$actions = this.$( '
' ); + this.$actions = $( '
' ); this.container = new OO.ui.PanelLayout( { - $: this.$, scrollable: true, classes: [ 'oo-ui-messageDialog-container' ] + scrollable: true, classes: [ 'oo-ui-messageDialog-container' ] } ); this.text = new OO.ui.PanelLayout( { - $: this.$, padded: true, expanded: false, classes: [ 'oo-ui-messageDialog-text' ] + padded: true, expanded: false, classes: [ 'oo-ui-messageDialog-text' ] } ); this.message = new OO.ui.LabelWidget( { - $: this.$, classes: [ 'oo-ui-messageDialog-message' ] + classes: [ 'oo-ui-messageDialog-message' ] } ); // Initialization @@ -6515,7 +6853,7 @@ OO.ui.MessageDialog.prototype.attachActions = function () { } if ( others.length ) { for ( i = 0, len = others.length; i < len; i++ ) { - other = others[i]; + other = others[ i ]; this.$actions.append( other.$element ); other.toggleFramed( false ); } @@ -6528,7 +6866,7 @@ OO.ui.MessageDialog.prototype.attachActions = function () { if ( !this.isOpening() ) { // If the dialog is currently opening, this will be called automatically soon. // This also calls #fitActions. - this.manager.updateWindowSize( this ); + this.updateSize(); } }; @@ -6545,17 +6883,19 @@ OO.ui.MessageDialog.prototype.fitActions = function () { // Detect clipping this.toggleVerticalActionLayout( false ); for ( i = 0, len = actions.length; i < len; i++ ) { - action = actions[i]; + action = actions[ i ]; if ( action.$element.innerWidth() < action.$label.outerWidth( true ) ) { this.toggleVerticalActionLayout( true ); break; } } + // Move the body out of the way of the foot + this.$body.css( 'bottom', this.$foot.outerHeight( true ) ); + if ( this.verticalActionLayout !== previous ) { - this.$body.css( 'bottom', this.$foot.outerHeight( true ) ); // We changed the layout, window height might need to be updated. - this.manager.updateWindowSize( this ); + this.updateSize(); } }; @@ -6636,18 +6976,17 @@ OO.ui.ProcessDialog.prototype.initialize = function () { OO.ui.ProcessDialog.super.prototype.initialize.call( this ); // Properties - this.$navigation = this.$( '
' ); - this.$location = this.$( '
' ); - this.$safeActions = this.$( '
' ); - this.$primaryActions = this.$( '
' ); - this.$otherActions = this.$( '
' ); + this.$navigation = $( '
' ); + this.$location = $( '
' ); + this.$safeActions = $( '
' ); + this.$primaryActions = $( '
' ); + this.$otherActions = $( '
' ); this.dismissButton = new OO.ui.ButtonWidget( { - $: this.$, label: OO.ui.msg( 'ooui-dialog-process-dismiss' ) } ); - this.retryButton = new OO.ui.ButtonWidget( { $: this.$ } ); - this.$errors = this.$( '
' ); - this.$errorsTitle = this.$( '
' ); + this.retryButton = new OO.ui.ButtonWidget(); + this.$errors = $( '
' ); + this.$errorsTitle = $( '
' ); // Events this.dismissButton.connect( this, { click: 'onDismissErrorButtonClick' } ); @@ -6665,7 +7004,7 @@ OO.ui.ProcessDialog.prototype.initialize = function () { .addClass( 'oo-ui-processDialog-errors-title' ) .text( OO.ui.msg( 'ooui-dialog-process-error' ) ); this.$errors - .addClass( 'oo-ui-processDialog-errors' ) + .addClass( 'oo-ui-processDialog-errors oo-ui-element-hidden' ) .append( this.$errorsTitle, this.dismissButton.$element, this.retryButton.$element ); this.$content .addClass( 'oo-ui-processDialog-content' ) @@ -6694,7 +7033,7 @@ OO.ui.ProcessDialog.prototype.attachActions = function () { } if ( others.length ) { for ( i = 0, len = others.length; i < len; i++ ) { - other = others[i]; + other = others[ i ]; this.$otherActions.append( other.$element ); other.toggleFramed( true ); } @@ -6743,18 +7082,18 @@ OO.ui.ProcessDialog.prototype.showErrors = function ( errors ) { warning = false; for ( i = 0, len = errors.length; i < len; i++ ) { - if ( !errors[i].isRecoverable() ) { + if ( !errors[ i ].isRecoverable() ) { recoverable = false; } - if ( errors[i].isWarning() ) { + if ( errors[ i ].isWarning() ) { warning = true; } - $item = this.$( '
' ) + $item = $( '
' ) .addClass( 'oo-ui-processDialog-error' ) - .append( errors[i].getMessage() ); - items.push( $item[0] ); + .append( errors[ i ].getMessage() ); + items.push( $item[ 0 ] ); } - this.$errorItems = this.$( items ); + this.$errorItems = $( items ); if ( recoverable ) { this.retryButton.clearFlags().setFlags( this.currentAction.getFlags() ); } else { @@ -6767,867 +7106,1131 @@ OO.ui.ProcessDialog.prototype.showErrors = function ( errors ) { } this.retryButton.toggle( recoverable ); this.$errorsTitle.after( this.$errorItems ); - this.$errors.show().scrollTop( 0 ); + this.$errors.removeClass( 'oo-ui-element-hidden' ).scrollTop( 0 ); }; /** * Hide errors. */ OO.ui.ProcessDialog.prototype.hideErrors = function () { - this.$errors.hide(); + this.$errors.addClass( 'oo-ui-element-hidden' ); this.$errorItems.remove(); this.$errorItems = null; }; /** - * Layout containing a series of pages. + * FieldLayouts are used with OO.ui.FieldsetLayout. Each FieldLayout requires a field-widget, + * which is a widget that is specified by reference before any optional configuration settings. + * + * Field layouts can be configured with help text and/or labels. Labels are aligned in one of four ways: + * + * - **left**: The label is placed before the field-widget and aligned with the left margin. + * A left-alignment is used for forms with many fields. + * - **right**: The label is placed before the field-widget and aligned to the right margin. + * A right-alignment is used for long but familiar forms which users tab through, + * verifying the current field with a quick glance at the label. + * - **top**: The label is placed above the field-widget. A top-alignment is used for brief forms + * that users fill out from top to bottom. + * - **inline**: The label is placed after the field-widget and aligned to the left. + An inline-alignment is best used with checkboxes or radio buttons. + * + * Help text is accessed via a help icon that appears in the upper right corner of the rendered field layout. + * Please see the [OOjs UI documentation on MediaWiki] [1] for examples and more information. * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Fields_and_Fieldsets * @class * @extends OO.ui.Layout + * @mixins OO.ui.LabelElement * * @constructor + * @param {OO.ui.Widget} fieldWidget Field widget * @param {Object} [config] Configuration options - * @cfg {boolean} [continuous=false] Show all pages, one after another - * @cfg {boolean} [autoFocus=true] Focus on the first focusable element when changing to a page - * @cfg {boolean} [outlined=false] Show an outline - * @cfg {boolean} [editable=false] Show controls for adding, removing and reordering pages + * @cfg {string} [align='left'] Alignment mode, either 'left', 'right', 'top' or 'inline' + * @cfg {string} [help] Explanatory text shown as a '?' icon. */ -OO.ui.BookletLayout = function OoUiBookletLayout( config ) { +OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) { + var hasInputWidget = fieldWidget instanceof OO.ui.InputWidget; + // Configuration initialization - config = config || {}; + config = $.extend( { align: 'left' }, config ); // Parent constructor - OO.ui.BookletLayout.super.call( this, config ); + OO.ui.FieldLayout.super.call( this, config ); + + // Mixin constructors + OO.ui.LabelElement.call( this, config ); // Properties - this.currentPageName = null; - this.pages = {}; - this.ignoreFocus = false; - this.stackLayout = new OO.ui.StackLayout( { $: this.$, continuous: !!config.continuous } ); - this.autoFocus = config.autoFocus === undefined || !!config.autoFocus; - this.outlineVisible = false; - this.outlined = !!config.outlined; - if ( this.outlined ) { - this.editable = !!config.editable; - this.outlineControlsWidget = null; - this.outlineSelectWidget = new OO.ui.OutlineSelectWidget( { $: this.$ } ); - this.outlinePanel = new OO.ui.PanelLayout( { $: this.$, scrollable: true } ); - this.gridLayout = new OO.ui.GridLayout( - [ this.outlinePanel, this.stackLayout ], - { $: this.$, widths: [ 1, 2 ] } + this.fieldWidget = fieldWidget; + this.$field = $( '
' ); + this.$body = $( '<' + ( hasInputWidget ? 'label' : 'div' ) + '>' ); + this.align = null; + if ( config.help ) { + this.popupButtonWidget = new OO.ui.PopupButtonWidget( { + classes: [ 'oo-ui-fieldLayout-help' ], + framed: false, + icon: 'info' + } ); + + this.popupButtonWidget.getPopup().$body.append( + $( '
' ) + .text( config.help ) + .addClass( 'oo-ui-fieldLayout-help-content' ) ); - this.outlineVisible = true; - if ( this.editable ) { - this.outlineControlsWidget = new OO.ui.OutlineControlsWidget( - this.outlineSelectWidget, { $: this.$ } - ); - } + this.$help = this.popupButtonWidget.$element; + } else { + this.$help = $( [] ); } // Events - this.stackLayout.connect( this, { set: 'onStackLayoutSet' } ); - if ( this.outlined ) { - this.outlineSelectWidget.connect( this, { select: 'onOutlineSelectWidgetSelect' } ); - } - if ( this.autoFocus ) { - // Event 'focus' does not bubble, but 'focusin' does - this.stackLayout.$element.on( 'focusin', this.onStackLayoutFocus.bind( this ) ); + if ( hasInputWidget ) { + this.$label.on( 'click', this.onLabelClick.bind( this ) ); } + this.fieldWidget.connect( this, { disable: 'onFieldDisable' } ); // Initialization - this.$element.addClass( 'oo-ui-bookletLayout' ); - this.stackLayout.$element.addClass( 'oo-ui-bookletLayout-stackLayout' ); - if ( this.outlined ) { - this.outlinePanel.$element - .addClass( 'oo-ui-bookletLayout-outlinePanel' ) - .append( this.outlineSelectWidget.$element ); - if ( this.editable ) { - this.outlinePanel.$element - .addClass( 'oo-ui-bookletLayout-outlinePanel-editable' ) - .append( this.outlineControlsWidget.$element ); - } - this.$element.append( this.gridLayout.$element ); - } else { - this.$element.append( this.stackLayout.$element ); - } + this.$element + .addClass( 'oo-ui-fieldLayout' ) + .append( this.$help, this.$body ); + this.$body.addClass( 'oo-ui-fieldLayout-body' ); + this.$field + .addClass( 'oo-ui-fieldLayout-field' ) + .toggleClass( 'oo-ui-fieldLayout-disable', this.fieldWidget.isDisabled() ) + .append( this.fieldWidget.$element ); + + this.setAlignment( config.align ); }; /* Setup */ -OO.inheritClass( OO.ui.BookletLayout, OO.ui.Layout ); +OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout ); +OO.mixinClass( OO.ui.FieldLayout, OO.ui.LabelElement ); -/* Events */ +/* Methods */ /** - * @event set - * @param {OO.ui.PageLayout} page Current page + * Handle field disable events. + * + * @param {boolean} value Field is disabled */ +OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) { + this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value ); +}; /** - * @event add - * @param {OO.ui.PageLayout[]} page Added pages - * @param {number} index Index pages were added at + * Handle label mouse click events. + * + * @param {jQuery.Event} e Mouse click event */ +OO.ui.FieldLayout.prototype.onLabelClick = function () { + this.fieldWidget.simulateLabelClick(); + return false; +}; /** - * @event remove - * @param {OO.ui.PageLayout[]} pages Removed pages + * Get the field. + * + * @return {OO.ui.Widget} Field widget */ - -/* Methods */ +OO.ui.FieldLayout.prototype.getField = function () { + return this.fieldWidget; +}; /** - * Handle stack layout focus. + * Set the field alignment mode. * - * @param {jQuery.Event} e Focusin event + * @private + * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline' + * @chainable */ -OO.ui.BookletLayout.prototype.onStackLayoutFocus = function ( e ) { - var name, $target; - - // Find the page that an element was focused within - $target = $( e.target ).closest( '.oo-ui-pageLayout' ); - for ( name in this.pages ) { - // Check for page match, exclude current page to find only page changes - if ( this.pages[name].$element[0] === $target[0] && name !== this.currentPageName ) { - this.setPage( name ); - break; +OO.ui.FieldLayout.prototype.setAlignment = function ( value ) { + if ( value !== this.align ) { + // Default to 'left' + if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) { + value = 'left'; } + // Reorder elements + if ( value === 'inline' ) { + this.$body.append( this.$field, this.$label ); + } else { + this.$body.append( this.$label, this.$field ); + } + // Set classes. The following classes can be used here: + // * oo-ui-fieldLayout-align-left + // * oo-ui-fieldLayout-align-right + // * oo-ui-fieldLayout-align-top + // * oo-ui-fieldLayout-align-inline + if ( this.align ) { + this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align ); + } + this.$element.addClass( 'oo-ui-fieldLayout-align-' + value ); + this.align = value; } + + return this; }; /** - * Handle stack layout set events. + * Layout made of a field, a button, and an optional label. * - * @param {OO.ui.PanelLayout|null} page The page panel that is now the current panel + * @class + * @extends OO.ui.FieldLayout + * + * @constructor + * @param {OO.ui.Widget} fieldWidget Field widget + * @param {OO.ui.ButtonWidget} buttonWidget Button widget + * @param {Object} [config] Configuration options + * @cfg {string} [align='left'] Alignment mode, either 'left', 'right', 'top' or 'inline' + * @cfg {string} [help] Explanatory text shown as a '?' icon. */ -OO.ui.BookletLayout.prototype.onStackLayoutSet = function ( page ) { - var layout = this; - if ( page ) { - page.scrollElementIntoView( { complete: function () { - if ( layout.autoFocus ) { - layout.focus(); - } - } } ); - } +OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) { + // Configuration initialization + config = $.extend( { align: 'left' }, config ); + + // Parent constructor + OO.ui.ActionFieldLayout.super.call( this, fieldWidget, config ); + + // Mixin constructors + OO.ui.LabelElement.call( this, config ); + + // Properties + this.fieldWidget = fieldWidget; + this.buttonWidget = buttonWidget; + this.$button = $( '
' ) + .addClass( 'oo-ui-actionFieldLayout-button' ) + .append( this.buttonWidget.$element ); + this.$input = $( '
' ) + .addClass( 'oo-ui-actionFieldLayout-input' ) + .append( this.fieldWidget.$element ); + this.$field + .addClass( 'oo-ui-actionFieldLayout' ) + .append( this.$input, this.$button ); }; +/* Setup */ + +OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout ); + /** - * Focus the first input in the current page. + * Layout made of a fieldset and optional legend. * - * If no page is selected, the first selectable page will be selected. - * If the focus is already in an element on the current page, nothing will happen. + * Just add OO.ui.FieldLayout items. + * + * @class + * @extends OO.ui.Layout + * @mixins OO.ui.IconElement + * @mixins OO.ui.LabelElement + * @mixins OO.ui.GroupElement + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {OO.ui.FieldLayout[]} [items] Items to add */ -OO.ui.BookletLayout.prototype.focus = function () { - var $input, page = this.stackLayout.getCurrentItem(); - if ( !page && this.outlined ) { - this.selectFirstSelectablePage(); - page = this.stackLayout.getCurrentItem(); - if ( !page ) { - return; - } +OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) { + // Configuration initialization + config = config || {}; + + // Parent constructor + OO.ui.FieldsetLayout.super.call( this, config ); + + // Mixin constructors + OO.ui.IconElement.call( this, config ); + OO.ui.LabelElement.call( this, config ); + OO.ui.GroupElement.call( this, config ); + + if ( config.help ) { + this.popupButtonWidget = new OO.ui.PopupButtonWidget( { + classes: [ 'oo-ui-fieldsetLayout-help' ], + framed: false, + icon: 'info' + } ); + + this.popupButtonWidget.getPopup().$body.append( + $( '
' ) + .text( config.help ) + .addClass( 'oo-ui-fieldsetLayout-help-content' ) + ); + this.$help = this.popupButtonWidget.$element; + } else { + this.$help = $( [] ); } - // Only change the focus if is not already in the current page - if ( !page.$element.find( ':focus' ).length ) { - $input = page.$element.find( ':input:first' ); - if ( $input.length ) { - $input[0].focus(); - } + + // Initialization + this.$element + .addClass( 'oo-ui-fieldsetLayout' ) + .prepend( this.$help, this.$icon, this.$label, this.$group ); + if ( Array.isArray( config.items ) ) { + this.addItems( config.items ); } }; +/* Setup */ + +OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout ); +OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.IconElement ); +OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.LabelElement ); +OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.GroupElement ); + /** - * Handle outline widget select events. + * Layout with an HTML form. * - * @param {OO.ui.OptionWidget|null} item Selected item + * @class + * @extends OO.ui.Layout + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {string} [method] HTML form `method` attribute + * @cfg {string} [action] HTML form `action` attribute + * @cfg {string} [enctype] HTML form `enctype` attribute */ -OO.ui.BookletLayout.prototype.onOutlineSelectWidgetSelect = function ( item ) { - if ( item ) { - this.setPage( item.getData() ); - } +OO.ui.FormLayout = function OoUiFormLayout( config ) { + // Configuration initialization + config = config || {}; + + // Parent constructor + OO.ui.FormLayout.super.call( this, config ); + + // Events + this.$element.on( 'submit', this.onFormSubmit.bind( this ) ); + + // Initialization + this.$element + .addClass( 'oo-ui-formLayout' ) + .attr( { + method: config.method, + action: config.action, + enctype: config.enctype + } ); }; +/* Setup */ + +OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout ); + +/* Events */ + /** - * Check if booklet has an outline. - * - * @return {boolean} + * @event submit */ -OO.ui.BookletLayout.prototype.isOutlined = function () { - return this.outlined; -}; + +/* Static Properties */ + +OO.ui.FormLayout.static.tagName = 'form'; + +/* Methods */ /** - * Check if booklet has editing controls. + * Handle form submit events. * - * @return {boolean} + * @param {jQuery.Event} e Submit event + * @fires submit */ -OO.ui.BookletLayout.prototype.isEditable = function () { - return this.editable; +OO.ui.FormLayout.prototype.onFormSubmit = function () { + this.emit( 'submit' ); + return false; }; /** - * Check if booklet has a visible outline. + * Layout made of proportionally sized columns and rows. * - * @return {boolean} + * @class + * @extends OO.ui.Layout + * @deprecated Use OO.ui.MenuLayout or plain CSS instead. + * + * @constructor + * @param {OO.ui.PanelLayout[]} panels Panels in the grid + * @param {Object} [config] Configuration options + * @cfg {number[]} [widths] Widths of columns as ratios + * @cfg {number[]} [heights] Heights of rows as ratios */ -OO.ui.BookletLayout.prototype.isOutlineVisible = function () { - return this.outlined && this.outlineVisible; +OO.ui.GridLayout = function OoUiGridLayout( panels, config ) { + var i, len, widths; + + // Configuration initialization + config = config || {}; + + // Parent constructor + OO.ui.GridLayout.super.call( this, config ); + + // Properties + this.panels = []; + this.widths = []; + this.heights = []; + + // Initialization + this.$element.addClass( 'oo-ui-gridLayout' ); + for ( i = 0, len = panels.length; i < len; i++ ) { + this.panels.push( panels[ i ] ); + this.$element.append( panels[ i ].$element ); + } + if ( config.widths || config.heights ) { + this.layout( config.widths || [ 1 ], config.heights || [ 1 ] ); + } else { + // Arrange in columns by default + widths = this.panels.map( function () { return 1; } ); + this.layout( widths, [ 1 ] ); + } }; +/* Setup */ + +OO.inheritClass( OO.ui.GridLayout, OO.ui.Layout ); + +/* Events */ + /** - * Hide or show the outline. + * @event layout + */ + +/** + * @event update + */ + +/* Methods */ + +/** + * Set grid dimensions. * - * @param {boolean} [show] Show outline, omit to invert current state - * @chainable + * @param {number[]} widths Widths of columns as ratios + * @param {number[]} heights Heights of rows as ratios + * @fires layout + * @throws {Error} If grid is not large enough to fit all panels */ -OO.ui.BookletLayout.prototype.toggleOutline = function ( show ) { - if ( this.outlined ) { - show = show === undefined ? !this.outlineVisible : !!show; - this.outlineVisible = show; - this.gridLayout.layout( show ? [ 1, 2 ] : [ 0, 1 ], [ 1 ] ); +OO.ui.GridLayout.prototype.layout = function ( widths, heights ) { + var x, y, + xd = 0, + yd = 0, + cols = widths.length, + rows = heights.length; + + // Verify grid is big enough to fit panels + if ( cols * rows < this.panels.length ) { + throw new Error( 'Grid is not large enough to fit ' + this.panels.length + 'panels' ); } - return this; + // Sum up denominators + for ( x = 0; x < cols; x++ ) { + xd += widths[ x ]; + } + for ( y = 0; y < rows; y++ ) { + yd += heights[ y ]; + } + // Store factors + this.widths = []; + this.heights = []; + for ( x = 0; x < cols; x++ ) { + this.widths[ x ] = widths[ x ] / xd; + } + for ( y = 0; y < rows; y++ ) { + this.heights[ y ] = heights[ y ] / yd; + } + // Synchronize view + this.update(); + this.emit( 'layout' ); }; /** - * Get the outline widget. + * Update panel positions and sizes. * - * @param {OO.ui.PageLayout} page Page to be selected - * @return {OO.ui.PageLayout|null} Closest page to another + * @fires update */ -OO.ui.BookletLayout.prototype.getClosestPage = function ( page ) { - var next, prev, level, - pages = this.stackLayout.getItems(), - index = $.inArray( page, pages ); +OO.ui.GridLayout.prototype.update = function () { + var x, y, panel, width, height, dimensions, + i = 0, + top = 0, + left = 0, + cols = this.widths.length, + rows = this.heights.length; - if ( index !== -1 ) { - next = pages[index + 1]; - prev = pages[index - 1]; - // Prefer adjacent pages at the same level - if ( this.outlined ) { - level = this.outlineSelectWidget.getItemFromData( page.getName() ).getLevel(); - if ( - prev && - level === this.outlineSelectWidget.getItemFromData( prev.getName() ).getLevel() - ) { - return prev; + for ( y = 0; y < rows; y++ ) { + height = this.heights[ y ]; + for ( x = 0; x < cols; x++ ) { + width = this.widths[ x ]; + panel = this.panels[ i ]; + dimensions = { + width: ( width * 100 ) + '%', + height: ( height * 100 ) + '%', + top: ( top * 100 ) + '%' + }; + // If RTL, reverse: + if ( OO.ui.Element.static.getDir( document ) === 'rtl' ) { + dimensions.right = ( left * 100 ) + '%'; + } else { + dimensions.left = ( left * 100 ) + '%'; } - if ( - next && - level === this.outlineSelectWidget.getItemFromData( next.getName() ).getLevel() - ) { - return next; + // HACK: Work around IE bug by setting visibility: hidden; if width or height is zero + if ( width === 0 || height === 0 ) { + dimensions.visibility = 'hidden'; + } else { + dimensions.visibility = ''; } + panel.$element.css( dimensions ); + i++; + left += width; } + top += height; + left = 0; } - return prev || next || null; + + this.emit( 'update' ); }; /** - * Get the outline widget. + * Get a panel at a given position. * - * @return {OO.ui.OutlineSelectWidget|null} Outline widget, or null if booklet has no outline + * The x and y position is affected by the current grid layout. + * + * @param {number} x Horizontal position + * @param {number} y Vertical position + * @return {OO.ui.PanelLayout} The panel at the given position */ -OO.ui.BookletLayout.prototype.getOutline = function () { - return this.outlineSelectWidget; +OO.ui.GridLayout.prototype.getPanel = function ( x, y ) { + return this.panels[ ( x * this.widths.length ) + y ]; }; /** - * Get the outline controls widget. If the outline is not editable, null is returned. + * Layout with a content and menu area. * - * @return {OO.ui.OutlineControlsWidget|null} The outline controls widget. + * The menu area can be positioned at the top, after, bottom or before. The content area will fill + * all remaining space. + * + * @class + * @extends OO.ui.Layout + * + * @constructor + * @param {Object} [config] Configuration options + * @cfg {number|string} [menuSize='18em'] Size of menu in pixels or any CSS unit + * @cfg {boolean} [showMenu=true] Show menu + * @cfg {string} [position='before'] Position of menu, either `top`, `after`, `bottom` or `before` + * @cfg {boolean} [collapse] Collapse the menu out of view */ -OO.ui.BookletLayout.prototype.getOutlineControls = function () { - return this.outlineControlsWidget; +OO.ui.MenuLayout = function OoUiMenuLayout( config ) { + var positions = this.constructor.static.menuPositions; + + // Configuration initialization + config = config || {}; + + // Parent constructor + OO.ui.MenuLayout.super.call( this, config ); + + // Properties + this.showMenu = config.showMenu !== false; + this.menuSize = config.menuSize || '18em'; + this.menuPosition = positions[ config.menuPosition ] || positions.before; + + /** + * Menu DOM node + * + * @property {jQuery} + */ + this.$menu = $( '
' ); + /** + * Content DOM node + * + * @property {jQuery} + */ + this.$content = $( '
' ); + + // Initialization + this.toggleMenu( this.showMenu ); + this.updateSizes(); + this.$menu + .addClass( 'oo-ui-menuLayout-menu' ) + .css( this.menuPosition.sizeProperty, this.menuSize ); + this.$content.addClass( 'oo-ui-menuLayout-content' ); + this.$element + .addClass( 'oo-ui-menuLayout ' + this.menuPosition.className ) + .append( this.$content, this.$menu ); +}; + +/* Setup */ + +OO.inheritClass( OO.ui.MenuLayout, OO.ui.Layout ); + +/* Static Properties */ + +OO.ui.MenuLayout.static.menuPositions = { + top: { + sizeProperty: 'height', + className: 'oo-ui-menuLayout-top' + }, + after: { + sizeProperty: 'width', + className: 'oo-ui-menuLayout-after' + }, + bottom: { + sizeProperty: 'height', + className: 'oo-ui-menuLayout-bottom' + }, + before: { + sizeProperty: 'width', + className: 'oo-ui-menuLayout-before' + } }; +/* Methods */ + /** - * Get a page by name. + * Toggle menu. * - * @param {string} name Symbolic name of page - * @return {OO.ui.PageLayout|undefined} Page, if found + * @param {boolean} showMenu Show menu, omit to toggle + * @chainable */ -OO.ui.BookletLayout.prototype.getPage = function ( name ) { - return this.pages[name]; +OO.ui.MenuLayout.prototype.toggleMenu = function ( showMenu ) { + showMenu = showMenu === undefined ? !this.showMenu : !!showMenu; + + if ( this.showMenu !== showMenu ) { + this.showMenu = showMenu; + this.updateSizes(); + } + + return this; }; /** - * Get the current page name. + * Check if menu is visible * - * @return {string|null} Current page name + * @return {boolean} Menu is visible */ -OO.ui.BookletLayout.prototype.getCurrentPageName = function () { - return this.currentPageName; +OO.ui.MenuLayout.prototype.isMenuVisible = function () { + return this.showMenu; }; /** - * Add a page to the layout. - * - * When pages are added with the same names as existing pages, the existing pages will be - * automatically removed before the new pages are added. + * Set menu size. * - * @param {OO.ui.PageLayout[]} pages Pages to add - * @param {number} index Index to insert pages after - * @fires add + * @param {number|string} size Size of menu in pixels or any CSS unit * @chainable */ -OO.ui.BookletLayout.prototype.addPages = function ( pages, index ) { - var i, len, name, page, item, currentIndex, - stackLayoutPages = this.stackLayout.getItems(), - remove = [], - items = []; - - // Remove pages with same names - for ( i = 0, len = pages.length; i < len; i++ ) { - page = pages[i]; - name = page.getName(); - - if ( Object.prototype.hasOwnProperty.call( this.pages, name ) ) { - // Correct the insertion index - currentIndex = $.inArray( this.pages[name], stackLayoutPages ); - if ( currentIndex !== -1 && currentIndex + 1 < index ) { - index--; - } - remove.push( this.pages[name] ); - } - } - if ( remove.length ) { - this.removePages( remove ); - } - - // Add new pages - for ( i = 0, len = pages.length; i < len; i++ ) { - page = pages[i]; - name = page.getName(); - this.pages[page.getName()] = page; - if ( this.outlined ) { - item = new OO.ui.OutlineOptionWidget( { $: this.$, data: name } ); - page.setOutlineItem( item ); - items.push( item ); - } - } - - if ( this.outlined && items.length ) { - this.outlineSelectWidget.addItems( items, index ); - this.selectFirstSelectablePage(); - } - this.stackLayout.addItems( pages, index ); - this.emit( 'add', pages, index ); +OO.ui.MenuLayout.prototype.setMenuSize = function ( size ) { + this.menuSize = size; + this.updateSizes(); return this; }; /** - * Remove a page from the layout. + * Update menu and content CSS based on current menu size and visibility * - * @fires remove - * @chainable + * This method is called internally when size or position is changed. */ -OO.ui.BookletLayout.prototype.removePages = function ( pages ) { - var i, len, name, page, - items = []; - - for ( i = 0, len = pages.length; i < len; i++ ) { - page = pages[i]; - name = page.getName(); - delete this.pages[name]; - if ( this.outlined ) { - items.push( this.outlineSelectWidget.getItemFromData( name ) ); - page.setOutlineItem( null ); - } - } - if ( this.outlined && items.length ) { - this.outlineSelectWidget.removeItems( items ); - this.selectFirstSelectablePage(); +OO.ui.MenuLayout.prototype.updateSizes = function () { + if ( this.showMenu ) { + this.$menu + .css( this.menuPosition.sizeProperty, this.menuSize ) + .css( 'overflow', '' ); + // Set offsets on all sides. CSS resets all but one with + // 'important' rules so directionality flips are supported + this.$content.css( { + top: this.menuSize, + right: this.menuSize, + bottom: this.menuSize, + left: this.menuSize + } ); + } else { + this.$menu + .css( this.menuPosition.sizeProperty, 0 ) + .css( 'overflow', 'hidden' ); + this.$content.css( { + top: 0, + right: 0, + bottom: 0, + left: 0 + } ); } - this.stackLayout.removeItems( pages ); - this.emit( 'remove', pages ); +}; - return this; +/** + * Get menu size. + * + * @return {number|string} Menu size + */ +OO.ui.MenuLayout.prototype.getMenuSize = function () { + return this.menuSize; }; /** - * Clear all pages from the layout. + * Set menu position. * - * @fires remove + * @param {string} position Position of menu, either `top`, `after`, `bottom` or `before` + * @throws {Error} If position value is not supported * @chainable */ -OO.ui.BookletLayout.prototype.clearPages = function () { - var i, len, - pages = this.stackLayout.getItems(); +OO.ui.MenuLayout.prototype.setMenuPosition = function ( position ) { + var positions = this.constructor.static.menuPositions; - this.pages = {}; - this.currentPageName = null; - if ( this.outlined ) { - this.outlineSelectWidget.clearItems(); - for ( i = 0, len = pages.length; i < len; i++ ) { - pages[i].setOutlineItem( null ); - } + if ( !positions[ position ] ) { + throw new Error( 'Cannot set position; unsupported position value: ' + position ); } - this.stackLayout.clearItems(); - this.emit( 'remove', pages ); + this.$menu.css( this.menuPosition.sizeProperty, '' ); + this.$element.removeClass( this.menuPosition.className ); - return this; -}; + this.menuPosition = positions[ position ]; -/** - * Set the current page by name. - * - * @fires set - * @param {string} name Symbolic name of page - */ -OO.ui.BookletLayout.prototype.setPage = function ( name ) { - var selectedItem, - $focused, - page = this.pages[name]; + this.updateSizes(); + this.$element.addClass( this.menuPosition.className ); - if ( name !== this.currentPageName ) { - if ( this.outlined ) { - selectedItem = this.outlineSelectWidget.getSelectedItem(); - if ( selectedItem && selectedItem.getData() !== name ) { - this.outlineSelectWidget.selectItem( this.outlineSelectWidget.getItemFromData( name ) ); - } - } - if ( page ) { - if ( this.currentPageName && this.pages[this.currentPageName] ) { - this.pages[this.currentPageName].setActive( false ); - // Blur anything focused if the next page doesn't have anything focusable - this - // is not needed if the next page has something focusable because once it is focused - // this blur happens automatically - if ( this.autoFocus && !page.$element.find( ':input' ).length ) { - $focused = this.pages[this.currentPageName].$element.find( ':focus' ); - if ( $focused.length ) { - $focused[0].blur(); - } - } - } - this.currentPageName = name; - this.stackLayout.setItem( page ); - page.setActive( true ); - this.emit( 'set', page ); - } - } + return this; }; /** - * Select the first selectable page. + * Get menu position. * - * @chainable + * @return {string} Menu position */ -OO.ui.BookletLayout.prototype.selectFirstSelectablePage = function () { - if ( !this.outlineSelectWidget.getSelectedItem() ) { - this.outlineSelectWidget.selectItem( this.outlineSelectWidget.getFirstSelectableItem() ); - } - - return this; +OO.ui.MenuLayout.prototype.getMenuPosition = function () { + return this.menuPosition; }; /** - * Layout made of a field and optional label. - * - * Available label alignment modes include: - * - left: Label is before the field and aligned away from it, best for when the user will be - * scanning for a specific label in a form with many fields - * - right: Label is before the field and aligned toward it, best for forms the user is very - * familiar with and will tab through field checking quickly to verify which field they are in - * - top: Label is before the field and above it, best for when the user will need to fill out all - * fields from top to bottom in a form with few fields - * - inline: Label is after the field and aligned toward it, best for small boolean fields like - * checkboxes or radio buttons + * Layout containing a series of pages. * * @class - * @extends OO.ui.Layout - * @mixins OO.ui.LabelElement + * @extends OO.ui.MenuLayout * * @constructor - * @param {OO.ui.Widget} fieldWidget Field widget * @param {Object} [config] Configuration options - * @cfg {string} [align='left'] Alignment mode, either 'left', 'right', 'top' or 'inline' - * @cfg {string} [help] Explanatory text shown as a '?' icon. + * @cfg {boolean} [continuous=false] Show all pages, one after another + * @cfg {boolean} [autoFocus=true] Focus on the first focusable element when changing to a page + * @cfg {boolean} [outlined=false] Show an outline + * @cfg {boolean} [editable=false] Show controls for adding, removing and reordering pages */ -OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) { - var hasInputWidget = fieldWidget instanceof OO.ui.InputWidget; - +OO.ui.BookletLayout = function OoUiBookletLayout( config ) { // Configuration initialization - config = $.extend( { align: 'left' }, config ); - - // Properties (must be set before parent constructor, which calls #getTagName) - this.fieldWidget = fieldWidget; + config = config || {}; // Parent constructor - OO.ui.FieldLayout.super.call( this, config ); - - // Mixin constructors - OO.ui.LabelElement.call( this, config ); + OO.ui.BookletLayout.super.call( this, config ); // Properties - this.$field = this.$( '
' ); - this.$body = this.$( '<' + ( hasInputWidget ? 'label' : 'div' ) + '>' ); - this.align = null; - if ( config.help ) { - this.popupButtonWidget = new OO.ui.PopupButtonWidget( { - $: this.$, - classes: [ 'oo-ui-fieldLayout-help' ], - framed: false, - icon: 'info' - } ); - - this.popupButtonWidget.getPopup().$body.append( - this.$( '
' ) - .text( config.help ) - .addClass( 'oo-ui-fieldLayout-help-content' ) - ); - this.$help = this.popupButtonWidget.$element; - } else { - this.$help = this.$( [] ); + this.currentPageName = null; + this.pages = {}; + this.ignoreFocus = false; + this.stackLayout = new OO.ui.StackLayout( { continuous: !!config.continuous } ); + this.$content.append( this.stackLayout.$element ); + this.autoFocus = config.autoFocus === undefined || !!config.autoFocus; + this.outlineVisible = false; + this.outlined = !!config.outlined; + if ( this.outlined ) { + this.editable = !!config.editable; + this.outlineControlsWidget = null; + this.outlineSelectWidget = new OO.ui.OutlineSelectWidget(); + this.outlinePanel = new OO.ui.PanelLayout( { scrollable: true } ); + this.$menu.append( this.outlinePanel.$element ); + this.outlineVisible = true; + if ( this.editable ) { + this.outlineControlsWidget = new OO.ui.OutlineControlsWidget( + this.outlineSelectWidget + ); + } } + this.toggleMenu( this.outlined ); // Events - if ( hasInputWidget ) { - this.$label.on( 'click', this.onLabelClick.bind( this ) ); + this.stackLayout.connect( this, { set: 'onStackLayoutSet' } ); + if ( this.outlined ) { + this.outlineSelectWidget.connect( this, { select: 'onOutlineSelectWidgetSelect' } ); + } + if ( this.autoFocus ) { + // Event 'focus' does not bubble, but 'focusin' does + this.stackLayout.$element.on( 'focusin', this.onStackLayoutFocus.bind( this ) ); } - this.fieldWidget.connect( this, { disable: 'onFieldDisable' } ); // Initialization - this.$element - .addClass( 'oo-ui-fieldLayout' ) - .append( this.$help, this.$body ); - this.$body.addClass( 'oo-ui-fieldLayout-body' ); - this.$field - .addClass( 'oo-ui-fieldLayout-field' ) - .toggleClass( 'oo-ui-fieldLayout-disable', this.fieldWidget.isDisabled() ) - .append( this.fieldWidget.$element ); - - this.setAlignment( config.align ); + this.$element.addClass( 'oo-ui-bookletLayout' ); + this.stackLayout.$element.addClass( 'oo-ui-bookletLayout-stackLayout' ); + if ( this.outlined ) { + this.outlinePanel.$element + .addClass( 'oo-ui-bookletLayout-outlinePanel' ) + .append( this.outlineSelectWidget.$element ); + if ( this.editable ) { + this.outlinePanel.$element + .addClass( 'oo-ui-bookletLayout-outlinePanel-editable' ) + .append( this.outlineControlsWidget.$element ); + } + } }; /* Setup */ -OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout ); -OO.mixinClass( OO.ui.FieldLayout, OO.ui.LabelElement ); +OO.inheritClass( OO.ui.BookletLayout, OO.ui.MenuLayout ); + +/* Events */ + +/** + * @event set + * @param {OO.ui.PageLayout} page Current page + */ + +/** + * @event add + * @param {OO.ui.PageLayout[]} page Added pages + * @param {number} index Index pages were added at + */ + +/** + * @event remove + * @param {OO.ui.PageLayout[]} pages Removed pages + */ /* Methods */ /** - * Handle field disable events. + * Handle stack layout focus. * - * @param {boolean} value Field is disabled + * @param {jQuery.Event} e Focusin event */ -OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) { - this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value ); +OO.ui.BookletLayout.prototype.onStackLayoutFocus = function ( e ) { + var name, $target; + + // Find the page that an element was focused within + $target = $( e.target ).closest( '.oo-ui-pageLayout' ); + for ( name in this.pages ) { + // Check for page match, exclude current page to find only page changes + if ( this.pages[ name ].$element[ 0 ] === $target[ 0 ] && name !== this.currentPageName ) { + this.setPage( name ); + break; + } + } }; /** - * Handle label mouse click events. + * Handle stack layout set events. * - * @param {jQuery.Event} e Mouse click event + * @param {OO.ui.PanelLayout|null} page The page panel that is now the current panel */ -OO.ui.FieldLayout.prototype.onLabelClick = function () { - this.fieldWidget.simulateLabelClick(); - return false; +OO.ui.BookletLayout.prototype.onStackLayoutSet = function ( page ) { + var layout = this; + if ( page ) { + page.scrollElementIntoView( { complete: function () { + if ( layout.autoFocus ) { + layout.focus(); + } + } } ); + } }; /** - * Get the field. + * Focus the first input in the current page. * - * @return {OO.ui.Widget} Field widget + * If no page is selected, the first selectable page will be selected. + * If the focus is already in an element on the current page, nothing will happen. */ -OO.ui.FieldLayout.prototype.getField = function () { - return this.fieldWidget; +OO.ui.BookletLayout.prototype.focus = function () { + var $input, page = this.stackLayout.getCurrentItem(); + if ( !page && this.outlined ) { + this.selectFirstSelectablePage(); + page = this.stackLayout.getCurrentItem(); + } + if ( !page ) { + return; + } + // Only change the focus if is not already in the current page + if ( !page.$element.find( ':focus' ).length ) { + $input = page.$element.find( ':input:first' ); + if ( $input.length ) { + $input[ 0 ].focus(); + } + } }; /** - * Set the field alignment mode. + * Handle outline widget select events. * - * @private - * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline' - * @chainable + * @param {OO.ui.OptionWidget|null} item Selected item */ -OO.ui.FieldLayout.prototype.setAlignment = function ( value ) { - if ( value !== this.align ) { - // Default to 'left' - if ( [ 'left', 'right', 'top', 'inline' ].indexOf( value ) === -1 ) { - value = 'left'; - } - // Reorder elements - if ( value === 'inline' ) { - this.$body.append( this.$field, this.$label ); - } else { - this.$body.append( this.$label, this.$field ); - } - // Set classes. The following classes can be used here: - // * oo-ui-fieldLayout-align-left - // * oo-ui-fieldLayout-align-right - // * oo-ui-fieldLayout-align-top - // * oo-ui-fieldLayout-align-inline - if ( this.align ) { - this.$element.removeClass( 'oo-ui-fieldLayout-align-' + this.align ); - } - this.$element.addClass( 'oo-ui-fieldLayout-align-' + value ); - this.align = value; +OO.ui.BookletLayout.prototype.onOutlineSelectWidgetSelect = function ( item ) { + if ( item ) { + this.setPage( item.getData() ); } +}; - return this; +/** + * Check if booklet has an outline. + * + * @return {boolean} + */ +OO.ui.BookletLayout.prototype.isOutlined = function () { + return this.outlined; }; /** - * Layout made of a fieldset and optional legend. + * Check if booklet has editing controls. * - * Just add OO.ui.FieldLayout items. + * @return {boolean} + */ +OO.ui.BookletLayout.prototype.isEditable = function () { + return this.editable; +}; + +/** + * Check if booklet has a visible outline. * - * @class - * @extends OO.ui.Layout - * @mixins OO.ui.IconElement - * @mixins OO.ui.LabelElement - * @mixins OO.ui.GroupElement + * @return {boolean} + */ +OO.ui.BookletLayout.prototype.isOutlineVisible = function () { + return this.outlined && this.outlineVisible; +}; + +/** + * Hide or show the outline. * - * @constructor - * @param {Object} [config] Configuration options - * @cfg {OO.ui.FieldLayout[]} [items] Items to add + * @param {boolean} [show] Show outline, omit to invert current state + * @chainable */ -OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) { - // Configuration initialization - config = config || {}; +OO.ui.BookletLayout.prototype.toggleOutline = function ( show ) { + if ( this.outlined ) { + show = show === undefined ? !this.outlineVisible : !!show; + this.outlineVisible = show; + this.toggleMenu( show ); + } - // Parent constructor - OO.ui.FieldsetLayout.super.call( this, config ); + return this; +}; - // Mixin constructors - OO.ui.IconElement.call( this, config ); - OO.ui.LabelElement.call( this, config ); - OO.ui.GroupElement.call( this, config ); +/** + * Get the outline widget. + * + * @param {OO.ui.PageLayout} page Page to be selected + * @return {OO.ui.PageLayout|null} Closest page to another + */ +OO.ui.BookletLayout.prototype.getClosestPage = function ( page ) { + var next, prev, level, + pages = this.stackLayout.getItems(), + index = $.inArray( page, pages ); - // Initialization - this.$element - .addClass( 'oo-ui-fieldsetLayout' ) - .prepend( this.$icon, this.$label, this.$group ); - if ( $.isArray( config.items ) ) { - this.addItems( config.items ); + if ( index !== -1 ) { + next = pages[ index + 1 ]; + prev = pages[ index - 1 ]; + // Prefer adjacent pages at the same level + if ( this.outlined ) { + level = this.outlineSelectWidget.getItemFromData( page.getName() ).getLevel(); + if ( + prev && + level === this.outlineSelectWidget.getItemFromData( prev.getName() ).getLevel() + ) { + return prev; + } + if ( + next && + level === this.outlineSelectWidget.getItemFromData( next.getName() ).getLevel() + ) { + return next; + } + } } + return prev || next || null; }; -/* Setup */ - -OO.inheritClass( OO.ui.FieldsetLayout, OO.ui.Layout ); -OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.IconElement ); -OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.LabelElement ); -OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.GroupElement ); - /** - * Layout with an HTML form. - * - * @class - * @extends OO.ui.Layout + * Get the outline widget. * - * @constructor - * @param {Object} [config] Configuration options - * @cfg {string} [method] HTML form `method` attribute - * @cfg {string} [action] HTML form `action` attribute - * @cfg {string} [enctype] HTML form `enctype` attribute + * @return {OO.ui.OutlineSelectWidget|null} Outline widget, or null if booklet has no outline */ -OO.ui.FormLayout = function OoUiFormLayout( config ) { - // Configuration initialization - config = config || {}; - - // Parent constructor - OO.ui.FormLayout.super.call( this, config ); - - // Events - this.$element.on( 'submit', this.onFormSubmit.bind( this ) ); - - // Initialization - this.$element - .addClass( 'oo-ui-formLayout' ) - .attr( { - method: config.method, - action: config.action, - enctype: config.enctype - } ); +OO.ui.BookletLayout.prototype.getOutline = function () { + return this.outlineSelectWidget; }; -/* Setup */ - -OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout ); +/** + * Get the outline controls widget. If the outline is not editable, null is returned. + * + * @return {OO.ui.OutlineControlsWidget|null} The outline controls widget. + */ +OO.ui.BookletLayout.prototype.getOutlineControls = function () { + return this.outlineControlsWidget; +}; -/* Events */ +/** + * Get a page by name. + * + * @param {string} name Symbolic name of page + * @return {OO.ui.PageLayout|undefined} Page, if found + */ +OO.ui.BookletLayout.prototype.getPage = function ( name ) { + return this.pages[ name ]; +}; /** - * @event submit + * Get the current page + * + * @return {OO.ui.PageLayout|undefined} Current page, if found */ - -/* Static Properties */ - -OO.ui.FormLayout.static.tagName = 'form'; - -/* Methods */ +OO.ui.BookletLayout.prototype.getCurrentPage = function () { + var name = this.getCurrentPageName(); + return name ? this.getPage( name ) : undefined; +}; /** - * Handle form submit events. + * Get the current page name. * - * @param {jQuery.Event} e Submit event - * @fires submit + * @return {string|null} Current page name */ -OO.ui.FormLayout.prototype.onFormSubmit = function () { - this.emit( 'submit' ); - return false; +OO.ui.BookletLayout.prototype.getCurrentPageName = function () { + return this.currentPageName; }; /** - * Layout made of proportionally sized columns and rows. + * Add a page to the layout. * - * @class - * @extends OO.ui.Layout + * When pages are added with the same names as existing pages, the existing pages will be + * automatically removed before the new pages are added. * - * @constructor - * @param {OO.ui.PanelLayout[]} panels Panels in the grid - * @param {Object} [config] Configuration options - * @cfg {number[]} [widths] Widths of columns as ratios - * @cfg {number[]} [heights] Heights of rows as ratios + * @param {OO.ui.PageLayout[]} pages Pages to add + * @param {number} index Index to insert pages after + * @fires add + * @chainable */ -OO.ui.GridLayout = function OoUiGridLayout( panels, config ) { - var i, len, widths; - - // Configuration initialization - config = config || {}; - - // Parent constructor - OO.ui.GridLayout.super.call( this, config ); +OO.ui.BookletLayout.prototype.addPages = function ( pages, index ) { + var i, len, name, page, item, currentIndex, + stackLayoutPages = this.stackLayout.getItems(), + remove = [], + items = []; - // Properties - this.panels = []; - this.widths = []; - this.heights = []; + // Remove pages with same names + for ( i = 0, len = pages.length; i < len; i++ ) { + page = pages[ i ]; + name = page.getName(); - // Initialization - this.$element.addClass( 'oo-ui-gridLayout' ); - for ( i = 0, len = panels.length; i < len; i++ ) { - this.panels.push( panels[i] ); - this.$element.append( panels[i].$element ); + if ( Object.prototype.hasOwnProperty.call( this.pages, name ) ) { + // Correct the insertion index + currentIndex = $.inArray( this.pages[ name ], stackLayoutPages ); + if ( currentIndex !== -1 && currentIndex + 1 < index ) { + index--; + } + remove.push( this.pages[ name ] ); + } } - if ( config.widths || config.heights ) { - this.layout( config.widths || [ 1 ], config.heights || [ 1 ] ); - } else { - // Arrange in columns by default - widths = this.panels.map( function () { return 1; } ); - this.layout( widths, [ 1 ] ); + if ( remove.length ) { + this.removePages( remove ); } -}; -/* Setup */ + // Add new pages + for ( i = 0, len = pages.length; i < len; i++ ) { + page = pages[ i ]; + name = page.getName(); + this.pages[ page.getName() ] = page; + if ( this.outlined ) { + item = new OO.ui.OutlineOptionWidget( { data: name } ); + page.setOutlineItem( item ); + items.push( item ); + } + } -OO.inheritClass( OO.ui.GridLayout, OO.ui.Layout ); + if ( this.outlined && items.length ) { + this.outlineSelectWidget.addItems( items, index ); + this.selectFirstSelectablePage(); + } + this.stackLayout.addItems( pages, index ); + this.emit( 'add', pages, index ); -/* Events */ + return this; +}; /** - * @event layout + * Remove a page from the layout. + * + * @fires remove + * @chainable */ +OO.ui.BookletLayout.prototype.removePages = function ( pages ) { + var i, len, name, page, + items = []; -/** - * @event update - */ + for ( i = 0, len = pages.length; i < len; i++ ) { + page = pages[ i ]; + name = page.getName(); + delete this.pages[ name ]; + if ( this.outlined ) { + items.push( this.outlineSelectWidget.getItemFromData( name ) ); + page.setOutlineItem( null ); + } + } + if ( this.outlined && items.length ) { + this.outlineSelectWidget.removeItems( items ); + this.selectFirstSelectablePage(); + } + this.stackLayout.removeItems( pages ); + this.emit( 'remove', pages ); -/* Methods */ + return this; +}; /** - * Set grid dimensions. + * Clear all pages from the layout. * - * @param {number[]} widths Widths of columns as ratios - * @param {number[]} heights Heights of rows as ratios - * @fires layout - * @throws {Error} If grid is not large enough to fit all panels + * @fires remove + * @chainable */ -OO.ui.GridLayout.prototype.layout = function ( widths, heights ) { - var x, y, - xd = 0, - yd = 0, - cols = widths.length, - rows = heights.length; +OO.ui.BookletLayout.prototype.clearPages = function () { + var i, len, + pages = this.stackLayout.getItems(); - // Verify grid is big enough to fit panels - if ( cols * rows < this.panels.length ) { - throw new Error( 'Grid is not large enough to fit ' + this.panels.length + 'panels' ); + this.pages = {}; + this.currentPageName = null; + if ( this.outlined ) { + this.outlineSelectWidget.clearItems(); + for ( i = 0, len = pages.length; i < len; i++ ) { + pages[ i ].setOutlineItem( null ); + } } + this.stackLayout.clearItems(); - // Sum up denominators - for ( x = 0; x < cols; x++ ) { - xd += widths[x]; - } - for ( y = 0; y < rows; y++ ) { - yd += heights[y]; - } - // Store factors - this.widths = []; - this.heights = []; - for ( x = 0; x < cols; x++ ) { - this.widths[x] = widths[x] / xd; - } - for ( y = 0; y < rows; y++ ) { - this.heights[y] = heights[y] / yd; - } - // Synchronize view - this.update(); - this.emit( 'layout' ); + this.emit( 'remove', pages ); + + return this; }; /** - * Update panel positions and sizes. + * Set the current page by name. * - * @fires update + * @fires set + * @param {string} name Symbolic name of page */ -OO.ui.GridLayout.prototype.update = function () { - var x, y, panel, width, height, dimensions, - i = 0, - top = 0, - left = 0, - cols = this.widths.length, - rows = this.heights.length; +OO.ui.BookletLayout.prototype.setPage = function ( name ) { + var selectedItem, + $focused, + page = this.pages[ name ]; - for ( y = 0; y < rows; y++ ) { - height = this.heights[y]; - for ( x = 0; x < cols; x++ ) { - width = this.widths[x]; - panel = this.panels[i]; - dimensions = { - width: ( width * 100 ) + '%', - height: ( height * 100 ) + '%', - top: ( top * 100 ) + '%' - }; - // If RTL, reverse: - if ( OO.ui.Element.static.getDir( this.$.context ) === 'rtl' ) { - dimensions.right = ( left * 100 ) + '%'; - } else { - dimensions.left = ( left * 100 ) + '%'; + if ( name !== this.currentPageName ) { + if ( this.outlined ) { + selectedItem = this.outlineSelectWidget.getSelectedItem(); + if ( selectedItem && selectedItem.getData() !== name ) { + this.outlineSelectWidget.selectItem( this.outlineSelectWidget.getItemFromData( name ) ); } - // HACK: Work around IE bug by setting visibility: hidden; if width or height is zero - if ( width === 0 || height === 0 ) { - dimensions.visibility = 'hidden'; - } else { - dimensions.visibility = ''; + } + if ( page ) { + if ( this.currentPageName && this.pages[ this.currentPageName ] ) { + this.pages[ this.currentPageName ].setActive( false ); + // Blur anything focused if the next page doesn't have anything focusable - this + // is not needed if the next page has something focusable because once it is focused + // this blur happens automatically + if ( this.autoFocus && !page.$element.find( ':input' ).length ) { + $focused = this.pages[ this.currentPageName ].$element.find( ':focus' ); + if ( $focused.length ) { + $focused[ 0 ].blur(); + } + } } - panel.$element.css( dimensions ); - i++; - left += width; + this.currentPageName = name; + this.stackLayout.setItem( page ); + page.setActive( true ); + this.emit( 'set', page ); } - top += height; - left = 0; } - - this.emit( 'update' ); }; /** - * Get a panel at a given position. - * - * The x and y position is affected by the current grid layout. + * Select the first selectable page. * - * @param {number} x Horizontal position - * @param {number} y Vertical position - * @return {OO.ui.PanelLayout} The panel at the given position + * @chainable */ -OO.ui.GridLayout.prototype.getPanel = function ( x, y ) { - return this.panels[ ( x * this.widths.length ) + y ]; +OO.ui.BookletLayout.prototype.selectFirstSelectablePage = function () { + if ( !this.outlineSelectWidget.getSelectedItem() ) { + this.outlineSelectWidget.selectItem( this.outlineSelectWidget.getFirstSelectableItem() ); + } + + return this; }; /** @@ -7814,7 +8417,7 @@ OO.ui.StackLayout = function OoUiStackLayout( config ) { if ( this.continuous ) { this.$element.addClass( 'oo-ui-stackLayout-continuous' ); } - if ( $.isArray( config.items ) ) { + if ( Array.isArray( config.items ) ) { this.addItems( config.items ); } }; @@ -7869,11 +8472,14 @@ OO.ui.StackLayout.prototype.unsetCurrentItem = function () { * @chainable */ OO.ui.StackLayout.prototype.addItems = function ( items, index ) { + // Update the visibility + this.updateHiddenState( items, this.currentItem ); + // Mixin method OO.ui.GroupElement.prototype.addItems.call( this, items, index ); if ( !this.currentItem && items.length ) { - this.setItem( items[0] ); + this.setItem( items[ 0 ] ); } return this; @@ -7894,7 +8500,7 @@ OO.ui.StackLayout.prototype.removeItems = function ( items ) { if ( $.inArray( this.currentItem, items ) !== -1 ) { if ( this.items.length ) { - this.setItem( this.items[0] ); + this.setItem( this.items[ 0 ] ); } else { this.unsetCurrentItem(); } @@ -7931,18 +8537,10 @@ OO.ui.StackLayout.prototype.clearItems = function () { * @fires set */ OO.ui.StackLayout.prototype.setItem = function ( item ) { - var i, len; - if ( item !== this.currentItem ) { - if ( !this.continuous ) { - for ( i = 0, len = this.items.length; i < len; i++ ) { - this.items[i].$element.css( 'display', '' ); - } - } + this.updateHiddenState( this.items, item ); + if ( $.inArray( item, this.items ) !== -1 ) { - if ( !this.continuous ) { - item.$element.css( 'display', 'block' ); - } this.currentItem = item; this.emit( 'set', item ); } else { @@ -7953,6 +8551,30 @@ OO.ui.StackLayout.prototype.setItem = function ( item ) { return this; }; +/** + * Update the visibility of all items in case of non-continuous view. + * + * Ensure all items are hidden except for the selected one. + * This method does nothing when the stack is continuous. + * + * @param {OO.ui.Layout[]} items Item list iterate over + * @param {OO.ui.Layout} [selectedItem] Selected item to show + */ +OO.ui.StackLayout.prototype.updateHiddenState = function ( items, selectedItem ) { + var i, len; + + if ( !this.continuous ) { + for ( i = 0, len = items.length; i < len; i++ ) { + if ( !selectedItem || selectedItem !== items[ i ] ) { + items[ i ].$element.addClass( 'oo-ui-element-hidden' ); + } + } + if ( selectedItem ) { + selectedItem.$element.removeClass( 'oo-ui-element-hidden' ); + } + } +}; + /** * Horizontal bar layout of tools as icon buttons. * @@ -8018,7 +8640,7 @@ OO.ui.PopupToolGroup = function OoUiPopupToolGroup( toolbar, config ) { this.active = false; this.dragging = false; this.onBlurHandler = this.onBlur.bind( this ); - this.$handle = this.$( '' ); + this.$handle = $( '' ); // Events this.$handle.on( { @@ -8035,7 +8657,7 @@ OO.ui.PopupToolGroup = function OoUiPopupToolGroup( toolbar, config ) { // OO.ui.HeaderedElement mixin constructor. if ( config.header !== undefined ) { this.$group - .prepend( this.$( '' ) + .prepend( $( '' ) .addClass( 'oo-ui-popupToolGroup-header' ) .text( config.header ) ); @@ -8079,7 +8701,7 @@ OO.ui.PopupToolGroup.prototype.setDisabled = function () { */ OO.ui.PopupToolGroup.prototype.onBlur = function ( e ) { // Only deactivate when clicking outside the dropdown element - if ( this.$( e.target ).closest( '.oo-ui-popupToolGroup' )[0] !== this.$element[0] ) { + if ( $( e.target ).closest( '.oo-ui-popupToolGroup' )[ 0 ] !== this.$element[ 0 ] ) { this.setActive( false ); } }; @@ -8217,8 +8839,8 @@ OO.ui.ListToolGroup.prototype.populate = function () { this.collapsibleTools = []; for ( i = 0, len = allowCollapse.length; i < len; i++ ) { - if ( this.tools[ allowCollapse[i] ] !== undefined ) { - this.collapsibleTools.push( this.tools[ allowCollapse[i] ] ); + if ( this.tools[ allowCollapse[ i ] ] !== undefined ) { + this.collapsibleTools.push( this.tools[ allowCollapse[ i ] ] ); } } @@ -8226,14 +8848,6 @@ OO.ui.ListToolGroup.prototype.populate = function () { this.$group.append( this.getExpandCollapseTool().$element ); this.getExpandCollapseTool().toggle( this.collapsibleTools.length !== 0 ); - - // Calling jQuery's .hide() and then .show() on a detached element caches the default value of its - // 'display' attribute and restores it, and the tool uses a and can be hidden and re-shown. - // Is this a jQuery bug? http://jsfiddle.net/gtj4hu3h/ - if ( this.getExpandCollapseTool().$element.css( 'display' ) === 'inline' ) { - this.getExpandCollapseTool().$element.css( 'display', 'block' ); - } - this.updateCollapsibleState(); }; @@ -8268,7 +8882,7 @@ OO.ui.ListToolGroup.prototype.onPointerUp = function ( e ) { var ret = OO.ui.ListToolGroup.super.prototype.onPointerUp.call( this, e ); // Do not close the popup when the user wants to show more/fewer tools - if ( this.$( e.target ).closest( '.oo-ui-tool-name-more-fewer' ).length ) { + if ( $( e.target ).closest( '.oo-ui-tool-name-more-fewer' ).length ) { // Prevent the popup list from being hidden this.setActive( true ); } @@ -8284,7 +8898,7 @@ OO.ui.ListToolGroup.prototype.updateCollapsibleState = function () { .setTitle( OO.ui.msg( this.expanded ? 'ooui-toolgroup-collapse' : 'ooui-toolgroup-expand' ) ); for ( i = 0, len = this.collapsibleTools.length; i < len; i++ ) { - this.collapsibleTools[i].toggle( this.expanded ); + this.collapsibleTools[ i ].toggle( this.expanded ); } }; @@ -8335,8 +8949,8 @@ OO.ui.MenuToolGroup.prototype.onUpdateState = function () { labelTexts = []; for ( name in this.tools ) { - if ( this.tools[name].isActive() ) { - labelTexts.push( this.tools[name].getTitle() ); + if ( this.tools[ name ].isActive() ) { + labelTexts.push( this.tools[ name ].getTitle() ); } } @@ -8438,7 +9052,7 @@ OO.ui.GroupWidget.prototype.setDisabled = function ( disabled ) { // During construction, #setDisabled is called before the OO.ui.GroupElement constructor if ( this.items ) { for ( i = 0, len = this.items.length; i < len; i++ ) { - this.items[i].updateDisabled(); + this.items[ i ].updateDisabled(); } } @@ -8504,6 +9118,7 @@ OO.ui.ItemWidget.prototype.setElementGroup = function ( group ) { * * @class * @abstract + * @deprecated Use OO.ui.LookupElement instead. * * @constructor * @param {OO.ui.TextInputWidget} input Input widget @@ -8519,7 +9134,6 @@ OO.ui.LookupInputWidget = function OoUiLookupInputWidget( input, config ) { this.lookupInput = input; this.$overlay = config.$overlay || this.$element; this.lookupMenu = new OO.ui.TextInputMenuSelectWidget( this, { - $: OO.ui.Element.static.getJQuery( this.$overlay ), input: this.lookupInput, $container: config.$container } ); @@ -8718,7 +9332,7 @@ OO.ui.LookupInputWidget.prototype.getLookupMenuItems = function () { this.abortLookupRequest(); if ( Object.prototype.hasOwnProperty.call( this.lookupCache, value ) ) { - deferred.resolve( this.getLookupMenuItemsFromData( this.lookupCache[value] ) ); + deferred.resolve( this.getLookupMenuItemsFromData( this.lookupCache[ value ] ) ); } else { this.lookupInput.pushPending(); this.lookupQuery = value; @@ -8739,8 +9353,8 @@ OO.ui.LookupInputWidget.prototype.getLookupMenuItems = function () { if ( ourRequest === widget.lookupRequest ) { widget.lookupQuery = null; widget.lookupRequest = null; - widget.lookupCache[value] = widget.getLookupCacheItemFromData( data ); - deferred.resolve( widget.getLookupMenuItemsFromData( widget.lookupCache[value] ) ); + widget.lookupCache[ value ] = widget.getLookupCacheItemFromData( data ); + deferred.resolve( widget.getLookupMenuItemsFromData( widget.lookupCache[ value ] ) ); } } ) .fail( function () { @@ -8832,21 +9446,18 @@ OO.ui.OutlineControlsWidget = function OoUiOutlineControlsWidget( outline, confi // Properties this.outline = outline; - this.$movers = this.$( '
' ); + this.$movers = $( '
' ); this.upButton = new OO.ui.ButtonWidget( { - $: this.$, framed: false, icon: 'collapse', title: OO.ui.msg( 'ooui-outline-control-move-up' ) } ); this.downButton = new OO.ui.ButtonWidget( { - $: this.$, framed: false, icon: 'expand', title: OO.ui.msg( 'ooui-outline-control-move-down' ) } ); this.removeButton = new OO.ui.ButtonWidget( { - $: this.$, framed: false, icon: 'remove', title: OO.ui.msg( 'ooui-outline-control-remove' ) @@ -8904,15 +9515,15 @@ OO.ui.OutlineControlsWidget.prototype.onOutlineChange = function () { i = -1; len = items.length; while ( ++i < len ) { - if ( items[i].isMovable() ) { - firstMovable = items[i]; + if ( items[ i ].isMovable() ) { + firstMovable = items[ i ]; break; } } i = len; while ( i-- ) { - if ( items[i].isMovable() ) { - lastMovable = items[i]; + if ( items[ i ].isMovable() ) { + lastMovable = items[ i ]; break; } } @@ -8976,14 +9587,34 @@ OO.ui.ToggleWidget.prototype.setValue = function ( value ) { this.emit( 'change', value ); this.$element.toggleClass( 'oo-ui-toggleWidget-on', value ); this.$element.toggleClass( 'oo-ui-toggleWidget-off', !value ); + this.$element.attr( 'aria-checked', value.toString() ); } return this; }; /** - * Group widget for multiple related buttons. - * - * Use together with OO.ui.ButtonWidget. + * A ButtonGroupWidget groups related buttons and is used together with OO.ui.ButtonWidget and + * its subclasses. Each button in a group is addressed by a unique reference. Buttons can be added, + * removed, and cleared from the group. + * + * @example + * // Example: A ButtonGroupWidget with two buttons + * var button1 = new OO.ui.PopupButtonWidget( { + * label : 'Select a category', + * icon : 'menu', + * popup : { + * $content: $( '

List of categories...

' ), + * padded: true, + * align: 'left' + * } + * } ); + * var button2 = new OO.ui.ButtonWidget( { + * label : 'Add item' + * }); + * var buttonGroup = new OO.ui.ButtonGroupWidget( { + * items: [button1, button2] + * } ); + * $('body').append(buttonGroup.$element); * * @class * @extends OO.ui.Widget @@ -9005,7 +9636,7 @@ OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) { // Initialization this.$element.addClass( 'oo-ui-buttonGroupWidget' ); - if ( $.isArray( config.items ) ) { + if ( Array.isArray( config.items ) ) { this.addItems( config.items ); } }; @@ -9016,7 +9647,23 @@ OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget ); OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.GroupElement ); /** - * Generic widget for buttons. + * ButtonWidget is a generic widget for buttons. A wide variety of looks, + * feels, and functionality can be customized via the class’s configuration options + * and methods. Please see the [OOjs UI documentation on MediaWiki] [1] for more information + * and examples. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches + * + * @example + * // A button widget + * var button = new OO.ui.ButtonWidget( { + * label : 'Button with Icon', + * icon : 'remove', + * iconTitle : 'Remove' + * } ); + * $( 'body' ).append( button.$element ); + * + * NOTE: HTML form buttons should use the OO.ui.ButtonInputWidget class. * * @class * @extends OO.ui.Widget @@ -9026,15 +9673,18 @@ OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.GroupElement ); * @mixins OO.ui.LabelElement * @mixins OO.ui.TitledElement * @mixins OO.ui.FlaggedElement + * @mixins OO.ui.TabIndexedElement * * @constructor * @param {Object} [config] Configuration options - * @cfg {string} [href] Hyperlink to visit when clicked - * @cfg {string} [target] Target to open hyperlink in + * @cfg {string} [href] Hyperlink to visit when the button is clicked. + * @cfg {string} [target] The frame or window in which to open the hyperlink. + * @cfg {boolean} [noFollow] Search engine traversal hint (default: true) */ OO.ui.ButtonWidget = function OoUiButtonWidget( config ) { // Configuration initialization - config = config || {}; + // FIXME: The `nofollow` alias is deprecated and will be removed (T89767) + config = $.extend( { noFollow: config && config.nofollow }, config ); // Parent constructor OO.ui.ButtonWidget.super.call( this, config ); @@ -9046,18 +9696,14 @@ OO.ui.ButtonWidget = function OoUiButtonWidget( config ) { OO.ui.LabelElement.call( this, config ); OO.ui.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) ); OO.ui.FlaggedElement.call( this, config ); + OO.ui.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) ); // Properties this.href = null; this.target = null; + this.noFollow = false; this.isHyperlink = false; - // Events - this.$button.on( { - click: this.onClick.bind( this ), - keypress: this.onKeyPress.bind( this ) - } ); - // Initialization this.$button.append( this.$icon, this.$label, this.$indicator ); this.$element @@ -9065,6 +9711,7 @@ OO.ui.ButtonWidget = function OoUiButtonWidget( config ) { .append( this.$button ); this.setHref( config.href ); this.setTarget( config.target ); + this.setNoFollow( config.noFollow ); }; /* Setup */ @@ -9076,45 +9723,54 @@ OO.mixinClass( OO.ui.ButtonWidget, OO.ui.IndicatorElement ); OO.mixinClass( OO.ui.ButtonWidget, OO.ui.LabelElement ); OO.mixinClass( OO.ui.ButtonWidget, OO.ui.TitledElement ); OO.mixinClass( OO.ui.ButtonWidget, OO.ui.FlaggedElement ); +OO.mixinClass( OO.ui.ButtonWidget, OO.ui.TabIndexedElement ); -/* Events */ +/* Methods */ /** - * @event click + * @inheritdoc */ +OO.ui.ButtonWidget.prototype.onMouseDown = function ( e ) { + if ( !this.isDisabled() ) { + // Remove the tab-index while the button is down to prevent the button from stealing focus + this.$button.removeAttr( 'tabindex' ); + } -/* Methods */ + return OO.ui.ButtonElement.prototype.onMouseDown.call( this, e ); +}; /** - * Handles mouse click events. - * - * @param {jQuery.Event} e Mouse click event - * @fires click + * @inheritdoc */ -OO.ui.ButtonWidget.prototype.onClick = function () { +OO.ui.ButtonWidget.prototype.onMouseUp = function ( e ) { if ( !this.isDisabled() ) { - this.emit( 'click' ); - if ( this.isHyperlink ) { - return true; - } + // Restore the tab-index after the button is up to restore the button's accessibility + this.$button.attr( 'tabindex', this.tabIndex ); } - return false; + + return OO.ui.ButtonElement.prototype.onMouseUp.call( this, e ); }; /** - * Handles keypress events. - * - * @param {jQuery.Event} e Keypress event - * @fires click + * @inheritdoc + */ +OO.ui.ButtonWidget.prototype.onClick = function ( e ) { + var ret = OO.ui.ButtonElement.prototype.onClick.call( this, e ); + if ( this.isHyperlink ) { + return true; + } + return ret; +}; + +/** + * @inheritdoc */ OO.ui.ButtonWidget.prototype.onKeyPress = function ( e ) { - if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) { - this.emit( 'click' ); - if ( this.isHyperlink ) { - return true; - } + var ret = OO.ui.ButtonElement.prototype.onKeyPress.call( this, e ); + if ( this.isHyperlink ) { + return true; } - return false; + return ret; }; /** @@ -9135,6 +9791,15 @@ OO.ui.ButtonWidget.prototype.getTarget = function () { return this.target; }; +/** + * Get search engine traversal hint. + * + * @return {boolean} Whether search engines should avoid traversing this hyperlink + */ +OO.ui.ButtonWidget.prototype.getNoFollow = function () { + return this.noFollow; +}; + /** * Set hyperlink location. * @@ -9178,7 +9843,32 @@ OO.ui.ButtonWidget.prototype.setTarget = function ( target ) { }; /** - * Button widget that executes an action and is managed by an OO.ui.ActionSet. + * Set search engine traversal hint. + * + * @param {boolean} noFollow True if search engines should avoid traversing this hyperlink + */ +OO.ui.ButtonWidget.prototype.setNoFollow = function ( noFollow ) { + noFollow = typeof noFollow === 'boolean' ? noFollow : true; + + if ( noFollow !== this.noFollow ) { + this.noFollow = noFollow; + if ( noFollow ) { + this.$button.attr( 'rel', 'nofollow' ); + } else { + this.$button.removeAttr( 'rel' ); + } + } + + return this; +}; + +/** + * An ActionWidget is a {@link OO.ui.ButtonWidget button widget} that executes an action. + * Action widgets are used with OO.ui.ActionSet, which manages the behavior and availability + * of the actions. Please see the [OOjs UI documentation on MediaWiki] [1] for more information + * and examples. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Process_Dialogs#Action_sets * * @class * @extends OO.ui.ButtonWidget @@ -9348,9 +10038,13 @@ OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) { // Mixin constructors OO.ui.PopupElement.call( this, config ); + // Events + this.connect( this, { click: 'onAction' } ); + // Initialization this.$element .addClass( 'oo-ui-popupButtonWidget' ) + .attr( 'aria-haspopup', 'true' ) .append( this.popup.$element ); }; @@ -9362,22 +10056,10 @@ OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.PopupElement ); /* Methods */ /** - * Handles mouse click events. - * - * @param {jQuery.Event} e Mouse click event + * Handle the button action being triggered. */ -OO.ui.PopupButtonWidget.prototype.onClick = function ( e ) { - // Skip clicks within the popup - if ( $.contains( this.popup.$element[0], e.target ) ) { - return; - } - - if ( !this.isDisabled() ) { - this.popup.toggle(); - // Parent method - OO.ui.PopupButtonWidget.super.prototype.onClick.call( this ); - } - return false; +OO.ui.PopupButtonWidget.prototype.onAction = function () { + this.popup.toggle(); }; /** @@ -9401,6 +10083,9 @@ OO.ui.ToggleButtonWidget = function OoUiToggleButtonWidget( config ) { // Mixin constructors OO.ui.ToggleWidget.call( this, config ); + // Events + this.connect( this, { click: 'onAction' } ); + // Initialization this.$element.addClass( 'oo-ui-toggleButtonWidget' ); }; @@ -9413,15 +10098,10 @@ OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.ToggleWidget ); /* Methods */ /** - * @inheritdoc + * Handle the button action being triggered. */ -OO.ui.ToggleButtonWidget.prototype.onClick = function () { - if ( !this.isDisabled() ) { - this.setValue( !this.value ); - } - - // Parent method - return OO.ui.ToggleButtonWidget.super.prototype.onClick.call( this ); +OO.ui.ToggleButtonWidget.prototype.onAction = function () { + this.setValue( !this.value ); }; /** @@ -9430,6 +10110,7 @@ OO.ui.ToggleButtonWidget.prototype.onClick = function () { OO.ui.ToggleButtonWidget.prototype.setValue = function ( value ) { value = !!value; if ( value !== this.value ) { + this.$button.attr( 'aria-pressed', value.toString() ); this.setActive( value ); } @@ -9440,12 +10121,37 @@ OO.ui.ToggleButtonWidget.prototype.setValue = function ( value ) { }; /** - * Dropdown menu of options. + * DropdownWidgets are not menus themselves, rather they contain a menu of options created with + * OO.ui.MenuOptionWidget. The DropdownWidget takes care of opening and displaying the menu so that + * users can interact with it. + * + * @example + * // Example: A DropdownWidget with a menu that contains three options + * var dropDown=new OO.ui.DropdownWidget( { + * label: 'Dropdown menu: Select a menu option', + * menu: { + * items: [ + * new OO.ui.MenuOptionWidget( { + * data: 'a', + * label: 'First' + * } ), + * new OO.ui.MenuOptionWidget( { + * data: 'b', + * label: 'Second' + * } ), + * new OO.ui.MenuOptionWidget( { + * data: 'c', + * label: 'Third' + * } ) + * ] + * } + * } ); * - * Dropdown menus provide a control for accessing a menu and compose a menu within the widget, which - * can be accessed using the #getMenu method. + * $('body').append(dropDown.$element); * - * Use with OO.ui.MenuOptionWidget. + * For more information, please see the [OOjs UI documentation on MediaWiki] [1]. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options * * @class * @extends OO.ui.Widget @@ -9453,6 +10159,7 @@ OO.ui.ToggleButtonWidget.prototype.setValue = function ( value ) { * @mixins OO.ui.IndicatorElement * @mixins OO.ui.LabelElement * @mixins OO.ui.TitledElement + * @mixins OO.ui.TabIndexedElement * * @constructor * @param {Object} [config] Configuration options @@ -9465,18 +10172,24 @@ OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) { // Parent constructor OO.ui.DropdownWidget.super.call( this, config ); + // Properties (must be set before TabIndexedElement constructor call) + this.$handle = this.$( '' ); + // Mixin constructors OO.ui.IconElement.call( this, config ); OO.ui.IndicatorElement.call( this, config ); OO.ui.LabelElement.call( this, config ); OO.ui.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) ); + OO.ui.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) ); // Properties - this.menu = new OO.ui.MenuSelectWidget( $.extend( { $: this.$, widget: this }, config.menu ) ); - this.$handle = this.$( '' ); + this.menu = new OO.ui.MenuSelectWidget( $.extend( { widget: this }, config.menu ) ); // Events - this.$element.on( { click: this.onClick.bind( this ) } ); + this.$handle.on( { + click: this.onClick.bind( this ), + keypress: this.onKeyPress.bind( this ) + } ); this.menu.connect( this, { select: 'onMenuSelect' } ); // Initialization @@ -9495,6 +10208,7 @@ OO.mixinClass( OO.ui.DropdownWidget, OO.ui.IconElement ); OO.mixinClass( OO.ui.DropdownWidget, OO.ui.IndicatorElement ); OO.mixinClass( OO.ui.DropdownWidget, OO.ui.LabelElement ); OO.mixinClass( OO.ui.DropdownWidget, OO.ui.TitledElement ); +OO.mixinClass( OO.ui.DropdownWidget, OO.ui.TabIndexedElement ); /* Methods */ @@ -9510,6 +10224,7 @@ OO.ui.DropdownWidget.prototype.getMenu = function () { /** * Handles menu select events. * + * @private * @param {OO.ui.MenuOptionWidget} item Selected menu item */ OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) { @@ -9530,30 +10245,49 @@ OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) { }; /** - * Handles mouse click events. + * Handle mouse click events. * + * @private * @param {jQuery.Event} e Mouse click event */ OO.ui.DropdownWidget.prototype.onClick = function ( e ) { - // Skip clicks within the menu - if ( $.contains( this.menu.$element[0], e.target ) ) { - return; + if ( !this.isDisabled() && e.which === 1 ) { + this.menu.toggle(); } + return false; +}; - if ( !this.isDisabled() ) { - if ( this.menu.isVisible() ) { - this.menu.toggle( false ); - } else { - this.menu.toggle( true ); - } +/** + * Handle key press events. + * + * @private + * @param {jQuery.Event} e Key press event + */ +OO.ui.DropdownWidget.prototype.onKeyPress = function ( e ) { + if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) { + this.menu.toggle(); } return false; }; /** - * Icon widget. + * IconWidget is a generic widget for {@link OO.ui.IconElement icons}. In general, IconWidgets should be used with OO.ui.LabelWidget, + * which creates a label that identifies the icon’s function. See the [OOjs UI documentation on MediaWiki] [1] + * for a list of icons included in the library. + * + * @example + * // An icon widget with a label + * var myIcon = new OO.ui.IconWidget({ + * icon: 'help', + * iconTitle: 'Help' + * }); + * // Create a label. + * var iconLabel = new OO.ui.LabelWidget({ + * label: 'Help' + * }); + * $('body').append(myIcon.$element, iconLabel.$element); * - * See OO.ui.IconElement for more information. + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons * * @class * @extends OO.ui.Widget @@ -9627,12 +10361,18 @@ OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.TitledElement ); OO.ui.IndicatorWidget.static.tagName = 'span'; /** - * Base class for input widgets. + * InputWidget is the base class for all input widgets, which + * include {@link OO.ui.TextInputWidget text inputs}, {@link OO.ui.CheckboxInputWidget checkbox inputs}, + * {@link OO.ui.RadioInputWidget radio inputs}, and {@link OO.ui.ButtonInputWidget button inputs}. + * See the [OOjs UI documentation on MediaWiki] [1] for more information and examples. + * + * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs * * @abstract * @class * @extends OO.ui.Widget * @mixins OO.ui.FlaggedElement + * @mixins OO.ui.TabIndexedElement * * @constructor * @param {Object} [config] Configuration options @@ -9647,14 +10387,15 @@ OO.ui.InputWidget = function OoUiInputWidget( config ) { // Parent constructor OO.ui.InputWidget.super.call( this, config ); - // Mixin constructors - OO.ui.FlaggedElement.call( this, config ); - // Properties this.$input = this.getInputElement( config ); this.value = ''; this.inputFilter = config.inputFilter; + // Mixin constructors + OO.ui.FlaggedElement.call( this, config ); + OO.ui.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) ); + // Events this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) ); @@ -9670,6 +10411,7 @@ OO.ui.InputWidget = function OoUiInputWidget( config ) { OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget ); OO.mixinClass( OO.ui.InputWidget, OO.ui.FlaggedElement ); +OO.mixinClass( OO.ui.InputWidget, OO.ui.TabIndexedElement ); /* Events */ @@ -9683,12 +10425,15 @@ OO.mixinClass( OO.ui.InputWidget, OO.ui.FlaggedElement ); /** * Get input element. * + * Subclasses of OO.ui.InputWidget use the `config` parameter to produce different elements in + * different circumstances. The element must have a `value` property (like form elements). + * * @private - * @param {Object} [config] Configuration options + * @param {Object} config Configuration options * @return {jQuery} Input element */ OO.ui.InputWidget.prototype.getInputElement = function () { - return this.$( '' ); + return $( '' ); }; /** @@ -9712,6 +10457,12 @@ OO.ui.InputWidget.prototype.onEdit = function () { * @return {string} Input value */ OO.ui.InputWidget.prototype.getValue = function () { + // Resynchronize our internal data with DOM data. Other scripts executing on the page can modify + // it, and we won't know unless they're kind enough to trigger a 'change' event. + var value = this.$input.val(); + if ( this.value !== value ) { + this.setValue( value ); + } return this.value; }; @@ -9721,13 +10472,7 @@ OO.ui.InputWidget.prototype.getValue = function () { * @param {boolean} isRTL */ OO.ui.InputWidget.prototype.setRTL = function ( isRTL ) { - if ( isRTL ) { - this.$input.removeClass( 'oo-ui-ltr' ); - this.$input.addClass( 'oo-ui-rtl' ); - } else { - this.$input.removeClass( 'oo-ui-rtl' ); - this.$input.addClass( 'oo-ui-ltr' ); - } + this.$input.prop( 'dir', isRTL ? 'rtl' : 'ltr' ); }; /** @@ -9778,7 +10523,7 @@ OO.ui.InputWidget.prototype.simulateLabelClick = function () { if ( this.$input.is( ':checkbox,:radio' ) ) { this.$input.click(); } else if ( this.$input.is( ':input' ) ) { - this.$input[0].focus(); + this.$input[ 0 ].focus(); } } }; @@ -9800,7 +10545,7 @@ OO.ui.InputWidget.prototype.setDisabled = function ( state ) { * @chainable */ OO.ui.InputWidget.prototype.focus = function () { - this.$input[0].focus(); + this.$input[ 0 ].focus(); return this; }; @@ -9810,12 +10555,27 @@ OO.ui.InputWidget.prototype.focus = function () { * @chainable */ OO.ui.InputWidget.prototype.blur = function () { - this.$input[0].blur(); + this.$input[ 0 ].blur(); return this; }; /** - * A button that is an input widget. Intended to be used within a OO.ui.FormLayout. + * ButtonInputWidget is used to submit HTML forms and is intended to be used within + * a OO.ui.FormLayout. If you do not need the button to work with HTML forms, you probably + * want to use OO.ui.ButtonWidget instead. Button input widgets can be rendered as either an + * HTML `