Merge "Split ImagePage.php into separate classes"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Thu, 4 Feb 2016 11:48:37 +0000 (11:48 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Thu, 4 Feb 2016 11:48:37 +0000 (11:48 +0000)
220 files changed:
RELEASE-NOTES-1.27
autoload.php
composer.json
docs/hooks.txt
images/.htaccess
includes/DefaultSettings.php
includes/DerivativeRequest.php
includes/FauxRequest.php
includes/GlobalFunctions.php
includes/LinkTarget.php [new file with mode: 0644]
includes/MediaWiki.php
includes/OutputPage.php
includes/PrefixSearch.php
includes/Setup.php
includes/Title.php
includes/WebRequest.php
includes/actions/RawAction.php
includes/actions/SubmitAction.php
includes/api/ApiBase.php
includes/api/ApiCheckToken.php
includes/api/ApiCreateAccount.php
includes/api/ApiFormatBase.php
includes/api/ApiFormatJson.php
includes/api/ApiFormatPhp.php
includes/api/ApiFormatXml.php
includes/api/ApiHelp.php
includes/api/ApiLogin.php
includes/api/ApiLogout.php
includes/api/ApiMain.php
includes/api/ApiOpenSearch.php
includes/api/ApiQueryPrefixSearch.php
includes/api/ApiQueryTokens.php
includes/api/ApiTokens.php
includes/api/i18n/en.json
includes/api/i18n/it.json
includes/api/i18n/qqq.json
includes/cache/MessageCache.php
includes/context/RequestContext.php
includes/installer/MysqlUpdater.php
includes/installer/PostgresUpdater.php
includes/installer/SqliteUpdater.php
includes/libs/objectcache/BagOStuff.php
includes/libs/objectcache/CachedBagOStuff.php [new file with mode: 0644]
includes/objectcache/ObjectCacheSessionHandler.php [deleted file]
includes/parser/ParserOptions.php
includes/parser/Preprocessor_DOM.php
includes/parser/Preprocessor_Hash.php
includes/search/SearchEngine.php
includes/search/SearchSuggestion.php [new file with mode: 0644]
includes/search/SearchSuggestionSet.php [new file with mode: 0644]
includes/session/BotPasswordSessionProvider.php [new file with mode: 0644]
includes/session/CookieSessionProvider.php [new file with mode: 0644]
includes/session/ImmutableSessionProviderWithCookie.php [new file with mode: 0644]
includes/session/PHPSessionHandler.php [new file with mode: 0644]
includes/session/Session.php [new file with mode: 0644]
includes/session/SessionBackend.php [new file with mode: 0644]
includes/session/SessionId.php [new file with mode: 0644]
includes/session/SessionInfo.php [new file with mode: 0644]
includes/session/SessionManager.php [new file with mode: 0644]
includes/session/SessionManagerInterface.php [new file with mode: 0644]
includes/session/SessionProvider.php [new file with mode: 0644]
includes/session/SessionProviderInterface.php [new file with mode: 0644]
includes/session/Token.php [new file with mode: 0644]
includes/session/UserInfo.php [new file with mode: 0644]
includes/specialpage/SpecialPage.php
includes/specialpage/SpecialPageFactory.php
includes/specials/SpecialAllPages.php
includes/specials/SpecialApiSandbox.php [new file with mode: 0644]
includes/specials/SpecialBotPasswords.php [new file with mode: 0644]
includes/specials/SpecialChangeContentModel.php
includes/specials/SpecialChangePassword.php
includes/specials/SpecialFileDuplicateSearch.php
includes/specials/SpecialMovepage.php
includes/specials/SpecialPageLanguage.php
includes/specials/SpecialPrefixindex.php
includes/specials/SpecialRecentchangeslinked.php
includes/specials/SpecialUndelete.php
includes/specials/SpecialUserlogin.php
includes/specials/SpecialUserlogout.php
includes/specials/SpecialWhatlinkshere.php
includes/title/MediaWikiPageLinkRenderer.php
includes/title/MediaWikiTitleCodec.php
includes/title/PageLinkRenderer.php
includes/title/TitleFormatter.php
includes/title/TitleValue.php
includes/user/BotPassword.php [new file with mode: 0644]
includes/user/LoggedOutEditToken.php [new file with mode: 0644]
includes/user/User.php
languages/i18n/ar.json
languages/i18n/ast.json
languages/i18n/azb.json
languages/i18n/ba.json
languages/i18n/be-tarask.json
languages/i18n/bg.json
languages/i18n/bn.json
languages/i18n/ca.json
languages/i18n/ce.json
languages/i18n/ckb.json
languages/i18n/cs.json
languages/i18n/cu.json
languages/i18n/da.json
languages/i18n/de.json
languages/i18n/diq.json
languages/i18n/el.json
languages/i18n/en.json
languages/i18n/eo.json
languages/i18n/es.json
languages/i18n/et.json
languages/i18n/fa.json
languages/i18n/fi.json
languages/i18n/fr.json
languages/i18n/gl.json
languages/i18n/gsw.json
languages/i18n/gu.json
languages/i18n/he.json
languages/i18n/hi.json
languages/i18n/hr.json
languages/i18n/hu.json
languages/i18n/hy.json
languages/i18n/ia.json
languages/i18n/ilo.json
languages/i18n/is.json
languages/i18n/it.json
languages/i18n/ja.json
languages/i18n/ka.json
languages/i18n/ko.json
languages/i18n/ksh.json
languages/i18n/la.json
languages/i18n/lb.json
languages/i18n/lt.json
languages/i18n/mk.json
languages/i18n/ml.json
languages/i18n/ms.json
languages/i18n/my.json
languages/i18n/nap.json
languages/i18n/nb.json
languages/i18n/nl.json
languages/i18n/nn.json
languages/i18n/pl.json
languages/i18n/pms.json
languages/i18n/pt.json
languages/i18n/qqq.json
languages/i18n/ro.json
languages/i18n/roa-tara.json
languages/i18n/ru.json
languages/i18n/sah.json
languages/i18n/scn.json
languages/i18n/sl.json
languages/i18n/sr-ec.json
languages/i18n/sr-el.json
languages/i18n/sv.json
languages/i18n/tr.json
languages/i18n/tyv.json
languages/i18n/uk.json
languages/i18n/ur.json
languages/i18n/vi.json
languages/i18n/vo.json
languages/i18n/zh-hans.json
languages/i18n/zh-hant.json
languages/messages/MessagesEn.php
maintenance/archives/patch-bot_passwords.sql [new file with mode: 0644]
maintenance/postgres/archives/patch-bot_passwords.sql [new file with mode: 0644]
maintenance/postgres/tables.sql
maintenance/resources/update-oojs-ui.sh
maintenance/tables.sql
resources/Resources.php
resources/ResourcesOOUI.php
resources/lib/oojs-ui/oojs-ui-apex-noimages.css [deleted file]
resources/lib/oojs-ui/oojs-ui-core-apex.css [new file with mode: 0644]
resources/lib/oojs-ui/oojs-ui-core-mediawiki.css [new file with mode: 0644]
resources/lib/oojs-ui/oojs-ui-core.js [new file with mode: 0644]
resources/lib/oojs-ui/oojs-ui-mediawiki-noimages.css [deleted file]
resources/lib/oojs-ui/oojs-ui-toolbars-apex.css [new file with mode: 0644]
resources/lib/oojs-ui/oojs-ui-toolbars-mediawiki.css [new file with mode: 0644]
resources/lib/oojs-ui/oojs-ui-toolbars.js [new file with mode: 0644]
resources/lib/oojs-ui/oojs-ui-widgets-apex.css [new file with mode: 0644]
resources/lib/oojs-ui/oojs-ui-widgets-mediawiki.css [new file with mode: 0644]
resources/lib/oojs-ui/oojs-ui-widgets.js [new file with mode: 0644]
resources/lib/oojs-ui/oojs-ui-windows-apex.css [new file with mode: 0644]
resources/lib/oojs-ui/oojs-ui-windows-mediawiki.css [new file with mode: 0644]
resources/lib/oojs-ui/oojs-ui-windows.js [new file with mode: 0644]
resources/lib/oojs-ui/oojs-ui.js [deleted file]
resources/src/mediawiki.special/mediawiki.special.apisandbox.css [new file with mode: 0644]
resources/src/mediawiki.special/mediawiki.special.apisandbox.js [new file with mode: 0644]
resources/src/mediawiki.special/mediawiki.special.apisandbox.top.css [new file with mode: 0644]
resources/src/mediawiki/mediawiki.apipretty.css
tests/TestsAutoLoader.php
tests/parser/parserTests.txt
tests/phpunit/MediaWikiTestCase.php
tests/phpunit/includes/TestLogger.php [new file with mode: 0644]
tests/phpunit/includes/api/ApiCreateAccountTest.php
tests/phpunit/includes/api/ApiLoginTest.php
tests/phpunit/includes/api/ApiTestCase.php
tests/phpunit/includes/api/ApiTestCaseUpload.php
tests/phpunit/includes/context/RequestContextTest.php
tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php [new file with mode: 0644]
tests/phpunit/includes/parser/PreprocessorTest.php
tests/phpunit/includes/search/SearchEnginePrefixTest.php [new file with mode: 0644]
tests/phpunit/includes/search/SearchSuggestionSetTest.php [new file with mode: 0644]
tests/phpunit/includes/session/BotPasswordSessionProviderTest.php [new file with mode: 0644]
tests/phpunit/includes/session/CookieSessionProviderTest.php [new file with mode: 0644]
tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php [new file with mode: 0644]
tests/phpunit/includes/session/PHPSessionHandlerTest.php [new file with mode: 0644]
tests/phpunit/includes/session/SessionBackendTest.php [new file with mode: 0644]
tests/phpunit/includes/session/SessionIdTest.php [new file with mode: 0644]
tests/phpunit/includes/session/SessionInfoTest.php [new file with mode: 0644]
tests/phpunit/includes/session/SessionManagerTest.php [new file with mode: 0644]
tests/phpunit/includes/session/SessionProviderTest.php [new file with mode: 0644]
tests/phpunit/includes/session/SessionTest.php [new file with mode: 0644]
tests/phpunit/includes/session/TestBagOStuff.php [new file with mode: 0644]
tests/phpunit/includes/session/TestUtils.php [new file with mode: 0644]
tests/phpunit/includes/session/TokenTest.php [new file with mode: 0644]
tests/phpunit/includes/session/UserInfoTest.php [new file with mode: 0644]
tests/phpunit/includes/upload/UploadFromUrlTest.php
tests/phpunit/includes/user/BotPasswordTest.php [new file with mode: 0644]
tests/phpunit/includes/user/UserTest.php
tests/phpunit/mocks/session/DummySessionBackend.php [new file with mode: 0644]
tests/phpunit/mocks/session/DummySessionProvider.php [new file with mode: 0644]
tests/phpunit/phpunit.php
tests/phpunit/structure/ApiDocumentationTest.php

index f4e4815..5b9b2b8 100644 (file)
@@ -62,9 +62,41 @@ production.
   $wgSharedDB and $wgSharedTables are properly set even on the "central" wiki
   that all others are sharing from and that $wgLocalDatabases is set to the
   full list of sharing wikis on all those wikis.
+* Massive overhaul to session handling:
+** $wgSessionsInObjectCache is no longer supported and must be true, due to
+   MediaWiki\Session\SessionManager. $wgSessionHandler is similarly no longer
+   used.
+** ObjectCacheSessionHandler is removed, replaced with
+   MediaWiki\Session\PhpSessionHandler.
+** PHP session handling in general ($_SESSION, session_id(), and so on) is
+   deprecated. Use MediaWiki\Session\SessionManager instead. A new config
+   variable, $wgPHPSessionHandling, is available to cause use of $_SESSION to
+   issue a deprecation warning or to cause most PHP session handling to throw
+   exceptions.
+** Deprecated UserSetCookies hook. Session-handling extensions should generally
+   be creating a custom subclass of CookieSessionProvider. Other extensions
+   messing with cookies can no longer count on user data being saved in cookies
+   versus other methods.
+** Deprecated UserLoadFromSession hook, extensions should create a
+   MediaWiki\Session\SessionProvider.
+** The User cannot be loaded from session until after Setup.php completes.
+   Attempts to do so will be ignored and the User will remain unloaded.
+** CSRF tokens may be fetched from the MediaWiki\Session\Session, which uses
+   the MediaWiki\Session\Token class.
+* MediaWiki will now auto-create users as necessary, removing the need for
+  extensions to do so. An 'autocreateaccount' right is added to allow
+  auto-creation when 'createaccount' is not granted to all users.
+* Deprecated AuthPluginAutoCreate hook in favor of LocalUserCreated.
+* Most cookie-handling methods in User are deprecated.
 * $wgAllowAsyncCopyUploads and $CopyUploadAsyncTimeout were removed. This was an
   experimental feature that has never worked.
+* Login and createaccount tokens now vary by timestamp.
+* LoginForm::getLoginToken() and LoginForm::getCreateaccountToken()
+  return a MediaWiki\Session\Token, and tokens must be checked using that
+  class's methods.
 * $wgEnotifUseJobQ was removed and the job queue is always used.
+* The functionality of the ApiSandbox extension has been merged into core. The
+  extension should no longer be used.
 
 === New features in 1.27 ===
 * $wgDataCenterUpdateStickTTL was also added. This decides how long a user
@@ -108,6 +140,10 @@ production.
 * It is now possible to patrol file uploads (both for new files and new versions
   of existing files). Special:NewFiles has gained an option to filter by patrol
   status. This functionality can be disabled using $wgUseFilePatrol.
+* MediaWiki\Session infrastructure allows for easier use of session mechanisms
+  other than the usual cookies.
+** SessionMetadata and SessionCheckInfo hooks allow for setting and checking
+   custom session metadata.
 * Added MWGrants and associated configuration settings $wgGrantPermissions and
   $wgGrantPermissionGroups to hold configuration for authentication features
   such as OAuth that want to allow restricting the user rights a user may make
@@ -117,6 +153,7 @@ production.
    $wgMWOAuthGrantPermissionGroups.
 * Added MWRestrictions as a class to check restrictions on a WebRequest, e.g.
   to assert that the request comes from a particular IP range.
+* Added bot passwords, a rights-restricted login mechanism for API-using bots.
 * Whitelisted the following HTML attributes for all elements in wikitext:
   aria-describedby, aria-flowto, aria-label, aria-labelledby, aria-owns.
 * Removed "presentation" restriction on the HTML role attribute in wikitext.
@@ -134,6 +171,7 @@ production.
 * Added wikimedia/cldr-plural-rule-parser v1.0.0.
 * Added wikimedia/relpath v1.0.3.
 * Added wikimedia/running-stat v1.1.0.
+* Added wikimedia/php-session-serializer v1.0.3.
 
 ==== Removed and replaced external libraries ====
 
@@ -156,6 +194,9 @@ production.
 * The following response properties from action=login are deprecated, and may
   be removed in the future: lgtoken, cookieprefix, sessionid. Clients should
   handle cookies to properly manage session state.
+* action=login transparently allows login using bot passwords. Clients should
+  merely need to change the username and password used after setting up a bot
+  password.
 * action=upload no longer understands statuskey, asyncdownload or leavemessage.
 
 === Action API internal changes in 1.27 ===
index b4c31dc..4d48de0 100644 (file)
@@ -181,6 +181,7 @@ $wgAutoloadLocalClasses = array(
        'BlockListPager' => __DIR__ . '/includes/specials/SpecialBlockList.php',
        'BlockLogFormatter' => __DIR__ . '/includes/logging/BlockLogFormatter.php',
        'BmpHandler' => __DIR__ . '/includes/media/BMP.php',
+       'BotPassword' => __DIR__ . '/includes/user/BotPassword.php',
        'BrokenRedirectsPage' => __DIR__ . '/includes/specials/SpecialBrokenRedirects.php',
        'BufferingStatsdDataFactory' => __DIR__ . '/includes/libs/BufferingStatsdDataFactory.php',
        'CLIParser' => __DIR__ . '/maintenance/parse.php',
@@ -189,6 +190,7 @@ $wgAutoloadLocalClasses = array(
        'CacheHelper' => __DIR__ . '/includes/cache/CacheHelper.php',
        'CacheTime' => __DIR__ . '/includes/parser/CacheTime.php',
        'CachedAction' => __DIR__ . '/includes/actions/CachedAction.php',
+       'CachedBagOStuff' => __DIR__ . '/includes/libs/objectcache/CachedBagOStuff.php',
        'CachingSiteStore' => __DIR__ . '/includes/site/CachingSiteStore.php',
        'CapsCleanup' => __DIR__ . '/maintenance/cleanupCaps.php',
        'Category' => __DIR__ . '/includes/Category.php',
@@ -559,6 +561,7 @@ $wgAutoloadLocalClasses = array(
        'IPSet' => __DIR__ . '/includes/compat/IPSetCompat.php',
        'IPTC' => __DIR__ . '/includes/media/IPTC.php',
        'IRCColourfulRCFeedFormatter' => __DIR__ . '/includes/rcfeed/IRCColourfulRCFeedFormatter.php',
+       'LinkTarget' => __DIR__ . '/includes/LinkTarget.php',
        'IcuCollation' => __DIR__ . '/includes/Collation.php',
        'IdentityCollation' => __DIR__ . '/includes/Collation.php',
        'ImageBuilder' => __DIR__ . '/maintenance/rebuildImages.php',
@@ -720,6 +723,7 @@ $wgAutoloadLocalClasses = array(
        'LogFormatter' => __DIR__ . '/includes/logging/LogFormatter.php',
        'LogPage' => __DIR__ . '/includes/logging/LogPage.php',
        'LogPager' => __DIR__ . '/includes/logging/LogPager.php',
+       'LoggedOutEditToken' => __DIR__ . '/includes/user/LoggedOutEditToken.php',
        'LoggedUpdateMaintenance' => __DIR__ . '/maintenance/Maintenance.php',
        'LoginForm' => __DIR__ . '/includes/specials/SpecialUserlogin.php',
        'LonelyPagesPage' => __DIR__ . '/includes/specials/SpecialLonelypages.php',
@@ -786,6 +790,20 @@ $wgAutoloadLocalClasses = array(
        'MediaWiki\\Logger\\Monolog\\WikiProcessor' => __DIR__ . '/includes/debug/logger/monolog/WikiProcessor.php',
        'MediaWiki\\Logger\\NullSpi' => __DIR__ . '/includes/debug/logger/NullSpi.php',
        'MediaWiki\\Logger\\Spi' => __DIR__ . '/includes/debug/logger/Spi.php',
+       'MediaWiki\\Session\\BotPasswordSessionProvider' => __DIR__ . '/includes/session/BotPasswordSessionProvider.php',
+       'MediaWiki\\Session\\CookieSessionProvider' => __DIR__ . '/includes/session/CookieSessionProvider.php',
+       'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' => __DIR__ . '/includes/session/ImmutableSessionProviderWithCookie.php',
+       'MediaWiki\\Session\\PHPSessionHandler' => __DIR__ . '/includes/session/PHPSessionHandler.php',
+       'MediaWiki\\Session\\Session' => __DIR__ . '/includes/session/Session.php',
+       'MediaWiki\\Session\\SessionBackend' => __DIR__ . '/includes/session/SessionBackend.php',
+       'MediaWiki\\Session\\SessionId' => __DIR__ . '/includes/session/SessionId.php',
+       'MediaWiki\\Session\\SessionInfo' => __DIR__ . '/includes/session/SessionInfo.php',
+       'MediaWiki\\Session\\SessionManager' => __DIR__ . '/includes/session/SessionManager.php',
+       'MediaWiki\\Session\\SessionManagerInterface' => __DIR__ . '/includes/session/SessionManagerInterface.php',
+       'MediaWiki\\Session\\SessionProvider' => __DIR__ . '/includes/session/SessionProvider.php',
+       'MediaWiki\\Session\\SessionProviderInterface' => __DIR__ . '/includes/session/SessionProviderInterface.php',
+       'MediaWiki\\Session\\Token' => __DIR__ . '/includes/session/Token.php',
+       'MediaWiki\\Session\\UserInfo' => __DIR__ . '/includes/session/UserInfo.php',
        'MediaWiki\\Site\\MediaWikiPageNameNormalizer' => __DIR__ . '/includes/site/MediaWikiPageNameNormalizer.php',
        'MediaWiki\\Tidy\\Html5Depurate' => __DIR__ . '/includes/tidy/Html5Depurate.php',
        'MediaWiki\\Tidy\\RaggettBase' => __DIR__ . '/includes/tidy/RaggettBase.php',
@@ -870,7 +888,6 @@ $wgAutoloadLocalClasses = array(
        'ORAField' => __DIR__ . '/includes/db/DatabaseOracle.php',
        'ORAResult' => __DIR__ . '/includes/db/DatabaseOracle.php',
        'ObjectCache' => __DIR__ . '/includes/objectcache/ObjectCache.php',
-       'ObjectCacheSessionHandler' => __DIR__ . '/includes/objectcache/ObjectCacheSessionHandler.php',
        'ObjectFactory' => __DIR__ . '/includes/libs/ObjectFactory.php',
        'ObjectFileCache' => __DIR__ . '/includes/cache/ObjectFileCache.php',
        'OldChangesList' => __DIR__ . '/includes/changes/OldChangesList.php',
@@ -1119,6 +1136,8 @@ $wgAutoloadLocalClasses = array(
        'SearchResult' => __DIR__ . '/includes/search/SearchResult.php',
        'SearchResultSet' => __DIR__ . '/includes/search/SearchResultSet.php',
        'SearchSqlite' => __DIR__ . '/includes/search/SearchSqlite.php',
+       'SearchSuggestion' => __DIR__ . '/includes/search/SearchSuggestion.php',
+       'SearchSuggestionSet' => __DIR__ . '/includes/search/SearchSuggestionSet.php',
        'SearchUpdate' => __DIR__ . '/includes/deferred/SearchUpdate.php',
        'SectionProfileCallback' => __DIR__ . '/includes/profiler/SectionProfiler.php',
        'SectionProfiler' => __DIR__ . '/includes/profiler/SectionProfiler.php',
@@ -1152,10 +1171,12 @@ $wgAutoloadLocalClasses = array(
        'SpecialAllMyUploads' => __DIR__ . '/includes/specials/SpecialMyRedirectPages.php',
        'SpecialAllPages' => __DIR__ . '/includes/specials/SpecialAllPages.php',
        'SpecialApiHelp' => __DIR__ . '/includes/specials/SpecialApiHelp.php',
+       'SpecialApiSandbox' => __DIR__ . '/includes/specials/SpecialApiSandbox.php',
        'SpecialBlankpage' => __DIR__ . '/includes/specials/SpecialBlankpage.php',
        'SpecialBlock' => __DIR__ . '/includes/specials/SpecialBlock.php',
        'SpecialBlockList' => __DIR__ . '/includes/specials/SpecialBlockList.php',
        'SpecialBookSources' => __DIR__ . '/includes/specials/SpecialBooksources.php',
+       'SpecialBotPasswords' => __DIR__ . '/includes/specials/SpecialBotPasswords.php',
        'SpecialCachedPage' => __DIR__ . '/includes/specials/SpecialCachedPage.php',
        'SpecialCategories' => __DIR__ . '/includes/specials/SpecialCategories.php',
        'SpecialChangeContentModel' => __DIR__ . '/includes/specials/SpecialChangeContentModel.php',
index 68d290b..17edfbf 100644 (file)
@@ -31,6 +31,7 @@
                "wikimedia/cldr-plural-rule-parser": "1.0.0",
                "wikimedia/composer-merge-plugin": "1.3.0",
                "wikimedia/ip-set": "1.0.1",
+               "wikimedia/php-session-serializer": "1.0.3",
                "wikimedia/relpath": "1.0.3",
                "wikimedia/running-stat": "1.1.0",
                "wikimedia/utfnormal": "1.0.3",
index 24eb868..79bc5f5 100644 (file)
@@ -513,7 +513,8 @@ sites statistics information.
 'ApiQueryTokensRegisterTypes': Use this hook to add additional token types to
 action=query&meta=tokens. Note that most modules will probably be able to use
 the 'csrf' token instead of creating their own token types.
-&$salts: array( type => salt to pass to User::getEditToken() )
+&$salts: array( type => salt to pass to User::getEditToken() or array of salt
+  and key to pass to Session::getToken() )
 
 'APIQueryUsersTokens': DEPRECATED! Use ApiQueryTokensRegisterTypes instead.
 Use this hook to add custom token to list=users. Every token has an action,
@@ -741,8 +742,9 @@ viewing.
 redirect was followed.
 &$article: target article (object)
 
-'AuthPluginAutoCreate': Called when creating a local account for an user logged
-in from an external authentication method.
+'AuthPluginAutoCreate': DEPRECATED! Use the 'LocalUserCreated' hook instead.
+Called when creating a local account for an user logged in from an external
+authentication method.
 $user: User object created locally
 
 'AuthPluginSetup': Update or replace authentication plugin object ($wgAuth).
@@ -2584,6 +2586,20 @@ $targetUser: the user whom to send watchlist email notification
 $title: the page title
 $enotif: EmailNotification object
 
+'SessionCheckInfo': Validate a MediaWiki\Session\SessionInfo as it's being
+loaded from storage. Return false to prevent it from being used.
+&$reason: String rejection reason to be logged
+$info: MediaWiki\Session\SessionInfo being validated
+$request: WebRequest being loaded from
+$metadata: Array|false Metadata array for the MediaWiki\Session\Session
+$data: Array|false Data array for the MediaWiki\Session\Session
+
+'SessionMetadata': Add metadata to a session being saved.
+$backend: MediaWiki\Session\SessionBackend being saved.
+&$metadata: Array Metadata to be stored. Add new keys here.
+$requests: Array of WebRequests potentially being saved to. Generally 0-1 real
+  request and 0+ FauxRequests.
+
 'SetupAfterCache': Called in Setup.php, after cache objects are set
 
 'ShortPagesQuery': Allow extensions to modify the query used by
@@ -3307,8 +3323,9 @@ $name: user name
 $user: user object
 &$s: database query object
 
-'UserLoadFromSession': Called to authenticate users on external/environmental
-means; occurs before session is loaded.
+'UserLoadFromSession': DEPRECATED! Create a MediaWiki\Session\SessionProvider instead.
+Called to authenticate users on external/environmental means; occurs before
+session is loaded.
 $user: user object being loaded
 &$result: set this to a boolean value to abort the normal authentication
   process
@@ -3399,9 +3416,13 @@ $user: User object
 'UserSaveSettings': Called when saving user settings.
 $user: User object
 
-'UserSetCookies': Called when setting user cookies.
+'UserSetCookies': DEPRECATED! If you're trying to replace core session cookie
+handling, you want to create a subclass of MediaWiki\Session\CookieSessionProvider
+instead. Otherwise, you can no longer count on user data being saved to cookies
+versus some other mechanism.
+Called when setting user cookies.
 $user: User object
-&$session: session array, will be added to $_SESSION
+&$session: session array, will be added to the session
 &$cookies: cookies array mapping cookie name to its value
 
 'UserSetEmail': Called when changing user email address.
index 3f3d41e..4e253b6 100644 (file)
@@ -1,4 +1,4 @@
-# Protect against bug 28235
+# Protect against bug T30235
 <IfModule rewrite_module>
        RewriteEngine On
        RewriteOptions inherit
index 2b3e6e2..9c7106f 100644 (file)
@@ -2146,7 +2146,7 @@ $wgMessageCacheType = CACHE_ANYTHING;
 $wgParserCacheType = CACHE_ANYTHING;
 
 /**
- * The cache type for storing session data. Used if $wgSessionsInObjectCache is true.
+ * The cache type for storing session data.
  *
  * For available types see $wgMainCacheType.
  */
@@ -2281,30 +2281,29 @@ $wgParserCacheExpireTime = 86400;
  *
  * @deprecated since 1.20; Use $wgSessionsInObjectCache
  */
-$wgSessionsInMemcached = false;
+$wgSessionsInMemcached = true;
 
 /**
- * Store sessions in an object cache, configured by $wgSessionCacheType. This
- * can be useful to improve performance, or to avoid the locking behavior of
- * PHP's default session handler, which tends to prevent multiple requests for
- * the same user from acting concurrently.
+ * @deprecated since 1.27, session data is always stored in object cache.
  */
-$wgSessionsInObjectCache = false;
+$wgSessionsInObjectCache = true;
 
 /**
- * The expiry time to use for session storage when $wgSessionsInObjectCache is
- * enabled, in seconds.
+ * The expiry time to use for session storage, in seconds.
  */
 $wgObjectCacheSessionExpiry = 3600;
 
 /**
- * This is used for setting php's session.save_handler. In practice, you will
- * almost never need to change this ever. Other options might be 'user' or
- * 'session_mysql.' Setting to null skips setting this entirely (which might be
- * useful if you're doing cross-application sessions, see bug 11381)
+ * @deprecated since 1.27, MediaWiki\\Session\\SessionManager doesn't use PHP session storage.
  */
 $wgSessionHandler = null;
 
+/**
+ * Whether to use PHP session handling ($_SESSION and session_*() functions)
+ * @var string 'enable', 'warn', or 'disable'
+ */
+$wgPHPSessionHandling = 'enable';
+
 /**
  * If enabled, will send MemCached debugging information to $wgDebugLogFile
  */
@@ -4645,6 +4644,30 @@ $wgSecureLogin = false;
  */
 $wgAuthenticationTokenVersion = null;
 
+/**
+ * MediaWiki\Session\SessionProvider configuration.
+ *
+ * Value is an array of ObjectFactory specifications for the SessionProviders
+ * to be used. Keys in the array are ignored. Order is not significant.
+ *
+ * @since 1.27
+ */
+$wgSessionProviders = array(
+       'MediaWiki\\Session\\CookieSessionProvider' => array(
+               'class' => 'MediaWiki\\Session\\CookieSessionProvider',
+               'args' => array( array(
+                       'priority' => 30,
+                       'callUserSetCookiesHook' => true,
+               ) ),
+       ),
+       'MediaWiki\\Session\\BotPasswordSessionProvider' => array(
+               'class' => 'MediaWiki\\Session\\BotPasswordSessionProvider',
+               'args' => array( array(
+                       'priority' => 40,
+               ) ),
+       ),
+);
+
 /** @} */ # end user accounts }
 
 /************************************************************************//**
@@ -5481,6 +5504,29 @@ $wgGrantPermissionGroups = array(
        'highvolume'          => 'high-volume',
 );
 
+/**
+ * @var bool Whether to enable bot passwords
+ * @since 1.27
+ */
+$wgEnableBotPasswords = true;
+
+/**
+ * Cluster for the bot_passwords table
+ * @var string|bool If false, the normal cluster will be used
+ * @since 1.27
+ */
+$wgBotPasswordsCluster = false;
+
+/**
+ * Database name for the bot_passwords table
+ *
+ * To use a database with a table prefix, set this variable to
+ * "{$database}-{$prefix}".
+ * @var string|bool If false, the normal database will be used
+ * @since 1.27
+ */
+$wgBotPasswordsDatabase = false;
+
 /** @} */ # end of user rights settings
 
 /************************************************************************//**
index dda1358..4c149ae 100644 (file)
@@ -61,6 +61,10 @@ class DerivativeRequest extends FauxRequest {
                return $this->base->getAllHeaders();
        }
 
+       public function getSession() {
+               return $this->base->getSession();
+       }
+
        public function getSessionData( $key ) {
                return $this->base->getSessionData( $key );
        }
index 888f853..f049d2e 100644 (file)
@@ -23,6 +23,8 @@
  * @file
  */
 
+use MediaWiki\Session\SessionManager;
+
 /**
  * WebRequest clone which takes values from a provided array.
  *
@@ -30,7 +32,6 @@
  */
 class FauxRequest extends WebRequest {
        private $wasPosted = false;
-       private $session = array();
        private $requestUrl;
        protected $cookies = array();
 
@@ -38,7 +39,8 @@ class FauxRequest extends WebRequest {
         * @param array $data Array of *non*-urlencoded key => value pairs, the
         *   fake GET/POST values
         * @param bool $wasPosted Whether to treat the data as POST
-        * @param array|null $session Session array or null
+        * @param MediaWiki\\Session\\Session|array|null $session Session, session
+        *  data array, or null
         * @param string $protocol 'http' or 'https'
         * @throws MWException
         */
@@ -53,8 +55,16 @@ class FauxRequest extends WebRequest {
                        throw new MWException( "FauxRequest() got bogus data" );
                }
                $this->wasPosted = $wasPosted;
-               if ( $session ) {
-                       $this->session = $session;
+               if ( $session instanceof MediaWiki\Session\Session ) {
+                       $this->sessionId = $session->getSessionId();
+               } elseif ( is_array( $session ) ) {
+                       $mwsession = SessionManager::singleton()->getEmptySession( $this );
+                       $this->sessionId = $mwsession->getSessionId();
+                       foreach ( $session as $key => $value ) {
+                               $mwsession->set( $key, $value );
+                       }
+               } elseif ( $session !== null ) {
+                       throw new MWException( "FauxRequest() got bogus session" );
                }
                $this->protocol = $protocol;
        }
@@ -140,10 +150,6 @@ class FauxRequest extends WebRequest {
                }
        }
 
-       public function checkSessionCookie() {
-               return false;
-       }
-
        /**
         * @since 1.25
         */
@@ -186,31 +192,15 @@ class FauxRequest extends WebRequest {
        }
 
        /**
-        * @param string $key
         * @return array|null
         */
-       public function getSessionData( $key ) {
-               if ( isset( $this->session[$key] ) ) {
-                       return $this->session[$key];
+       public function getSessionArray() {
+               if ( $this->sessionId !== null ) {
+                       return iterator_to_array( $this->getSession() );
                }
                return null;
        }
 
-       /**
-        * @param string $key
-        * @param array $data
-        */
-       public function setSessionData( $key, $data ) {
-               $this->session[$key] = $data;
-       }
-
-       /**
-        * @return array|mixed|null
-        */
-       public function getSessionArray() {
-               return $this->session;
-       }
-
        /**
         * FauxRequests shouldn't depend on raw request data (but that could be implemented here)
         * @return string
index 1f9d14e..4066945 100644 (file)
@@ -26,6 +26,7 @@ if ( !defined( 'MEDIAWIKI' ) ) {
 
 use Liuggio\StatsdClient\Sender\SocketSender;
 use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\Session\SessionManager;
 
 // Hide compatibility functions from Doxygen
 /// @cond
@@ -3010,9 +3011,12 @@ function wfBaseConvert( $input, $sourceBase, $destBase, $pad = 1,
 /**
  * Check if there is sufficient entropy in php's built-in session generation
  *
+ * @deprecated since 1.27, PHP's session generation isn't used with
+ *  MediaWiki\\Session\\SessionManager
  * @return bool True = there is sufficient entropy
  */
 function wfCheckEntropy() {
+       wfDeprecated( __FUNCTION__, '1.27' );
        return (
                        ( wfIsWindows() && version_compare( PHP_VERSION, '5.3.3', '>=' ) )
                        || ini_get( 'session.entropy_file' )
@@ -3021,83 +3025,65 @@ function wfCheckEntropy() {
 }
 
 /**
- * Override session_id before session startup if php's built-in
- * session generation code is not secure.
+ * @deprecated since 1.27, PHP's session generation isn't used with
+ *  MediaWiki\\Session\\SessionManager
  */
 function wfFixSessionID() {
-       // If the cookie or session id is already set we already have a session and should abort
-       if ( isset( $_COOKIE[session_name()] ) || session_id() ) {
-               return;
-       }
-
-       // PHP's built-in session entropy is enabled if:
-       // - entropy_file is set or you're on Windows with php 5.3.3+
-       // - AND entropy_length is > 0
-       // We treat it as disabled if it doesn't have an entropy length of at least 32
-       $entropyEnabled = wfCheckEntropy();
-
-       // If built-in entropy is not enabled or not sufficient override PHP's
-       // built in session id generation code
-       if ( !$entropyEnabled ) {
-               wfDebug( __METHOD__ . ": PHP's built in entropy is disabled or not sufficient, " .
-                       "overriding session id generation using our cryptrand source.\n" );
-               session_id( MWCryptRand::generateHex( 32 ) );
-       }
+       wfDeprecated( __FUNCTION__, '1.27' );
 }
 
 /**
- * Reset the session_id
+ * Reset the session id
  *
+ * @deprecated since 1.27, use MediaWiki\\Session\\SessionManager instead
  * @since 1.22
  */
 function wfResetSessionID() {
-       global $wgCookieSecure;
-       $oldSessionId = session_id();
-       $cookieParams = session_get_cookie_params();
-       if ( wfCheckEntropy() && $wgCookieSecure == $cookieParams['secure'] ) {
-               session_regenerate_id( false );
-       } else {
-               $tmp = $_SESSION;
-               session_destroy();
-               wfSetupSession( MWCryptRand::generateHex( 32 ) );
-               $_SESSION = $tmp;
+       wfDeprecated( __FUNCTION__, '1.27' );
+       $session = SessionManager::getGlobalSession();
+       $delay = $session->delaySave();
+
+       $session->resetId();
+
+       // Make sure a session is started, since that's what the old
+       // wfResetSessionID() did.
+       if ( session_id() !== $session->getId() ) {
+               wfSetupSession( $session->getId() );
        }
-       $newSessionId = session_id();
+
+       ScopedCallback::consume( $delay );
 }
 
 /**
  * Initialise php session
  *
- * @param bool $sessionId
+ * @deprecated since 1.27, use MediaWiki\\Session\\SessionManager instead.
+ *  Generally, "using" SessionManager will be calling ->getSessionById() or
+ *  ::getGlobalSession() (depending on whether you were passing $sessionId
+ *  here), then calling $session->persist().
+ * @param bool|string $sessionId
  */
 function wfSetupSession( $sessionId = false ) {
-       global $wgSessionsInObjectCache, $wgSessionHandler;
-       global $wgCookiePath, $wgCookieDomain, $wgCookieSecure, $wgCookieHttpOnly;
+       wfDeprecated( __FUNCTION__, '1.27' );
 
-       if ( $wgSessionsInObjectCache ) {
-               ObjectCacheSessionHandler::install();
-       } elseif ( $wgSessionHandler && $wgSessionHandler != ini_get( 'session.save_handler' ) ) {
-               # Only set this if $wgSessionHandler isn't null and session.save_handler
-               # hasn't already been set to the desired value (that causes errors)
-               ini_set( 'session.save_handler', $wgSessionHandler );
+       // If they're calling this, they probably want our session management even
+       // if NO_SESSION was set for Setup.php.
+       if ( !MediaWiki\Session\PHPSessionHandler::isInstalled() ) {
+               MediaWiki\Session\PHPSessionHandler::install( SessionManager::singleton() );
        }
 
-       session_set_cookie_params(
-               0, $wgCookiePath, $wgCookieDomain, $wgCookieSecure, $wgCookieHttpOnly );
-       session_cache_limiter( 'private, must-revalidate' );
        if ( $sessionId ) {
                session_id( $sessionId );
-       } else {
-               wfFixSessionID();
        }
 
-       MediaWiki\suppressWarnings();
-       session_start();
-       MediaWiki\restoreWarnings();
+       $session = SessionManager::getGlobalSession();
+       $session->persist();
 
-       if ( $wgSessionsInObjectCache ) {
-               ObjectCacheSessionHandler::renewCurrentSession();
+       if ( session_id() !== $session->getId() ) {
+               session_id( $session->getId() );
        }
+       MediaWiki\quietCall( 'session_cache_limiter', 'private, must-revalidate' );
+       MediaWiki\quietCall( 'session_start' );
 }
 
 /**
diff --git a/includes/LinkTarget.php b/includes/LinkTarget.php
new file mode 100644 (file)
index 0000000..1ce5f32
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+
+/**
+ * @author Addshore
+ *
+ * @since 1.27
+ */
+interface LinkTarget {
+
+       /**
+        * Get the namespace index
+        *
+        * @return int Namespace index
+        */
+       public function getNamespace();
+
+       /**
+        * Get the link fragment (i.e.\ the bit after the #) in text form
+        *
+        * @return string link fragment
+        */
+       public function getFragment();
+
+       /**
+        * Get the main part with underscores
+        *
+        * @return string Main part of the link, with underscores (for use in hrf attributes)
+        */
+       public function getDBkey();
+
+       /**
+        * Returns the link in text form,
+        * without namespace prefix or fragment.
+        *
+        * This is computed from the DB key by replacing any underscores with spaces.
+        *
+        * @return string
+        */
+       public function getText();
+
+}
index 7846ca4..8385a06 100644 (file)
@@ -671,8 +671,10 @@ class MediaWiki {
                if (
                        $request->getProtocol() == 'http' &&
                        (
+                               $request->getSession()->shouldForceHTTPS() ||
+                               // Check the cookie manually, for paranoia
                                $request->getCookie( 'forceHTTPS', '' ) ||
-                               // check for prefixed version for currently logged in users
+                               // check for prefixed version that was used for a time in older MW versions
                                $request->getCookie( 'forceHTTPS' ) ||
                                // Avoid checking the user and groups unless it's enabled.
                                (
index e06fad9..3adef5b 100644 (file)
@@ -21,6 +21,7 @@
  */
 
 use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\Session\SessionManager;
 use WrappedString\WrappedString;
 
 /**
@@ -1977,11 +1978,9 @@ class OutputPage extends ContextSource {
                if ( $cookies === null ) {
                        $config = $this->getConfig();
                        $cookies = array_merge(
+                               SessionManager::singleton()->getVaryCookies(),
                                array(
-                                       $config->get( 'CookiePrefix' ) . 'Token',
-                                       $config->get( 'CookiePrefix' ) . 'LoggedOut',
-                                       "forceHTTPS",
-                                       session_name()
+                                       'forceHTTPS',
                                ),
                                $config->get( 'CacheVaryCookies' )
                        );
@@ -2033,6 +2032,9 @@ class OutputPage extends ContextSource {
         * @return string
         */
        public function getVaryHeader() {
+               foreach ( SessionManager::singleton()->getVaryHeaders() as $header => $options ) {
+                       $this->addVaryHeader( $header, $options );
+               }
                return 'Vary: ' . join( ', ', array_keys( $this->mVaryHeader ) );
        }
 
@@ -2050,6 +2052,10 @@ class OutputPage extends ContextSource {
                }
                $this->addVaryHeader( 'Cookie', $cookiesOption );
 
+               foreach ( SessionManager::singleton()->getVaryHeaders() as $header => $options ) {
+                       $this->addVaryHeader( $header, $options );
+               }
+
                $headers = array();
                foreach ( $this->mVaryHeader as $header => $option ) {
                        $newheader = $header;
@@ -2173,8 +2179,8 @@ class OutputPage extends ContextSource {
 
                if ( $this->mEnableClientCache ) {
                        if (
-                               $config->get( 'UseSquid' ) && session_id() == '' && !$this->isPrintable() &&
-                               $this->mCdnMaxage != 0 && !$this->haveCacheVaryCookies()
+                               $config->get( 'UseSquid' ) && !SessionManager::getGlobalSession()->isPersistent() &&
+                               !$this->isPrintable() && $this->mCdnMaxage != 0 && !$this->haveCacheVaryCookies()
                        ) {
                                if ( $config->get( 'UseESI' ) ) {
                                        # We'll purge the proxy cache explicitly, but require end user agents
@@ -4045,7 +4051,7 @@ class OutputPage extends ContextSource {
                        $this->getLanguage()->getDir()
                );
                $this->addModuleStyles( array(
-                       'oojs-ui.styles',
+                       'oojs-ui-core.styles',
                        'oojs-ui.styles.icons',
                        'oojs-ui.styles.indicators',
                        'oojs-ui.styles.textures',
index c6f187d..5f36cf5 100644 (file)
@@ -23,6 +23,7 @@
 /**
  * Handles searching prefixes of titles and finding any page
  * names that match. Used largely by the OpenSearch implementation.
+ * @deprecated Since 1.27, Use SearchEngine::prefixSearchSubpages or SearchEngine::completionSearch
  *
  * @ingroup Search
  */
@@ -259,14 +260,17 @@ abstract class PrefixSearch {
         * @param int $offset Number of items to skip
         * @return array Array of Title objects
         */
-       protected function defaultSearchBackend( $namespaces, $search, $limit, $offset ) {
+       public function defaultSearchBackend( $namespaces, $search, $limit, $offset ) {
                $ns = array_shift( $namespaces ); // support only one namespace
-               if ( in_array( NS_MAIN, $namespaces ) ) {
+               if ( is_null( $ns ) || in_array( NS_MAIN, $namespaces ) ) {
                        $ns = NS_MAIN; // if searching on many always default to main
                }
 
-               $t = Title::newFromText( $search, $ns );
+               if ( $ns == NS_SPECIAL ) {
+                       return $this->specialSearch( $search, $limit, $offset );
+               }
 
+               $t = Title::newFromText( $search, $ns );
                $prefix = $t ? $t->getDBkey() : '';
                $dbr = wfGetDB( DB_SLAVE );
                $res = $dbr->select( 'page',
@@ -318,6 +322,7 @@ abstract class PrefixSearch {
 
 /**
  * Performs prefix search, returning Title objects
+ * @deprecated Since 1.27, Use SearchEngine::prefixSearchSubpages or SearchEngine::completionSearch
  * @ingroup Search
  */
 class TitlePrefixSearch extends PrefixSearch {
@@ -337,6 +342,7 @@ class TitlePrefixSearch extends PrefixSearch {
 
 /**
  * Performs prefix search, returning strings
+ * @deprecated Since 1.27, Use SearchEngine::prefixSearchSubpages or SearchEngine::completionSearch
  * @ingroup Search
  */
 class StringPrefixSearch extends PrefixSearch {
index 06962c1..6c85638 100644 (file)
@@ -496,10 +496,26 @@ if ( $wgMaximalPasswordLength !== false ) {
        $wgPasswordPolicy['policies']['default']['MaximalPasswordLength'] = $wgMaximalPasswordLength;
 }
 
-// Backwards compatibility with deprecated alias
-// Must be before call to wfSetupSession()
-if ( $wgSessionsInMemcached ) {
-       $wgSessionsInObjectCache = true;
+// Backwards compatibility warning
+if ( !$wgSessionsInObjectCache && !$wgSessionsInMemcached ) {
+       wfDeprecated( '$wgSessionsInObjectCache = false', '1.27' );
+       if ( $wgSessionHandler ) {
+               wfDeprecated( '$wgSessionsHandler', '1.27' );
+       }
+       $cacheType = get_class( ObjectCache::getInstance( $wgSessionCacheType ) );
+       wfDebugLog(
+               'caches',
+               "Session data will be stored in \"$cacheType\" cache with " .
+                       "expiry $wgObjectCacheSessionExpiry seconds"
+       );
+}
+$wgSessionsInObjectCache = true;
+
+if ( $wgPHPSessionHandling !== 'enable' &&
+       $wgPHPSessionHandling !== 'warn' &&
+       $wgPHPSessionHandling !== 'disable'
+) {
+       $wgPHPSessionHandling = 'warn';
 }
 
 Profiler::instance()->scopedProfileOut( $ps_default );
@@ -656,20 +672,6 @@ Profiler::instance()->scopedProfileOut( $ps_memcached );
 // Most of the config is out, some might want to run hooks here.
 Hooks::run( 'SetupAfterCache' );
 
-$ps_session = Profiler::instance()->scopedProfileIn( $fname . '-session' );
-
-if ( !defined( 'MW_NO_SESSION' ) && !$wgCommandLineMode ) {
-       // If session.auto_start is there, we can't touch session name
-       if ( !wfIniGetBool( 'session.auto_start' ) ) {
-               session_name( $wgSessionName ? $wgSessionName : $wgCookiePrefix . '_session' );
-       }
-
-       if ( $wgRequest->checkSessionCookie() || isset( $_COOKIE[$wgCookiePrefix . 'Token'] ) ) {
-               wfSetupSession();
-       }
-}
-
-Profiler::instance()->scopedProfileOut( $ps_session );
 $ps_globals = Profiler::instance()->scopedProfileIn( $fname . '-globals' );
 
 /**
@@ -682,6 +684,66 @@ $wgContLang->initContLang();
 // Now that variant lists may be available...
 $wgRequest->interpolateTitle();
 
+if ( !is_object( $wgAuth ) ) {
+       $wgAuth = new AuthPlugin;
+       Hooks::run( 'AuthPluginSetup', array( &$wgAuth ) );
+}
+
+// Set up the session
+$ps_session = Profiler::instance()->scopedProfileIn( $fname . '-session' );
+/**
+ * @var MediaWiki\\Session\\SessionId|null $wgInitialSessionId The persistent
+ * session ID (if any) loaded at startup
+ */
+$wgInitialSessionId = null;
+if ( !defined( 'MW_NO_SESSION' ) && !$wgCommandLineMode ) {
+       // If session.auto_start is there, we can't touch session name
+       if ( $wgPHPSessionHandling !== 'disable' && !wfIniGetBool( 'session.auto_start' ) ) {
+               session_name( $wgSessionName ? $wgSessionName : $wgCookiePrefix . '_session' );
+       }
+
+       // Create the SessionManager singleton and set up our session handler
+       MediaWiki\Session\PHPSessionHandler::install(
+               MediaWiki\Session\SessionManager::singleton()
+       );
+
+       // Initialize the session
+       try {
+               $session = MediaWiki\Session\SessionManager::getGlobalSession();
+       } catch ( OverflowException $ex ) {
+               if ( isset( $ex->sessionInfos ) && count( $ex->sessionInfos ) >= 2 ) {
+                       // The exception is because the request had multiple possible
+                       // sessions tied for top priority. Report this to the user.
+                       $list = array();
+                       foreach ( $ex->sessionInfos as $info ) {
+                               $list[] = $info->getProvider()->describe( $wgContLang );
+                       }
+                       $list = $wgContLang->listToText( $list );
+                       throw new HttpError( 400,
+                               Message::newFromKey( 'sessionmanager-tie', $list )->inLanguage( $wgContLang )->plain()
+                       );
+               }
+
+               // Not the one we want, rethrow
+               throw $ex;
+       }
+
+       if ( $session->isPersistent() ) {
+               $wgInitialSessionId = $session->getSessionId();
+       }
+
+       $session->renew();
+       if ( MediaWiki\Session\PHPSessionHandler::isEnabled() &&
+               ( $session->isPersistent() || $session->shouldRememberUser() )
+       ) {
+               // Start the PHP-session for backwards compatibility
+               session_id( $session->getId() );
+               MediaWiki\quietCall( 'session_cache_limiter', 'private, must-revalidate' );
+               MediaWiki\quietCall( 'session_start' );
+       }
+}
+Profiler::instance()->scopedProfileOut( $ps_session );
+
 /**
  * @var User $wgUser
  */
@@ -702,11 +764,6 @@ $wgOut = RequestContext::getMain()->getOutput(); // BackCompat
  */
 $wgParser = new StubObject( 'wgParser', $wgParserConf['class'], array( $wgParserConf ) );
 
-if ( !is_object( $wgAuth ) ) {
-       $wgAuth = new AuthPlugin;
-       Hooks::run( 'AuthPluginSetup', array( &$wgAuth ) );
-}
-
 /**
  * @var Title $wgTitle
  */
@@ -738,6 +795,16 @@ foreach ( $wgExtensionFunctions as $func ) {
        Profiler::instance()->scopedProfileOut( $ps_ext_func );
 }
 
+// If the session user has a 0 id but a valid name, that means we need to
+// autocreate it.
+$sessionUser = MediaWiki\Session\SessionManager::getGlobalSession()->getUser();
+if ( $sessionUser->getId() === 0 && User::isValidUserName( $sessionUser->getName() ) ) {
+       $ps_autocreate = Profiler::instance()->scopedProfileIn( $fname . '-autocreate' );
+       MediaWiki\Session\SessionManager::autoCreateUser( $sessionUser );
+       Profiler::instance()->scopedProfileOut( $ps_autocreate );
+}
+unset( $sessionUser );
+
 wfDebug( "Fully initialised\n" );
 $wgFullyInitialised = true;
 
index 882b7dd..55c7179 100644 (file)
@@ -30,7 +30,7 @@
  * @note Consider using a TitleValue object instead. TitleValue is more lightweight
  *       and does not rely on global state or the database.
  */
-class Title {
+class Title implements LinkTarget {
        /** @var HashBagOStuff */
        static private $titleCache = null;
 
@@ -236,10 +236,21 @@ class Title {
         * @return Title
         */
        public static function newFromTitleValue( TitleValue $titleValue ) {
+               return self::newFromLinkTarget( $titleValue );
+       }
+
+       /**
+        * Create a new Title from a LinkTarget
+        *
+        * @param LinkTarget $linkTarget Assumed to be safe.
+        *
+        * @return Title
+        */
+       public static function newFromLinkTarget( LinkTarget $linkTarget ) {
                return self::makeTitle(
-                       $titleValue->getNamespace(),
-                       $titleValue->getText(),
-                       $titleValue->getFragment() );
+                       $linkTarget->getNamespace(),
+                       $linkTarget->getText(),
+                       $linkTarget->getFragment() );
        }
 
        /**
index 7b76592..4c4ca97 100644 (file)
@@ -23,6 +23,8 @@
  * @file
  */
 
+use MediaWiki\Session\SessionManager;
+
 /**
  * The WebRequest class encapsulates getting at data passed in the
  * URL or via a POSTed form stripping illegal input characters and
@@ -63,6 +65,13 @@ class WebRequest {
         */
        protected $protocol;
 
+       /**
+        * @var \\MediaWiki\\Session\\SessionId|null Session ID to use for this
+        *  request. We can't save the session directly due to reference cycles not
+        *  working too well (slow GC in Zend and never collected in HHVM).
+        */
+       protected $sessionId = null;
+
        public function __construct() {
                $this->requestTime = isset( $_SERVER['REQUEST_TIME_FLOAT'] )
                        ? $_SERVER['REQUEST_TIME_FLOAT'] : microtime( true );
@@ -638,18 +647,49 @@ class WebRequest {
        }
 
        /**
-        * Returns true if there is a session cookie set.
+        * Return the session for this request
+        * @since 1.27
+        * @note For performance, keep the session locally if you will be making
+        *  much use of it instead of calling this method repeatedly.
+        * @return MediaWiki\\Session\\Session
+        */
+       public function getSession() {
+               if ( $this->sessionId !== null ) {
+                       $session = SessionManager::singleton()->getSessionById( (string)$this->sessionId, true, $this );
+                       if ( $session ) {
+                               return $session;
+                       }
+               }
+
+               $session = SessionManager::singleton()->getSessionForRequest( $this );
+               $this->sessionId = $session->getSessionId();
+               return $session;
+       }
+
+       /**
+        * Set the session for this request
+        * @since 1.27
+        * @private For use by MediaWiki\\Session classes only
+        * @param MediaWiki\\Session\\SessionId $sessionId
+        */
+       public function setSessionId( MediaWiki\Session\SessionId $sessionId ) {
+               $this->sessionId = $sessionId;
+       }
+
+       /**
+        * Returns true if the request has a persistent session.
         * This does not necessarily mean that the user is logged in!
         *
-        * If you want to check for an open session, use session_id()
-        * instead; that will also tell you if the session was opened
-        * during the current request (in which case the cookie will
-        * be sent back to the client at the end of the script run).
-        *
+        * @deprecated since 1.27, use
+        *  \\MediaWiki\\Session\\SessionManager::singleton()->getPersistedSessionId()
+        *  instead.
         * @return bool
         */
        public function checkSessionCookie() {
-               return isset( $_COOKIE[session_name()] );
+               global $wgInitialSessionId;
+               wfDeprecated( __METHOD__, '1.27' );
+               return $wgInitialSessionId !== null &&
+                       $this->getSession()->getId() === (string)$wgInitialSessionId;
        }
 
        /**
@@ -907,26 +947,25 @@ class WebRequest {
        }
 
        /**
-        * Get data from $_SESSION
+        * Get data from the session
         *
-        * @param string $key Name of key in $_SESSION
+        * @note Prefer $this->getSession() instead if making multiple calls.
+        * @param string $key Name of key in the session
         * @return mixed
         */
        public function getSessionData( $key ) {
-               if ( !isset( $_SESSION[$key] ) ) {
-                       return null;
-               }
-               return $_SESSION[$key];
+               return $this->getSession()->get( $key );
        }
 
        /**
         * Set session data
         *
-        * @param string $key Name of key in $_SESSION
+        * @note Prefer $this->getSession() instead if making multiple calls.
+        * @param string $key Name of key in the session
         * @param mixed $data
         */
        public function setSessionData( $key, $data ) {
-               $_SESSION[$key] = $data;
+               return $this->getSession()->set( $key, $data );
        }
 
        /**
index 69cd7aa..b371848 100644 (file)
@@ -83,7 +83,8 @@ class RawAction extends FormlessAction {
                $response->header( 'Content-type: ' . $contentType . '; charset=UTF-8' );
                // Output may contain user-specific data;
                // vary generated content for open sessions on private wikis
-               $privateCache = !User::isEveryoneAllowed( 'read' ) && ( $smaxage == 0 || session_id() != '' );
+               $privateCache = !User::isEveryoneAllowed( 'read' ) &&
+                       ( $smaxage == 0 || MediaWiki\Session\SessionManager::getGlobalSession()->isPersistent() );
                // Don't accidentally cache cookies if user is logged in (T55032)
                $privateCache = $privateCache || $this->getUser()->isLoggedIn();
                $mode = $privateCache ? 'private' : 'public';
index fae49f6..8990b75 100644 (file)
@@ -32,10 +32,8 @@ class SubmitAction extends EditAction {
        }
 
        public function show() {
-               if ( session_id() === '' ) {
-                       // Send a cookie so anons get talk message notifications
-                       wfSetupSession();
-               }
+               // Send a cookie so anons get talk message notifications
+               MediaWiki\Session\SessionManager::getGlobalSession()->persist();
 
                parent::show();
        }
index b9163d2..02720c0 100644 (file)
@@ -1260,11 +1260,10 @@ abstract class ApiBase extends ContextSource {
                        );
                }
 
-               if ( $this->getUser()->matchEditToken(
-                       $token,
-                       $salts[$tokenType],
-                       $this->getRequest()
-               ) ) {
+               $tokenObj = ApiQueryTokens::getToken(
+                       $this->getUser(), $this->getRequest()->getSession(), $salts[$tokenType]
+               );
+               if ( $tokenObj->match( $token ) ) {
                        return true;
                }
 
index 28c6ece..dfcbaf8 100644 (file)
@@ -32,21 +32,22 @@ class ApiCheckToken extends ApiBase {
                $params = $this->extractRequestParams();
                $token = $params['token'];
                $maxage = $params['maxtokenage'];
-               $request = $this->getRequest();
                $salts = ApiQueryTokens::getTokenTypeSalts();
-               $salt = $salts[$params['type']];
 
                $res = array();
 
-               if ( $this->getUser()->matchEditToken( $token, $salt, $request, $maxage ) ) {
+               $tokenObj = ApiQueryTokens::getToken(
+                       $this->getUser(), $this->getRequest()->getSession(), $salts[$params['type']]
+               );
+               if ( $tokenObj->match( $token, $maxage ) ) {
                        $res['result'] = 'valid';
-               } elseif ( $maxage !== null && $this->getUser()->matchEditToken( $token, $salt, $request ) ) {
+               } elseif ( $maxage !== null && $tokenObj->match( $token ) ) {
                        $res['result'] = 'expired';
                } else {
                        $res['result'] = 'invalid';
                }
 
-               $ts = User::getEditTokenTimestamp( $token );
+               $ts = MediaWiki\Session\Token::getTimestamp( $token );
                if ( $ts !== null ) {
                        $mwts = new MWTimestamp();
                        $mwts->timestamp->setTimestamp( $ts );
index 1368bda..d6baf34 100644 (file)
@@ -59,10 +59,8 @@ class ApiCreateAccount extends ApiBase {
 
                $params = $this->extractRequestParams();
 
-               // Init session if necessary
-               if ( session_id() == '' ) {
-                       wfSetupSession();
-               }
+               // Make sure session is persisted
+               MediaWiki\Session\SessionManager::getGlobalSession()->persist();
 
                if ( $params['mailpassword'] && !$params['email'] ) {
                        $this->dieUsageMsg( 'noemail' );
@@ -151,8 +149,11 @@ class ApiCreateAccount extends ApiBase {
                        // Token was incorrect, so add it to result, but don't throw an exception
                        // since not having the correct token is part of the normal
                        // flow of events.
-                       $result['token'] = LoginForm::getCreateaccountToken();
+                       $result['token'] = LoginForm::getCreateaccountToken()->toString();
                        $result['result'] = 'NeedToken';
+                       $this->setWarning( 'Fetching a token via action=createaccount is deprecated. ' .
+                               'Use action=query&meta=tokens&type=createaccount instead.' );
+                       $this->logFeatureUsage( 'action=createaccount&!token' );
                } elseif ( !$status->isOK() ) {
                        // There was an error. Die now.
                        $this->dieStatus( $status );
@@ -202,7 +203,11 @@ class ApiCreateAccount extends ApiBase {
                                ApiBase::PARAM_TYPE => 'password',
                        ),
                        'domain' => null,
-                       'token' => null,
+                       'token' => array(
+                               ApiBase::PARAM_TYPE => 'string',
+                               ApiBase::PARAM_REQUIRED => false, // for BC
+                               ApiBase::PARAM_HELP_MSG => array( 'api-help-param-token', 'createaccount' ),
+                       ),
                        'email' => array(
                                ApiBase::PARAM_TYPE => 'string',
                                ApiBase::PARAM_REQUIRED => $this->getConfig()->get( 'EmailConfirmToEdit' ),
index be68310..69cedd7 100644 (file)
@@ -32,6 +32,7 @@
 abstract class ApiFormatBase extends ApiBase {
        private $mIsHtml, $mFormat, $mUnescapeAmps, $mHelp;
        private $mBuffer, $mDisabled = false;
+       private $mIsWrappedHtml = false;
        protected $mForceDefaultParams = false;
 
        /**
@@ -45,6 +46,7 @@ abstract class ApiFormatBase extends ApiBase {
                $this->mIsHtml = ( substr( $format, -2, 2 ) === 'fm' ); // ends with 'fm'
                if ( $this->mIsHtml ) {
                        $this->mFormat = substr( $format, 0, -2 ); // remove ending 'fm'
+                       $this->mIsWrappedHtml = $this->getMain()->getCheck( 'wrappedhtml' );
                } else {
                        $this->mFormat = $format;
                }
@@ -79,6 +81,15 @@ abstract class ApiFormatBase extends ApiBase {
                return $this->mIsHtml;
        }
 
+       /**
+        * Returns true when the special wrapped mode is enabled.
+        * @since 1.27
+        * @return bool
+        */
+       protected function getIsWrappedHtml() {
+               return $this->mIsWrappedHtml;
+       }
+
        /**
         * Disable the formatter.
         *
@@ -145,7 +156,9 @@ abstract class ApiFormatBase extends ApiBase {
                        return;
                }
 
-               $mime = $this->getIsHtml() ? 'text/html' : $this->getMimeType();
+               $mime = $this->getIsWrappedHtml()
+                       ? 'text/mediawiki-api-prettyprint-wrapped'
+                       : ( $this->getIsHtml() ? 'text/html' : $this->getMimeType() );
 
                // Some printers (ex. Feed) do their own header settings,
                // in which case $mime will be set to null
@@ -185,19 +198,21 @@ abstract class ApiFormatBase extends ApiBase {
                        $out->addModuleStyles( 'mediawiki.apipretty' );
                        $out->setPageTitle( $context->msg( 'api-format-title' ) );
 
-                       // When the format without suffix 'fm' is defined, there is a non-html version
-                       if ( $this->getMain()->getModuleManager()->isDefined( $lcformat, 'format' ) ) {
-                               $msg = $context->msg( 'api-format-prettyprint-header' )->params( $format, $lcformat );
-                       } else {
-                               $msg = $context->msg( 'api-format-prettyprint-header-only-html' )->params( $format );
-                       }
+                       if ( !$this->getIsWrappedHtml() ) {
+                               // When the format without suffix 'fm' is defined, there is a non-html version
+                               if ( $this->getMain()->getModuleManager()->isDefined( $lcformat, 'format' ) ) {
+                                       $msg = $context->msg( 'api-format-prettyprint-header' )->params( $format, $lcformat );
+                               } else {
+                                       $msg = $context->msg( 'api-format-prettyprint-header-only-html' )->params( $format );
+                               }
 
-                       $header = $msg->parseAsBlock();
-                       $out->addHTML(
-                               Html::rawElement( 'div', array( 'class' => 'api-pretty-header' ),
-                                       ApiHelp::fixHelpLinks( $header )
-                               )
-                       );
+                               $header = $msg->parseAsBlock();
+                               $out->addHTML(
+                                       Html::rawElement( 'div', array( 'class' => 'api-pretty-header' ),
+                                               ApiHelp::fixHelpLinks( $header )
+                                       )
+                               );
+                       }
 
                        if ( Hooks::run( 'ApiFormatHighlight', array( $context, $result, $mime, $format ) ) ) {
                                $out->addHTML(
@@ -205,10 +220,38 @@ abstract class ApiFormatBase extends ApiBase {
                                );
                        }
 
-                       // API handles its own clickjacking protection.
-                       // Note, that $wgBreakFrames will still override $wgApiFrameOptions for format mode.
-                       $out->allowClickjacking();
-                       $out->output();
+                       if ( $this->getIsWrappedHtml() ) {
+                               // This is a special output mode mainly intended for ApiSandbox use
+                               $time = microtime( true ) - $this->getConfig()->get( 'RequestTime' );
+                               $json = FormatJson::encode(
+                                       array(
+                                               'html' => $out->getHTML(),
+                                               'modules' => array_values( array_unique( array_merge(
+                                                       $out->getModules(),
+                                                       $out->getModuleScripts(),
+                                                       $out->getModuleStyles()
+                                               ) ) ),
+                                               'time' => round( $time * 1000 ),
+                                       ),
+                                       false, FormatJson::ALL_OK
+                               );
+
+                               // Bug 66776: wfMangleFlashPolicy() is needed to avoid a nasty bug in
+                               // Flash, but what it does isn't friendly for the API, so we need to
+                               // work around it.
+                               if ( preg_match( '/\<\s*cross-domain-policy\s*\>/i', $json ) ) {
+                                       $json = preg_replace(
+                                               '/\<(\s*cross-domain-policy\s*)\>/i', '\\u003C$1\\u003E', $json
+                                       );
+                               }
+
+                               echo $json;
+                       } else {
+                               // API handles its own clickjacking protection.
+                               // Note, that $wgBreakFrames will still override $wgApiFrameOptions for format mode.
+                               $out->allowClickjacking();
+                               $out->output();
+                       }
                } else {
                        // For non-HTML output, clear all errors that might have been
                        // displayed if display_errors=On
@@ -234,6 +277,18 @@ abstract class ApiFormatBase extends ApiBase {
                return $this->mBuffer;
        }
 
+       public function getAllowedParams() {
+               $ret = array();
+               if ( $this->getIsHtml() ) {
+                       $ret['wrappedhtml'] = array(
+                               ApiBase::PARAM_DFLT => false,
+                               ApiBase::PARAM_HELP_MSG => 'apihelp-format-param-wrappedhtml',
+
+                       );
+               }
+               return $ret;
+       }
+
        protected function getExamplesMessages() {
                return array(
                        'action=query&meta=siteinfo&siprop=namespaces&format=' . $this->getModuleName()
index a319be3..1566a0f 100644 (file)
@@ -121,10 +121,10 @@ class ApiFormatJson extends ApiFormatBase {
 
        public function getAllowedParams() {
                if ( $this->isRaw ) {
-                       return array();
+                       return parent::getAllowedParams();
                }
 
-               $ret = array(
+               $ret = parent::getAllowedParams() + array(
                        'callback' => array(
                                ApiBase::PARAM_HELP_MSG => 'apihelp-json-param-callback',
                        ),
index df9d581..f5f2504 100644 (file)
@@ -78,7 +78,7 @@ class ApiFormatPhp extends ApiFormatBase {
        }
 
        public function getAllowedParams() {
-               $ret = array(
+               $ret = parent::getAllowedParams() + array(
                        'formatversion' => array(
                                ApiBase::PARAM_TYPE => array( 1, 2, 'latest' ),
                                ApiBase::PARAM_DFLT => 1,
index e8ad387..b4a478c 100644 (file)
@@ -288,7 +288,7 @@ class ApiFormatXml extends ApiFormatBase {
        }
 
        public function getAllowedParams() {
-               return array(
+               return parent::getAllowedParams() + array(
                        'xslt' => array(
                                ApiBase::PARAM_HELP_MSG => 'apihelp-xml-param-xslt',
                        ),
index bbea20b..ecd6eb6 100644 (file)
@@ -690,9 +690,12 @@ class ApiHelp extends ApiBase {
                                        ) );
 
                                        $link = wfAppendQuery( wfScript( 'api' ), $qs );
+                                       $sandbox = SpecialPage::getTitleFor( 'ApiSandbox' )->getLocalURL() . '#' . $qs;
                                        $help['examples'] .= Html::rawElement( 'dt', null, $msg->parse() );
                                        $help['examples'] .= Html::rawElement( 'dd', null,
-                                               Html::element( 'a', array( 'href' => $link ), "api.php?$qs" )
+                                               Html::element( 'a', array( 'href' => $link ), "api.php?$qs" ) . ' ' .
+                                               Html::rawElement( 'a', array( 'href' => $sandbox ),
+                                                       $context->msg( 'api-help-open-in-apisandbox' )->parse() )
                                        );
                                }
                                $help['examples'] .= Html::closeElement( 'dl' );
index eb376d3..03cd666 100644 (file)
@@ -24,6 +24,7 @@
  *
  * @file
  */
+
 use MediaWiki\Logger\LoggerFactory;
 
 /**
@@ -62,26 +63,69 @@ class ApiLogin extends ApiBase {
 
                $result = array();
 
-               // Init session if necessary
-               if ( session_id() == '' ) {
-                       wfSetupSession();
+               // Make sure session is persisted
+               $session = MediaWiki\Session\SessionManager::getGlobalSession();
+               $session->persist();
+
+               // Make sure it's possible to log in
+               if ( !$session->canSetUser() ) {
+                       $this->getResult()->addValue( null, 'login', array(
+                               'result' => 'Aborted',
+                               'reason' => 'Cannot log in when using ' .
+                                       $session->getProvider()->describe( Language::factory( 'en' ) ),
+                       ) );
+
+                       return;
                }
 
+               $authRes = false;
                $context = new DerivativeContext( $this->getContext() );
-               $context->setRequest( new DerivativeRequest(
-                       $this->getContext()->getRequest(),
-                       array(
-                               'wpName' => $params['name'],
-                               'wpPassword' => $params['password'],
-                               'wpDomain' => $params['domain'],
-                               'wpLoginToken' => $params['token'],
-                               'wpRemember' => ''
-                       )
-               ) );
-               $loginForm = new LoginForm();
-               $loginForm->setContext( $context );
+               $loginType = 'N/A';
+
+               // Check login token
+               $token = LoginForm::getLoginToken();
+               if ( $token->wasNew() || !$params['token'] ) {
+                       $authRes = LoginForm::NEED_TOKEN;
+               } elseif ( !$token->match( $params['token'] ) ) {
+                       $authRes = LoginForm::WRONG_TOKEN;
+               }
+
+               // Try bot passwords
+               if ( $authRes === false && $this->getConfig()->get( 'EnableBotPasswords' ) &&
+                       strpos( $params['name'], BotPassword::getSeparator() ) !== false
+               ) {
+                       $status = BotPassword::login(
+                               $params['name'], $params['password'], $this->getRequest()
+                       );
+                       if ( $status->isOk() ) {
+                               $session = $status->getValue();
+                               $authRes = LoginForm::SUCCESS;
+                               $loginType = 'BotPassword';
+                       } else {
+                               LoggerFactory::getInstance( 'authmanager' )->info(
+                                       'BotPassword login failed: ' . $status->getWikiText()
+                               );
+                       }
+               }
+
+               // Normal login
+               if ( $authRes === false ) {
+                       $context->setRequest( new DerivativeRequest(
+                               $this->getContext()->getRequest(),
+                               array(
+                                       'wpName' => $params['name'],
+                                       'wpPassword' => $params['password'],
+                                       'wpDomain' => $params['domain'],
+                                       'wpLoginToken' => $params['token'],
+                                       'wpRemember' => ''
+                               )
+                       ) );
+                       $loginForm = new LoginForm();
+                       $loginForm->setContext( $context );
+                       $authRes = $loginForm->authenticateUserData();
+                       $loginType = 'LoginForm';
+               }
 
-               $authRes = $loginForm->authenticateUserData();
                switch ( $authRes ) {
                        case LoginForm::SUCCESS:
                                $user = $context->getUser();
@@ -107,16 +151,19 @@ class ApiLogin extends ApiBase {
                                // SessionManager/AuthManager are *really* going to break it.
                                $result['lgtoken'] = $user->getToken();
                                $result['cookieprefix'] = $this->getConfig()->get( 'CookiePrefix' );
-                               $result['sessionid'] = session_id();
+                               $result['sessionid'] = $session->getId();
                                break;
 
                        case LoginForm::NEED_TOKEN:
                                $result['result'] = 'NeedToken';
-                               $result['token'] = $loginForm->getLoginToken();
+                               $result['token'] = LoginForm::getLoginToken()->toString();
+                               $this->setWarning( 'Fetching a token via action=login is deprecated. ' .
+                                  'Use action=query&meta=tokens&type=login instead.' );
+                               $this->logFeatureUsage( 'action=login&!lgtoken' );
 
                                // @todo: See above about deprecation
                                $result['cookieprefix'] = $this->getConfig()->get( 'CookiePrefix' );
-                               $result['sessionid'] = session_id();
+                               $result['sessionid'] = $session->getId();
                                break;
 
                        case LoginForm::WRONG_TOKEN:
@@ -187,6 +234,7 @@ class ApiLogin extends ApiBase {
                LoggerFactory::getInstance( 'authmanager' )->info( 'Login attempt', array(
                        'event' => 'login',
                        'successful' => $authRes === LoginForm::SUCCESS,
+                       'loginType' => $loginType,
                        'status' => LoginForm::$statusCodes[$authRes],
                ) );
        }
@@ -206,7 +254,11 @@ class ApiLogin extends ApiBase {
                                ApiBase::PARAM_TYPE => 'password',
                        ),
                        'domain' => null,
-                       'token' => null,
+                       'token' => array(
+                               ApiBase::PARAM_TYPE => 'string',
+                               ApiBase::PARAM_REQUIRED => false, // for BC
+                               ApiBase::PARAM_HELP_MSG => array( 'api-help-param-token', 'login' ),
+                       ),
                );
        }
 
index bf0ca9c..b40f5a3 100644 (file)
 class ApiLogout extends ApiBase {
 
        public function execute() {
+               // Make sure it's possible to log out
+               $session = MediaWiki\Session\SessionManager::getGlobalSession();
+               if ( !$session->canSetUser() ) {
+                       $this->dieUsage(
+                               'Cannot log out when using ' .
+                                       $session->getProvider()->describe( Language::factory( 'en' ) ),
+                               'cannotlogout'
+                       );
+               }
+
                $user = $this->getUser();
                $oldName = $user->getName();
                $user->logout();
index f6f4d20..458fd18 100644 (file)
@@ -769,7 +769,7 @@ class ApiMain extends ApiBase {
                                        return;
                                }
                                // Logged out, send normal public headers below
-                       } elseif ( session_id() != '' ) {
+                       } elseif ( MediaWiki\Session\SessionManager::getGlobalSession()->isPersistent() ) {
                                // Logged in or otherwise has session (e.g. anonymous users who have edited)
                                // Mark request private
                                $response->header( "Cache-Control: $privateCache" );
@@ -1246,7 +1246,7 @@ class ApiMain extends ApiBase {
                }
 
                if ( $request->getProtocol() === 'http' && (
-                       $request->getCookie( 'forceHTTPS', '' ) ||
+                       $request->getSession()->shouldForceHTTPS() ||
                        ( $this->getUser()->isLoggedIn() &&
                                $this->getUser()->requiresHTTPS() )
                ) ) {
index 5ce43cc..ff5707e 100644 (file)
@@ -123,9 +123,12 @@ class ApiOpenSearch extends ApiBase {
         * @param array &$results Put results here. Keys have to be integers.
         */
        protected function search( $search, $limit, $namespaces, $resolveRedir, &$results ) {
-               // Find matching titles as Title objects
-               $searcher = new TitlePrefixSearch;
-               $titles = $searcher->searchWithVariants( $search, $limit, $namespaces );
+
+               $searchEngine = SearchEngine::create();
+               $searchEngine->setLimitOffset( $limit );
+               $searchEngine->setNamespaces( $namespaces );
+               $titles = $searchEngine->extractTitles( $searchEngine->completionSearchWithVariants( $search ) );
+
                if ( !$titles ) {
                        return;
                }
index 25ff07c..1dac740 100644 (file)
@@ -45,8 +45,11 @@ class ApiQueryPrefixSearch extends ApiQueryGeneratorBase {
                $namespaces = $params['namespace'];
                $offset = $params['offset'];
 
-               $searcher = new TitlePrefixSearch;
-               $titles = $searcher->searchWithVariants( $search, $limit + 1, $namespaces, $offset );
+               $searchEngine = SearchEngine::create();
+               $searchEngine->setLimitOffset( $limit + 1, $offset );
+               $searchEngine->setNamespaces( $namespaces );
+               $titles = $searchEngine->extractTitles( $searchEngine->completionSearchWithVariants( $search ) );
+
                if ( $resultPageSet ) {
                        $resultPageSet->setRedirectMergePolicy( function( array $current, array $new ) {
                                if ( !isset( $current['index'] ) || $new['index'] < $current['index'] ) {
index f887664..3f3464b 100644 (file)
@@ -44,16 +44,24 @@ class ApiQueryTokens extends ApiQueryBase {
                        return;
                }
 
+               $user = $this->getUser();
+               $session = $this->getRequest()->getSession();
                $salts = self::getTokenTypeSalts();
                foreach ( $params['type'] as $type ) {
-                       $salt = $salts[$type];
-                       $val = $this->getUser()->getEditToken( $salt, $this->getRequest() );
-                       $res[$type . 'token'] = $val;
+                       $res[$type . 'token'] = self::getToken( $user, $session, $salts[$type] )->toString();
                }
 
                $this->getResult()->addValue( 'query', $this->getModuleName(), $res );
        }
 
+       /**
+        * Get the salts for known token types
+        * @return (string|array)[] Returning a string will use that as the salt
+        *  for User::getEditTokenObject() to fetch the token, which will give a
+        *  LoggedOutEditToken (always "+\\") for anonymous users. Returning an
+        *  array will use it as parameters to MediaWiki\\Session\\Session::getToken(),
+        *  which will always return a full token even for anonymous users.
+        */
        public static function getTokenTypeSalts() {
                static $salts = null;
                if ( !$salts ) {
@@ -63,6 +71,8 @@ class ApiQueryTokens extends ApiQueryBase {
                                'patrol' => 'patrol',
                                'rollback' => 'rollback',
                                'userrights' => 'userrights',
+                               'login' => array( '', 'login' ),
+                               'createaccount' => array( '', 'createaccount' ),
                        );
                        Hooks::run( 'ApiQueryTokensRegisterTypes', array( &$salts ) );
                        ksort( $salts );
@@ -71,6 +81,27 @@ class ApiQueryTokens extends ApiQueryBase {
                return $salts;
        }
 
+       /**
+        * Get a token from a salt
+        * @param User $user
+        * @param MediaWiki\\Session\\Session $session
+        * @param string|array $salt A string will be used as the salt for
+        *  User::getEditTokenObject() to fetch the token, which will give a
+        *  LoggedOutEditToken (always "+\\") for anonymous users. An array will
+        *  be used as parameters to MediaWiki\\Session\\Session::getToken(), which
+        *  will always return a full token even for anonymous users. An array will
+        *  also persist the session.
+        * @return MediaWiki\\Session\\Token
+        */
+       public static function getToken( User $user, MediaWiki\Session\Session $session, $salt ) {
+               if ( is_array( $salt ) ) {
+                       $session->persist();
+                       return call_user_func_array( array( $session, 'getToken' ), $salt );
+               } else {
+                       return $user->getEditTokenObject( $salt, $session->getRequest() );
+               }
+       }
+
        public function getAllowedParams() {
                return array(
                        'type' => array(
@@ -90,6 +121,11 @@ class ApiQueryTokens extends ApiQueryBase {
                );
        }
 
+       public function isReadMode() {
+               // So login tokens can be fetched on private wikis
+               return false;
+       }
+
        public function getCacheMode( $params ) {
                return 'private';
        }
index f92526d..c10c938 100644 (file)
@@ -81,7 +81,7 @@ class ApiTokens extends ApiBase {
                foreach ( ApiQueryTokens::getTokenTypeSalts() as $name => $salt ) {
                        if ( !isset( $types[$name] ) ) {
                                $types[$name] = function () use ( $salt, $user, $request ) {
-                                       return $user->getEditToken( $salt, $request );
+                                       return ApiQueryTokens::getToken( $user, $request->getSession(), $salt )->toString();
                                };
                        }
                }
index 1af53fa..a1b303f 100644 (file)
@@ -6,7 +6,7 @@
                ]
        },
 
-       "apihelp-main-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:API:Main_page|Documentation]]\n* [[mw:API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Mailing list]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API Announcements]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bugs & requests]\n</div>\n<strong>Status:</strong> All features shown on this page should be working, but the API is still in active development, and may change at any time. Subscribe to [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ the mediawiki-api-announce mailing list] for notice of updates.\n\n<strong>Erroneous requests:</strong> When erroneous requests are sent to the API, an HTTP header will be sent with the key \"MediaWiki-API-Error\" and then both the value of the header and the error code sent back will be set to the same value. For more information see [[mw:API:Errors_and_warnings|API: Errors and warnings]].",
+       "apihelp-main-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:API:Main_page|Documentation]]\n* [[mw:API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Mailing list]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API Announcements]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bugs & requests]\n</div>\n<strong>Status:</strong> All features shown on this page should be working, but the API is still in active development, and may change at any time. Subscribe to [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ the mediawiki-api-announce mailing list] for notice of updates.\n\n<strong>Erroneous requests:</strong> When erroneous requests are sent to the API, an HTTP header will be sent with the key \"MediaWiki-API-Error\" and then both the value of the header and the error code sent back will be set to the same value. For more information see [[mw:API:Errors_and_warnings|API: Errors and warnings]].\n\n<strong>Testing:</strong> For ease of testing API requests, see [[Special:ApiSandbox]].",
        "apihelp-main-param-action": "Which action to perform.",
        "apihelp-main-param-format": "The format of the output.",
        "apihelp-main-param-maxlag": "Maximum lag can be used when MediaWiki is installed on a database replicated cluster. To save actions causing any more site replication lag, this parameter can make the client wait until the replication lag is less than the specified value. In case of excessive lag, error code <samp>maxlag</samp> is returned with a message like <samp>Waiting for $host: $lag seconds lagged</samp>.<br />See [[mw:Manual:Maxlag_parameter|Manual: Maxlag parameter]] for more information.",
        "apihelp-watch-example-generator": "Watch the first few pages in the main namespace.",
 
        "apihelp-format-example-generic": "Return the query result in the $1 format.",
+       "apihelp-format-param-wrappedhtml": "Return the pretty-printed HTML and associated ResourceLoader modules as a JSON object.",
        "apihelp-json-description": "Output data in JSON format.",
        "apihelp-json-param-callback": "If specified, wraps the output into a given function call. For safety, all user-specific data will be restricted.",
        "apihelp-json-param-utf8": "If specified, encodes most (but not all) non-ASCII characters as UTF-8 instead of replacing them with hexadecimal escape sequences. Default when <var>formatversion</var> is not <kbd>1</kbd>.",
        "api-help-permissions": "{{PLURAL:$1|Permission|Permissions}}:",
        "api-help-permissions-granted-to": "{{PLURAL:$1|Granted to}}: $2",
        "api-help-right-apihighlimits": "Use higher limits in API queries (slow queries: $1; fast queries: $2). The limits for slow queries also apply to multivalue parameters.",
+       "api-help-open-in-apisandbox": "<small>[open in sandbox]</small>",
 
        "api-credits-header": "Credits",
        "api-credits": "API developers:\n* Yuri Astrakhan (creator, lead developer Sep 2006–Sep 2007)\n* Roan Kattouw (lead developer Sep 2007–2009)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Brad Jorsch (lead developer 2013–present)\n\nPlease send your comments, suggestions and questions to mediawiki-api@lists.wikimedia.org\nor file a bug report at https://phabricator.wikimedia.org/."
index dd3c80c..b7bf22f 100644 (file)
        "apihelp-query+allrevisions-example-ns-main": "Elenca solo le prime 50 versioni nel namespace principale.",
        "apihelp-query+mystashedfiles-param-prop": "Quali proprietà recuperare per il file.",
        "apihelp-query+mystashedfiles-paramvalue-prop-size": "Recupera la dimensione del file e le dimensioni dell'immagine.",
+       "apihelp-query+mystashedfiles-paramvalue-prop-type": "Recupera il tipo MIME del file e il tipo media.",
        "apihelp-query+mystashedfiles-param-limit": "Quanti file restituire.",
        "apihelp-query+alltransclusions-paramvalue-prop-title": "Aggiunge il titolo dell'inclusione.",
        "apihelp-query+alltransclusions-param-limit": "Quanti elementi totali restituire.",
index 4d4614c..e3354aa 100644 (file)
        "apihelp-watch-example-unwatch": "{{doc-apihelp-example|watch}}",
        "apihelp-watch-example-generator": "{{doc-apihelp-example|watch}}",
        "apihelp-format-example-generic": "{{doc-apihelp-example|format|params=* $1 - Format name|paramstart=2|noseealso=1}}",
+       "apihelp-format-param-wrappedhtml": "{{doc-apihelp-param|format|wrappedhtml|description=the \"wrappedhtml\" parameter in pretty-printing format modules}}",
        "apihelp-json-description": "{{doc-apihelp-description|json|seealso=* {{msg-mw|apihelp-jsonfm-description}}}}",
        "apihelp-json-param-callback": "{{doc-apihelp-param|json|callback}}",
        "apihelp-json-param-utf8": "{{doc-apihelp-param|json|utf8}}",
        "api-help-permissions": "Label for the \"permissions\" section in the main module's help output.\n\nParameters:\n* $1 - Number of permissions displayed\n{{Identical|Permission}}",
        "api-help-permissions-granted-to": "Used to introduce the list of groups each permission is assigned to.\n\nParameters:\n* $1 - Number of groups\n* $2 - List of group names, comma-separated",
        "api-help-right-apihighlimits": "{{technical}}{{doc-right|apihighlimits|prefix=api-help}}\nThis message is used instead of {{msg-mw|right-apihighlimits}} in the API help to display the actual limits.\n\nParameters:\n* $1 - Limit for slow queries\n* $2 - Limit for fast queries",
+       "api-help-open-in-apisandbox": "Text for the link to open an API example in [[Special:ApiSandbox]].",
        "api-credits-header": "Header for the API credits section in the API help output\n{{Identical|Credit}}",
        "api-credits": "API credits text, displayed in the API help output"
 }
index 24df574..2fae4e3 100644 (file)
@@ -168,7 +168,18 @@ class MessageCache {
         * @return ParserOptions
         */
        function getParserOptions() {
+               global $wgUser;
+
                if ( !$this->mParserOptions ) {
+                       if ( !$wgUser->isSafeToLoad() ) {
+                               // $wgUser isn't unstubbable yet, so don't try to get a
+                               // ParserOptions for it. And don't cache this ParserOptions
+                               // either.
+                               $po = ParserOptions::newFromAnon();
+                               $po->setEditSection( false );
+                               return $po;
+                       }
+
                        $this->mParserOptions = new ParserOptions;
                        $this->mParserOptions->setEditSection( false );
                }
index 36c644a..73e11b5 100644 (file)
@@ -510,10 +510,11 @@ class RequestContext implements IContextSource, MutableContext {
         * @since 1.21
         */
        public function exportSession() {
+               $session = MediaWiki\Session\SessionManager::getGlobalSession();
                return array(
                        'ip' => $this->getRequest()->getIP(),
                        'headers' => $this->getRequest()->getAllHeaders(),
-                       'sessionId' => session_id(),
+                       'sessionId' => $session->isPersistent() ? $session->getId() : '',
                        'userId' => $this->getUser()->getId()
                );
        }
@@ -541,7 +542,9 @@ class RequestContext implements IContextSource, MutableContext {
         * @since 1.21
         */
        public static function importScopedSession( array $params ) {
-               if ( session_id() != '' && strlen( $params['sessionId'] ) ) {
+               if ( strlen( $params['sessionId'] ) &&
+                       MediaWiki\Session\SessionManager::getGlobalSession()->isPersistent()
+               ) {
                        // Sanity check to avoid sending random cookies for the wrong users.
                        // This method should only called by CLI scripts or by HTTP job runners.
                        throw new MWException( "Sessions can only be imported when none is active." );
@@ -563,23 +566,39 @@ class RequestContext implements IContextSource, MutableContext {
                        global $wgRequest, $wgUser;
 
                        $context = RequestContext::getMain();
+
                        // Commit and close any current session
-                       session_write_close(); // persist
-                       session_id( '' ); // detach
-                       $_SESSION = array(); // clear in-memory array
-                       // Remove any user IP or agent information
-                       $context->setRequest( new FauxRequest() );
+                       if ( MediaWiki\Session\PHPSessionHandler::isEnabled() ) {
+                               session_write_close(); // persist
+                               session_id( '' ); // detach
+                               $_SESSION = array(); // clear in-memory array
+                       }
+
+                       // Get new session, if applicable
+                       $session = null;
+                       if ( strlen( $params['sessionId'] ) ) { // don't make a new random ID
+                               $manager = MediaWiki\Session\SessionManager::singleton();
+                               $session = $manager->getSessionById( $params['sessionId'], true )
+                                       ?: $manager->getEmptySession();
+                       }
+
+                       // Remove any user IP or agent information, and attach the request
+                       // with the new session.
+                       $context->setRequest( new FauxRequest( array(), false, $session ) );
                        $wgRequest = $context->getRequest(); // b/c
+
                        // Now that all private information is detached from the user, it should
                        // be safe to load the new user. If errors occur or an exception is thrown
                        // and caught (leaving the main context in a mixed state), there is no risk
                        // of the User object being attached to the wrong IP, headers, or session.
                        $context->setUser( $user );
                        $wgUser = $context->getUser(); // b/c
-                       if ( strlen( $params['sessionId'] ) ) { // don't make a new random ID
-                               wfSetupSession( $params['sessionId'] ); // sets $_SESSION
+                       if ( $session && MediaWiki\Session\PHPSessionHandler::isEnabled() ) {
+                               session_id( $session->getId() );
+                               MediaWiki\quietCall( 'session_cache_limiter', 'private, must-revalidate' );
+                               MediaWiki\quietCall( 'session_start' );
                        }
-                       $request = new FauxRequest( array(), false, $_SESSION );
+                       $request = new FauxRequest( array(), false, $session );
                        $request->setIP( $params['ip'] );
                        foreach ( $params['headers'] as $name => $value ) {
                                $request->setHeader( $name, $value );
index 9b5635b..10fed31 100644 (file)
@@ -278,6 +278,7 @@ class MysqlUpdater extends DatabaseUpdater {
                        // 1.27
                        array( 'dropTable', 'msg_resource_links' ),
                        array( 'dropTable', 'msg_resource' ),
+                       array( 'addTable', 'bot_passwords', 'patch-bot_passwords.sql' ),
                );
        }
 
index 7880557..21d5dbc 100644 (file)
@@ -89,6 +89,7 @@ class PostgresUpdater extends DatabaseUpdater {
                        array( 'addTable', 'uploadstash', 'patch-uploadstash.sql' ),
                        array( 'addTable', 'user_former_groups', 'patch-user_former_groups.sql' ),
                        array( 'addTable', 'sites', 'patch-sites.sql' ),
+                       array( 'addTable', 'bot_passwords', 'patch-bot_passwords.sql' ),
 
                        # Needed before new field
                        array( 'convertArchive2' ),
index 5279c2d..ba223c4 100644 (file)
@@ -147,6 +147,7 @@ class SqliteUpdater extends DatabaseUpdater {
                        // 1.27
                        array( 'dropTable', 'msg_resource_links' ),
                        array( 'dropTable', 'msg_resource' ),
+                       array( 'addTable', 'bot_passwords', 'patch-bot_passwords.sql' ),
                );
        }
 
index b9be43d..3736103 100644 (file)
@@ -69,6 +69,7 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
        const READ_VERIFIED = 2; // promise that caller can tell when keys are stale
        /** Bitfield constants for set()/merge() */
        const WRITE_SYNC = 1; // synchronously write to all locations for replicated stores
+       const WRITE_CACHE_ONLY = 2; // Only change state of the in-memory cache
 
        public function __construct( array $params = array() ) {
                if ( isset( $params['logger'] ) ) {
diff --git a/includes/libs/objectcache/CachedBagOStuff.php b/includes/libs/objectcache/CachedBagOStuff.php
new file mode 100644 (file)
index 0000000..fc15618
--- /dev/null
@@ -0,0 +1,114 @@
+<?php
+/**
+ * Wrapper around a BagOStuff that caches data in memory
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Cache
+ */
+
+use Psr\Log\LoggerInterface;
+
+/**
+ * Wrapper around a BagOStuff that caches data in memory
+ *
+ * The differences between CachedBagOStuff and MultiWriteBagOStuff are:
+ * * CachedBagOStuff supports only one "backend".
+ * * There's a flag for writes to only go to the in-memory cache.
+ * * The in-memory cache is always updated.
+ * * Locks go to the backend cache (with MultiWriteBagOStuff, it would wind
+ *   up going to the HashBagOStuff used for the in-memory cache).
+ *
+ * @ingroup Cache
+ */
+class CachedBagOStuff extends HashBagOStuff {
+       /** @var BagOStuff */
+       protected $backend;
+
+       /**
+        * @param BagOStuff $backend Permanent backend to use
+        * @param array $params Parameters for HashBagOStuff
+        */
+       function __construct( BagOStuff $backend, $params = array() ) {
+               $this->backend = $backend;
+               parent::__construct( $params );
+       }
+
+       protected function doGet( $key, $flags = 0 ) {
+               $ret = parent::doGet( $key, $flags );
+               if ( $ret === false ) {
+                       $ret = $this->backend->doGet( $key, $flags );
+                       if ( $ret !== false ) {
+                               $this->set( $key, $ret, 0, self::WRITE_CACHE_ONLY );
+                       }
+               }
+               return $ret;
+       }
+
+       public function set( $key, $value, $exptime = 0, $flags = 0 ) {
+               parent::set( $key, $value, $exptime, $flags );
+               if ( !( $flags & self::WRITE_CACHE_ONLY ) ) {
+                       $this->backend->set( $key, $value, $exptime, $flags & ~self::WRITE_CACHE_ONLY );
+               }
+               return true;
+       }
+
+       public function delete( $key, $flags = 0 ) {
+               unset( $this->bag[$key] );
+               if ( !( $flags & self::WRITE_CACHE_ONLY ) ) {
+                       $this->backend->delete( $key );
+               }
+
+               return true;
+       }
+
+       public function setLogger( LoggerInterface $logger ) {
+               parent::setLogger( $logger );
+               $this->backend->setLogger( $logger );
+       }
+
+       public function setDebug( $bool ) {
+               parent::setDebug( $bool );
+               $this->backend->setDebug( $bool );
+       }
+
+       public function lock( $key, $timeout = 6, $expiry = 6, $rclass = '' ) {
+               return $this->backend->lock( $key, $timeout, $expiry, $rclass );
+       }
+
+       public function unlock( $key ) {
+               return $this->backend->unlock( $key );
+       }
+
+       public function deleteObjectsExpiringBefore( $date, $progressCallback = false ) {
+               parent::deleteObjectsExpiringBefore( $date, $progressCallback );
+               return $this->backend->deleteObjectsExpiringBefore( $date, $progressCallback );
+       }
+
+       public function getLastError() {
+               return $this->backend->getLastError();
+       }
+
+       public function clearLastError() {
+               $this->backend->clearLastError();
+       }
+
+       public function modifySimpleRelayEvent( array $event ) {
+               return $this->backend->modifySimpleRelayEvent( $event );
+       }
+
+}
diff --git a/includes/objectcache/ObjectCacheSessionHandler.php b/includes/objectcache/ObjectCacheSessionHandler.php
deleted file mode 100644 (file)
index cc85074..0000000
+++ /dev/null
@@ -1,207 +0,0 @@
-<?php
-/**
- * Session storage in object cache.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- * @ingroup Cache
- */
-
-use MediaWiki\Logger\LoggerFactory;
-
-/**
- * Session storage in object cache.
- * Used if $wgSessionsInObjectCache is true.
- *
- * @ingroup Cache
- */
-class ObjectCacheSessionHandler {
-       /** @var array Map of (session ID => SHA-1 of the data) */
-       protected static $hashCache = array();
-
-       /**
-        * Install a session handler for the current web request
-        */
-       static function install() {
-               session_set_save_handler(
-                       array( __CLASS__, 'open' ),
-                       array( __CLASS__, 'close' ),
-                       array( __CLASS__, 'read' ),
-                       array( __CLASS__, 'write' ),
-                       array( __CLASS__, 'destroy' ),
-                       array( __CLASS__, 'gc' ) );
-
-               // It's necessary to register a shutdown function to call session_write_close(),
-               // because by the time the request shutdown function for the session module is
-               // called, the BagOStuff has already been destroyed. Shutdown functions registered
-               // this way are called before object destruction.
-               register_shutdown_function( array( __CLASS__, 'handleShutdown' ) );
-       }
-
-       /**
-        * Get the cache storage object to use for session storage
-        * @return BagOStuff
-        */
-       protected static function getCache() {
-               global $wgSessionCacheType;
-
-               return ObjectCache::getInstance( $wgSessionCacheType );
-       }
-
-       /**
-        * Get a cache key for the given session id.
-        *
-        * @param string $id Session id
-        * @return string Cache key
-        */
-       protected static function getKey( $id ) {
-               return wfMemcKey( 'session', $id );
-       }
-
-       /**
-        * @param mixed $data
-        * @return string
-        */
-       protected static function getHash( $data ) {
-               return sha1( serialize( $data ) );
-       }
-
-       /**
-        * Callback when opening a session.
-        *
-        * @param string $save_path Path used to store session files, unused
-        * @param string $session_name Session name
-        * @return bool Success
-        */
-       static function open( $save_path, $session_name ) {
-               return true;
-       }
-
-       /**
-        * Callback when closing a session.
-        * NOP.
-        *
-        * @return bool Success
-        */
-       static function close() {
-               return true;
-       }
-
-       /**
-        * Callback when reading session data.
-        *
-        * @param string $id Session id
-        * @return mixed Session data
-        */
-       static function read( $id ) {
-               $stime = microtime( true );
-               $data = self::getCache()->get( self::getKey( $id ) );
-               $real = microtime( true ) - $stime;
-
-               RequestContext::getMain()->getStats()->timing( "session.read", 1000 * $real );
-
-               self::$hashCache = array( $id => self::getHash( $data ) );
-
-               return ( $data === false ) ? '' : $data;
-       }
-
-       /**
-        * Callback when writing session data.
-        *
-        * @param string $id Session id
-        * @param string $data Session data
-        * @return bool Success
-        */
-       static function write( $id, $data ) {
-               global $wgObjectCacheSessionExpiry;
-
-               // Only issue a write if anything changed (PHP 5.6 already does this)
-               if ( !isset( self::$hashCache[$id] )
-                       || self::getHash( $data ) !== self::$hashCache[$id]
-               ) {
-                       $stime = microtime( true );
-                       self::getCache()->set( self::getKey( $id ), $data, $wgObjectCacheSessionExpiry );
-                       $real = microtime( true ) - $stime;
-
-                       RequestContext::getMain()->getStats()->timing( "session.write", 1000 * $real );
-               }
-
-               return true;
-       }
-
-       /**
-        * Callback to destroy a session when calling session_destroy().
-        *
-        * @param string $id Session id
-        * @return bool Success
-        */
-       static function destroy( $id ) {
-               $stime = microtime( true );
-               self::getCache()->delete( self::getKey( $id ) );
-               $real = microtime( true ) - $stime;
-
-               RequestContext::getMain()->getStats()->timing( "session.destroy", 1000 * $real );
-
-               return true;
-       }
-
-       /**
-        * Callback to execute garbage collection.
-        * NOP: Object caches perform garbage collection implicitly
-        *
-        * @param int $maxlifetime Maximum session life time
-        * @return bool Success
-        */
-       static function gc( $maxlifetime ) {
-               return true;
-       }
-
-       /**
-        * Shutdown function.
-        * See the comment inside ObjectCacheSessionHandler::install for rationale.
-        */
-       static function handleShutdown() {
-               session_write_close();
-       }
-
-       /**
-        * Pre-emptive session renewal function
-        */
-       static function renewCurrentSession() {
-               global $wgObjectCacheSessionExpiry;
-
-               // Once a session is at half TTL, renew it
-               $window = $wgObjectCacheSessionExpiry / 2;
-               $logger = LoggerFactory::getInstance( 'SessionHandler' );
-
-               $now = microtime( true );
-               // Session are only written in object stores when $_SESSION changes,
-               // which also renews the TTL ($wgObjectCacheSessionExpiry). If a user
-               // is active but not causing session data changes, it may suddenly
-               // expire as they view a form, blocking the first submission.
-               // Make a dummy change every so often to avoid this.
-               if ( !isset( $_SESSION['wsExpiresUnix'] ) ) {
-                       $_SESSION['wsExpiresUnix'] = $now + $wgObjectCacheSessionExpiry;
-
-                       $logger->info( "Set expiry for session " . session_id(), array() );
-               } elseif ( ( $now + $window ) > $_SESSION['wsExpiresUnix'] ) {
-                       $_SESSION['wsExpiresUnix'] = $now + $wgObjectCacheSessionExpiry;
-
-                       $logger->info( "Renewed session " . session_id(), array() );
-               }
-       }
-}
index e6d5274..0e8d76d 100644 (file)
@@ -599,6 +599,15 @@ class ParserOptions {
                $this->initialiseFromUser( $user, $lang );
        }
 
+       /**
+        * Get a ParserOptions object for an anonymous user
+        * @return ParserOptions
+        */
+       public static function newFromAnon() {
+               global $wgContLang;
+               return new ParserOptions( new User, $wgContLang );
+       }
+
        /**
         * Get a ParserOptions object from a given user.
         * Language will be taken from $wgLang.
index 817f153..4ca3a87 100644 (file)
@@ -237,8 +237,6 @@ class Preprocessor_DOM extends Preprocessor {
                $inHeading = false;
                // True if there are no more greater-than (>) signs right of $i
                $noMoreGT = false;
-               // Map of tag name => true if there are no more closing tags of given type right of $i
-               $noMoreClosingTag = array();
                // True to ignore all input up to the next <onlyinclude>
                $findOnlyinclude = $enableOnlyinclude;
                // Do a line-start run without outputting an LF character
@@ -459,21 +457,17 @@ class Preprocessor_DOM extends Preprocessor {
                                } else {
                                        $attrEnd = $tagEndPos;
                                        // Find closing tag
-                                       if (
-                                               !isset( $noMoreClosingTag[$name] ) &&
-                                               preg_match( "/<\/" . preg_quote( $name, '/' ) . "\s*>/i",
+                                       if ( preg_match( "/<\/" . preg_quote( $name, '/' ) . "\s*>/i",
                                                        $text, $matches, PREG_OFFSET_CAPTURE, $tagEndPos + 1 )
                                        ) {
                                                $inner = substr( $text, $tagEndPos + 1, $matches[0][1] - $tagEndPos - 1 );
                                                $i = $matches[0][1] + strlen( $matches[0][0] );
                                                $close = '<close>' . htmlspecialchars( $matches[0][0] ) . '</close>';
                                        } else {
-                                               // No end tag -- don't match the tag, treat opening tag as literal and resume parsing.
-                                               $i = $tagEndPos + 1;
-                                               $accum .= htmlspecialchars( substr( $text, $tagStartPos, $tagEndPos + 1 - $tagStartPos ) );
-                                               // Cache results, otherwise we have O(N^2) performance for input like <foo><foo><foo>...
-                                               $noMoreClosingTag[$name] = true;
-                                               continue;
+                                               // No end tag -- let it run out to the end of the text.
+                                               $inner = substr( $text, $tagEndPos + 1 );
+                                               $i = $lengthText;
+                                               $close = '';
                                        }
                                }
                                // <includeonly> and <noinclude> just become <ignore> tags
index 28c49fd..50eaefb 100644 (file)
@@ -160,8 +160,6 @@ class Preprocessor_Hash extends Preprocessor {
                $inHeading = false;
                // True if there are no more greater-than (>) signs right of $i
                $noMoreGT = false;
-               // Map of tag name => true if there are no more closing tags of given type right of $i
-               $noMoreClosingTag = array();
                // True to ignore all input up to the next <onlyinclude>
                $findOnlyinclude = $enableOnlyinclude;
                // Do a line-start run without outputting an LF character
@@ -382,21 +380,17 @@ class Preprocessor_Hash extends Preprocessor {
                                } else {
                                        $attrEnd = $tagEndPos;
                                        // Find closing tag
-                                       if (
-                                               !isset( $noMoreClosingTag[$name] ) &&
-                                               preg_match( "/<\/" . preg_quote( $name, '/' ) . "\s*>/i",
+                                       if ( preg_match( "/<\/" . preg_quote( $name, '/' ) . "\s*>/i",
                                                        $text, $matches, PREG_OFFSET_CAPTURE, $tagEndPos + 1 )
                                        ) {
                                                $inner = substr( $text, $tagEndPos + 1, $matches[0][1] - $tagEndPos - 1 );
                                                $i = $matches[0][1] + strlen( $matches[0][0] );
                                                $close = $matches[0][0];
                                        } else {
-                                               // No end tag -- don't match the tag, treat opening tag as literal and resume parsing.
-                                               $i = $tagEndPos + 1;
-                                               $accum->addLiteral( substr( $text, $tagStartPos, $tagEndPos + 1 - $tagStartPos ) );
-                                               // Cache results, otherwise we have O(N^2) performance for input like <foo><foo><foo>...
-                                               $noMoreClosingTag[$name] = true;
-                                               continue;
+                                               // No end tag -- let it run out to the end of the text.
+                                               $inner = substr( $text, $tagEndPos + 1 );
+                                               $i = $lengthText;
+                                               $close = null;
                                        }
                                }
                                // <includeonly> and <noinclude> just become <ignore> tags
index 3c8d56e..81b850a 100644 (file)
@@ -296,6 +296,15 @@ class SearchEngine {
         * @param int[]|null $namespaces
         */
        function setNamespaces( $namespaces ) {
+               if ( $namespaces ) {
+                       // Filter namespaces to only keep valid ones
+                       $validNs = $this->searchableNamespaces();
+                       $namespaces = array_filter( $namespaces, function( $ns ) use( $validNs ) {
+                               return $ns < 0 || isset( $validNs[$ns] );
+                       } );
+               } else {
+                       $namespaces = array();
+               }
                $this->namespaces = $namespaces;
        }
 
@@ -570,6 +579,201 @@ class SearchEngine {
        public function textAlreadyUpdatedForIndex() {
                return false;
        }
+
+       /**
+        * Makes search simple string if it was namespaced.
+        * Sets namespaces of the search to namespaces extracted from string.
+        * @param string $search
+        * @return $string Simplified search string
+        */
+       protected function normalizeNamespaces( $search ) {
+               // Find a Title which is not an interwiki and is in NS_MAIN
+               $title = Title::newFromText( $search );
+               $ns = $this->namespaces;
+               if ( $title && !$title->isExternal() ) {
+                       $ns = array( $title->getNamespace() );
+                       $search = $title->getText();
+                       if ( $ns[0] == NS_MAIN ) {
+                               $ns = $this->namespaces; // no explicit prefix, use default namespaces
+                               Hooks::run( 'PrefixSearchExtractNamespace', array( &$ns, &$search ) );
+                       }
+               } else {
+                       $title = Title::newFromText( $search . 'Dummy' );
+                       if ( $title && $title->getText() == 'Dummy'
+                                       && $title->getNamespace() != NS_MAIN
+                                       && !$title->isExternal() )
+                       {
+                               $ns = array( $title->getNamespace() );
+                               $search = '';
+                       } else {
+                               Hooks::run( 'PrefixSearchExtractNamespace', array( &$ns, &$search ) );
+                       }
+               }
+
+               $ns = array_map( function( $space ) {
+                       return $space == NS_MEDIA ? NS_FILE : $space;
+               }, $ns );
+
+               $this->setNamespaces( $ns );
+               return $search;
+       }
+
+       /**
+        * Perform a completion search.
+        * Does not resolve namespaces and does not check variants.
+        * Search engine implementations may want to override this function.
+        * @param string $search
+        * @return SearchSuggestionSet
+        */
+       protected function completionSearchBackend( $search ) {
+               $results = array();
+
+               $search = trim( $search );
+
+               if ( !in_array( NS_SPECIAL, $this->namespaces ) && // We do not run hook on Special: search
+                        !Hooks::run( 'PrefixSearchBackend',
+                               array( $this->namespaces, $search, $this->limit, &$results, $this->offset )
+               ) ) {
+                       // False means hook worked.
+                       // FIXME: Yes, the API is weird. That's why it is going to be deprecated.
+
+                       return SearchSuggestionSet::fromStrings( $results );
+               } else {
+                       // Hook did not do the job, use default simple search
+                       $results = $this->simplePrefixSearch( $search );
+                       return SearchSuggestionSet::fromTitles( $results );
+               }
+       }
+
+       /**
+        * Perform a completion search.
+        * @param string $search
+        * @return SearchSuggestionSet
+        */
+       public function completionSearch( $search ) {
+               if ( trim( $search ) === '' ) {
+                       return SearchSuggestionSet::emptySuggestionSet(); // Return empty result
+               }
+               $search = $this->normalizeNamespaces( $search );
+               return $this->processCompletionResults( $search, $this->completionSearchBackend( $search ) );
+       }
+
+       /**
+        * Perform a completion search with variants.
+        * @param string $search
+        * @return SearchSuggestionSet
+        */
+       public function completionSearchWithVariants( $search ) {
+               if ( trim( $search ) === '' ) {
+                       return SearchSuggestionSet::emptySuggestionSet(); // Return empty result
+               }
+               $search = $this->normalizeNamespaces( $search );
+
+               $results = $this->completionSearchBackend( $search );
+               $fallbackLimit = $this->limit - $results->getSize();
+               if ( $fallbackLimit > 0 ) {
+                       global $wgContLang;
+
+                       $fallbackSearches = $wgContLang->autoConvertToAllVariants( $search );
+                       $fallbackSearches = array_diff( array_unique( $fallbackSearches ), array( $search ) );
+
+                       foreach ( $fallbackSearches as $fbs ) {
+                               $this->setLimitOffset( $fallbackLimit );
+                               $fallbackSearchResult = $this->completionSearch( $fbs );
+                               $results->appendAll( $fallbackSearchResult );
+                               $fallbackLimit -= count( $fallbackSearchResult );
+                               if ( $fallbackLimit <= 0 ) {
+                                       break;
+                               }
+                       }
+               }
+               return $this->processCompletionResults( $search, $results );
+       }
+
+       /**
+        * Extract titles from completion results
+        * @param SearchSuggestionSet $completionResults
+        * @return Title[]
+        */
+       public function extractTitles( SearchSuggestionSet $completionResults ) {
+               return $completionResults->map( function( SearchSuggestion $sugg ) {
+                       return $sugg->getSuggestedTitle();
+               } );
+       }
+
+       /**
+        * Process completion search results.
+        * Resolves the titles and rescores.
+        * @param SearchSuggestionSet $suggestions
+        * @return SearchSuggestionSet
+        */
+       protected function processCompletionResults( $search, SearchSuggestionSet $suggestions ) {
+               if ( $suggestions->getSize() == 0 ) {
+                       // If we don't have anything, don't bother
+                       return $suggestions;
+               }
+               $search = trim( $search );
+               // preload the titles with LinkBatch
+               $titles = $suggestions->map( function( SearchSuggestion $sugg ) {
+                       return $sugg->getSuggestedTitle();
+               } );
+               $lb = new LinkBatch( $titles );
+               $lb->setCaller( __METHOD__ );
+               $lb->execute();
+
+               $results = $suggestions->map( function( SearchSuggestion $sugg ) {
+                       return $sugg->getSuggestedTitle()->getPrefixedText();
+               } );
+
+               // Rescore results with an exact title match
+               $rescorer = new SearchExactMatchRescorer();
+               $rescoredResults = $rescorer->rescore( $search, $this->namespaces, $results, $this->limit );
+
+               if ( count( $rescoredResults ) > 0 ) {
+                       $found = array_search( $rescoredResults[0], $results );
+                       if ( $found === false ) {
+                               // If the first result is not in the previous array it
+                               // means that we found a new exact match
+                               $exactMatch = SearchSuggestion::fromTitle( 0, Title::newFromText( $rescoredResults[0] ) );
+                               $suggestions->prepend( $exactMatch );
+                               $suggestions->shrink( $this->limit );
+                       } else {
+                               // if the first result is not the same we need to rescore
+                               if ( $found > 0 ) {
+                                       $suggestions->rescore( $found );
+                               }
+                       }
+               }
+
+               return $suggestions;
+       }
+
+       /**
+        * Simple prefix search for subpages.
+        * @param string $search
+        * @return Title[]
+        */
+       public function defaultPrefixSearch( $search ) {
+               if ( trim( $search ) === '' ) {
+                       return array();
+               }
+
+               $search = $this->normalizeNamespaces( $search );
+               return $this->simplePrefixSearch( $search );
+       }
+
+       /**
+        * Call out to simple search backend.
+        * Defaults to TitlePrefixSearch.
+        * @param string $search
+        * @return Title[]
+        */
+       protected function simplePrefixSearch( $search ) {
+               // Use default database prefix search
+               $backend = new TitlePrefixSearch;
+               return $backend->defaultSearchBackend( $this->namespaces, $search, $this->limit, $this->offset );
+       }
+
 }
 
 /**
diff --git a/includes/search/SearchSuggestion.php b/includes/search/SearchSuggestion.php
new file mode 100644 (file)
index 0000000..cd9062b
--- /dev/null
@@ -0,0 +1,187 @@
+<?php
+
+/**
+ * Search suggestion
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ */
+
+/**
+ * A search suggestion
+ *
+ */
+class SearchSuggestion {
+       /**
+        * @var string the suggestion
+        */
+       private $text;
+
+       /**
+        * @var string the suggestion URL
+        */
+       private $url;
+
+       /**
+        * @var Title|null the suggested title
+        */
+       private $suggestedTitle;
+
+       /**
+        * NOTE: even if suggestedTitle is a redirect suggestedTitleID
+        * is the ID of the target page.
+        * @var int|null the suggested title ID
+        */
+       private $suggestedTitleID;
+
+       /**
+        * @var float|null The suggestion score
+        */
+       private $score;
+
+       /**
+        * Construct a new suggestion
+        * @param float $score the suggestion score
+        * @param string $text|null the suggestion text
+        * @param Title|null $suggestedTitle the suggested title
+        * @param int|null $suggestedTitleID the suggested title ID
+        */
+       public function __construct( $score, $text = null, Title $suggestedTitle = null,
+                       $suggestedTitleID = null ) {
+               $this->score = $score;
+               $this->text = $text;
+               if ( $suggestedTitle ) {
+                       $this->setSuggestedTitle( $suggestedTitle );
+               }
+               $this->suggestedTitleID = $suggestedTitleID;
+       }
+
+       /**
+        * The suggestion text
+        * @return string
+        */
+       public function getText() {
+               return $this->text;
+       }
+
+       /**
+        * Set the suggestion text.
+        * @param string $text
+        * @param bool $setTitle Should we also update the title?
+        */
+       public function setText( $text, $setTitle = true ) {
+               $this->text = $text;
+               if ( $setTitle && $text ) {
+                       $this->setSuggestedTitle( Title::makeTitle( 0, $text ) );
+               }
+       }
+
+       /**
+        * Title object in the case this suggestion is based on a title.
+        * May return null if the suggestion is not a Title.
+        * @return Title|null
+        */
+       public function getSuggestedTitle() {
+               return $this->suggestedTitle;
+       }
+
+       /**
+        * Set the suggested title
+        * @param Title|null $title
+        */
+       public function setSuggestedTitle( Title $title = null ) {
+               $this->suggestedTitle = $title;
+               if ( $title !== null ) {
+                       $this->url = wfExpandUrl( $title->getFullURL(), PROTO_CURRENT );
+               }
+       }
+
+       /**
+        * Title ID in the case this suggestion is based on a title.
+        * May return null if the suggestion is not a Title.
+        * @return int|null
+        */
+       public function getSuggestedTitleID() {
+               return $this->suggestedTitleID;
+       }
+
+       /**
+        * Set the suggested title ID
+        * @param int|null $suggestedTitleID
+        */
+       public function setSuggestedTitleID( $suggestedTitleID = null ) {
+               $this->suggestedTitleID = $suggestedTitleID;
+       }
+
+       /**
+        * Suggestion score
+        * @return float Suggestion score
+        */
+       public function getScore() {
+               return $this->score;
+       }
+
+       /**
+        * Set the suggestion score
+        * @param float $score
+        */
+       public function setScore( $score ) {
+               $this->score = $score;
+       }
+
+       /**
+        * Suggestion URL, can be the link to the Title or maybe in the
+        * future a link to the search results for this search suggestion.
+        * @return string Suggestion URL
+        */
+       public function getURL() {
+               return $this->url;
+       }
+
+       /**
+        * Set the suggestion URL
+        * @param string $url
+        */
+       public function setURL( $url ) {
+               $this->url = $url;
+       }
+
+       /**
+        * Create suggestion from Title
+        * @param float $score Suggestions score
+        * @param Title $title
+        * @return SearchSuggestion
+        */
+       public static function fromTitle( $score, Title $title ) {
+               return new self( $score, $title->getPrefixedText(), $title, $title->getArticleID() );
+       }
+
+       /**
+        * Create suggestion from text
+        * Will also create a title if text if not empty.
+        * @param float $score Suggestions score
+        * @param string $text
+        * @return SearchSuggestion
+        */
+       public static function fromText( $score, $text ) {
+               $suggestion = new self( $score, $text );
+               if ( $text ) {
+                       $suggestion->setSuggestedTitle( Title::makeTitle( 0, $text ) );
+               }
+               return $suggestion;
+       }
+
+}
diff --git a/includes/search/SearchSuggestionSet.php b/includes/search/SearchSuggestionSet.php
new file mode 100644 (file)
index 0000000..a1f9a04
--- /dev/null
@@ -0,0 +1,213 @@
+<?php
+
+/**
+ * Search suggestion sets
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ */
+
+/**
+ * A set of search suggestions.
+ * The set is always ordered by score, with the best match first.
+ */
+class SearchSuggestionSet {
+       /**
+        * @var SearchSuggestion[]
+        */
+       private $suggestions = array();
+
+       /**
+        *
+        * @var array
+        */
+       private $pageMap = array();
+
+       /**
+        * Builds a new set of suggestions.
+        *
+        * NOTE: the array should be sorted by score (higher is better),
+        * in descending order.
+        * SearchSuggestionSet will not try to re-order this input array.
+        * Providing an unsorted input array is a mistake and will lead to
+        * unexpected behaviors.
+        *
+        * @param SearchSuggestion[] $suggestions (must be sorted by score)
+        */
+       public function __construct( array $suggestions ) {
+               foreach ( $suggestions as $suggestion ) {
+                       $pageID = $suggestion->getSuggestedTitleID();
+                       if ( $pageID && empty( $this->pageMap[$pageID] ) ) {
+                               $this->pageMap[$pageID] = true;
+                       }
+                       $this->suggestions[] = $suggestion;
+               }
+       }
+
+       /**
+        * Get the list of suggestions.
+        * @return SearchSuggestion[]
+        */
+       public function getSuggestions() {
+               return $this->suggestions;
+       }
+
+       /**
+        * Call array_map on the suggestions array
+        * @param callback $callback
+        * @return array
+        */
+       public function map( $callback ) {
+               return array_map( $callback, $this->suggestions );
+       }
+
+       /**
+        * Add a new suggestion at the end.
+        * If the score of the new suggestion is greater than the worst one,
+        * the new suggestion score will be updated (worst - 1).
+        *
+        * @param SearchSuggestion $suggestion
+        */
+       public function append( SearchSuggestion $suggestion ) {
+               $pageID = $suggestion->getSuggestedTitleID();
+               if ( $pageID && isset( $this->pageMap[$pageID] ) ) {
+                       return;
+               }
+               if ( $this->getSize() > 0 && $suggestion->getScore() >= $this->getWorstScore() ) {
+                       $suggestion->setScore( $this->getWorstScore() - 1 );
+               }
+               $this->suggestions[] = $suggestion;
+               if ( $pageID ) {
+                       $this->pageMap[$pageID] = true;
+               }
+       }
+
+       /**
+        * Add suggestion set to the end of the current one.
+        * @param SearchSuggestionSet $set
+        */
+       public function appendAll( SearchSuggestionSet $set ) {
+               foreach ( $set->getSuggestions() as $sugg ) {
+                       $this->append( $sugg );
+               }
+       }
+
+       /**
+        * Move the suggestion at index $key to the first position
+        */
+       public function rescore( $key ) {
+               $removed = array_splice( $this->suggestions, $key, 1 );
+               unset( $this->pageMap[$removed[0]->getSuggestedTitleID()] );
+               $this->prepend( $removed[0] );
+       }
+
+       /**
+        * Add a new suggestion at the top. If the new suggestion score
+        * is lower than the best one its score will be updated (best + 1)
+        * @param SearchSuggestion $suggestion
+        */
+       public function prepend( SearchSuggestion $suggestion ) {
+               $pageID = $suggestion->getSuggestedTitleID();
+               if ( $pageID && isset( $this->pageMap[$pageID] ) ) {
+                       return;
+               }
+               if ( $this->getSize() > 0 && $suggestion->getScore() <= $this->getBestScore() ) {
+                       $suggestion->setScore( $this->getBestScore() + 1 );
+               }
+               array_unshift( $this->suggestions,  $suggestion );
+               if ( $pageID ) {
+                       $this->pageMap[$pageID] = true;
+               }
+       }
+
+       /**
+        * @return float the best score in this suggestion set
+        */
+       public function getBestScore() {
+               if ( empty( $this->suggestions ) ) {
+                       return 0;
+               }
+               return $this->suggestions[0]->getScore();
+       }
+
+       /**
+        * @return float the worst score in this set
+        */
+       public function getWorstScore() {
+               if ( empty( $this->suggestions ) ) {
+                       return 0;
+               }
+               return end( $this->suggestions )->getScore();
+       }
+
+       /**
+        * @return int the number of suggestion in this set
+        */
+       public function getSize() {
+               return count( $this->suggestions );
+       }
+
+       /**
+        * Remove any extra elements in the suggestions set
+        * @param int $limit the max size of this set.
+        */
+       public function shrink( $limit ) {
+               if ( count( $this->suggestions ) > $limit ) {
+                       $this->suggestions = array_slice( $this->suggestions, 0, $limit );
+               }
+       }
+
+       /**
+        * Builds a new set of suggestion based on a title array.
+        * Useful when using a backend that supports only Titles.
+        *
+        * NOTE: Suggestion scores will be generated.
+        *
+        * @param Title[] $titles
+        * @return SearchSuggestionSet
+        */
+       public static function fromTitles( array $titles ) {
+               $score = count( $titles );
+               $suggestions = array_map( function( $title ) use ( &$score ) {
+                       return SearchSuggestion::fromTitle( $score--, $title );
+               }, $titles );
+               return new SearchSuggestionSet( $suggestions );
+       }
+
+       /**
+        * Builds a new set of suggestion based on a string array.
+        *
+        * NOTE: Suggestion scores will be generated.
+        *
+        * @param string[] $titles
+        * @return SearchSuggestionSet
+        */
+       public static function fromStrings( array $titles ) {
+               $score = count( $titles );
+               $suggestions = array_map( function( $title ) use ( &$score ) {
+                       return SearchSuggestion::fromText( $score--, $title );
+               }, $titles );
+               return new SearchSuggestionSet( $suggestions );
+       }
+
+
+       /**
+        * @return SearchSuggestionSet an empty suggestion set
+        */
+       public static function emptySuggestionSet() {
+               return new SearchSuggestionSet( array() );
+       }
+}
diff --git a/includes/session/BotPasswordSessionProvider.php b/includes/session/BotPasswordSessionProvider.php
new file mode 100644 (file)
index 0000000..d9c60c7
--- /dev/null
@@ -0,0 +1,167 @@
+<?php
+/**
+ * Session provider for bot passwords
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use BotPassword;
+use User;
+use WebRequest;
+
+/**
+ * Session provider for bot passwords
+ * @since 1.27
+ */
+class BotPasswordSessionProvider extends ImmutableSessionProviderWithCookie {
+
+       /**
+        * @param array $params Keys include:
+        *  - priority: (required) Set the priority
+        *  - sessionCookieName: Session cookie name. Default is '_BPsession'.
+        *  - sessionCookieOptions: Options to pass to WebResponse::setCookie().
+        */
+       public function __construct( array $params = array() ) {
+               if ( !isset( $params['sessionCookieName'] ) ) {
+                       $params['sessionCookieName'] = '_BPsession';
+               }
+               parent::__construct( $params );
+
+               if ( !isset( $params['priority'] ) ) {
+                       throw new \InvalidArgumentException( __METHOD__ . ': priority must be specified' );
+               }
+               if ( $params['priority'] < SessionInfo::MIN_PRIORITY ||
+                       $params['priority'] > SessionInfo::MAX_PRIORITY
+               ) {
+                       throw new \InvalidArgumentException( __METHOD__ . ': Invalid priority' );
+               }
+
+               $this->priority = $params['priority'];
+       }
+
+       public function provideSessionInfo( WebRequest $request ) {
+               // Only relevant for the API
+               if ( !defined( 'MW_API' ) ) {
+                       return null;
+               }
+
+               // Enabled?
+               if ( !$this->config->get( 'EnableBotPasswords' ) ) {
+                       return null;
+               }
+
+               // Have a session ID?
+               $id = $this->getSessionIdFromCookie( $request );
+               if ( $id === null ) {
+                       return null;
+               }
+
+               return new SessionInfo( $this->priority, array(
+                       'provider' => $this,
+                       'id' => $id,
+                       'persisted' => true
+               ) );
+       }
+
+       public function newSessionInfo( $id = null ) {
+               // We don't activate by default
+               return null;
+       }
+
+       /**
+        * Create a new session for a request
+        * @param User $user
+        * @param BotPassword $bp
+        * @param WebRequest $request
+        * @return Session
+        */
+       public function newSessionForRequest( User $user, BotPassword $bp, WebRequest $request ) {
+               $id = $this->getSessionIdFromCookie( $request );
+               $info = new SessionInfo( SessionInfo::MAX_PRIORITY, array(
+                       'provider' => $this,
+                       'id' => $id,
+                       'userInfo' => UserInfo::newFromUser( $user, true ),
+                       'persisted' => $id !== null,
+                       'metadata' => array(
+                               'centralId' => $bp->getUserCentralId(),
+                               'appId' => $bp->getAppId(),
+                               'token' => $bp->getToken(),
+                               'rights' => \MWGrants::getGrantRights( $bp->getGrants() ),
+                       ),
+               ) );
+               $session = $this->getManager()->getSessionFromInfo( $info, $request );
+               $session->persist();
+               return $session;
+       }
+
+       public function refreshSessionInfo( SessionInfo $info, WebRequest $request, &$metadata ) {
+               $missingKeys = array_diff(
+                       array( 'centralId', 'appId', 'token' ),
+                       array_keys( $metadata )
+               );
+               if ( $missingKeys ) {
+                       $this->logger->info( "Session $info: Missing metadata: " . join( ', ', $missingKeys ) );
+                       return false;
+               }
+
+               $bp = BotPassword::newFromCentralId( $metadata['centralId'], $metadata['appId'] );
+               if ( !$bp ) {
+                       $this->logger->info(
+                               "Session $info: No BotPassword for {$metadata['centralId']} {$metadata['appId']}"
+                       );
+                       return false;
+               }
+
+               if ( !hash_equals( $metadata['token'], $bp->getToken() ) ) {
+                       $this->logger->info( "Session $info: BotPassword token check failed" );
+                       return false;
+               }
+
+               $status = $bp->getRestrictions()->check( $request );
+               if ( !$status->isOk() ) {
+                       $this->logger->info( "Session $info: Restrictions check failed", $status->getValue() );
+                       return false;
+               }
+
+               // Update saved rights
+               $metadata['rights'] = \MWGrants::getGrantRights( $bp->getGrants() );
+
+               return true;
+       }
+
+       public function preventSessionsForUser( $username ) {
+               BotPassword::removeAllPasswordsForUser( $username );
+       }
+
+       public function getAllowedUserRights( SessionBackend $backend ) {
+               if ( $backend->getProvider() !== $this ) {
+                       throw new InvalidArgumentException( 'Backend\'s provider isn\'t $this' );
+               }
+               $data = $backend->getProviderMetadata();
+               if ( $data ) {
+                       return $data['rights'];
+               }
+
+               // Should never happen
+               $this->logger->debug( __METHOD__ . ': No provider metadata, returning no rights allowed' );
+               return array();
+       }
+}
diff --git a/includes/session/CookieSessionProvider.php b/includes/session/CookieSessionProvider.php
new file mode 100644 (file)
index 0000000..f989cbc
--- /dev/null
@@ -0,0 +1,372 @@
+<?php
+/**
+ * MediaWiki cookie-based session provider interface
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use Config;
+use User;
+use WebRequest;
+
+/**
+ * A CookieSessionProvider persists sessions using cookies
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+class CookieSessionProvider extends SessionProvider {
+
+       protected $params = array();
+       protected $cookieOptions = array();
+
+       /**
+        * @param array $params Keys include:
+        *  - priority: (required) Priority of the returned sessions
+        *  - callUserSetCookiesHook: Whether to call the deprecated hook
+        *  - sessionName: Session cookie name. Doesn't honor 'prefix'. Defaults to
+        *    $wgSessionName, or $wgCookiePrefix . '_session' if that is unset.
+        *  - cookieOptions: Options to pass to WebRequest::setCookie():
+        *    - prefix: Cookie prefix, defaults to $wgCookiePrefix
+        *    - path: Cookie path, defaults to $wgCookiePath
+        *    - domain: Cookie domain, defaults to $wgCookieDomain
+        *    - secure: Cookie secure flag, defaults to $wgCookieSecure
+        *    - httpOnly: Cookie httpOnly flag, defaults to $wgCookieHttpOnly
+        */
+       public function __construct( $params = array() ) {
+               parent::__construct();
+
+               $params += array(
+                       'cookieOptions' => array(),
+                       // @codeCoverageIgnoreStart
+               );
+               // @codeCoverageIgnoreEnd
+
+               if ( !isset( $params['priority'] ) ) {
+                       throw new \InvalidArgumentException( __METHOD__ . ': priority must be specified' );
+               }
+               if ( $params['priority'] < SessionInfo::MIN_PRIORITY ||
+                       $params['priority'] > SessionInfo::MAX_PRIORITY
+               ) {
+                       throw new \InvalidArgumentException( __METHOD__ . ': Invalid priority' );
+               }
+
+               if ( !is_array( $params['cookieOptions'] ) ) {
+                       throw new \InvalidArgumentException( __METHOD__ . ': cookieOptions must be an array' );
+               }
+
+               $this->priority = $params['priority'];
+               $this->cookieOptions = $params['cookieOptions'];
+               $this->params = $params;
+               unset( $this->params['priority'] );
+               unset( $this->params['cookieOptions'] );
+       }
+
+       public function setConfig( Config $config ) {
+               parent::setConfig( $config );
+
+               // @codeCoverageIgnoreStart
+               $this->params += array(
+                       // @codeCoverageIgnoreEnd
+                       'callUserSetCookiesHook' => false,
+                       'sessionName' =>
+                               $config->get( 'SessionName' ) ?: $config->get( 'CookiePrefix' ) . '_session',
+               );
+
+               // @codeCoverageIgnoreStart
+               $this->cookieOptions += array(
+                       // @codeCoverageIgnoreEnd
+                       'prefix' => $config->get( 'CookiePrefix' ),
+                       'path' => $config->get( 'CookiePath' ),
+                       'domain' => $config->get( 'CookieDomain' ),
+                       'secure' => $config->get( 'CookieSecure' ),
+                       'httpOnly' => $config->get( 'CookieHttpOnly' ),
+               );
+       }
+
+       public function provideSessionInfo( WebRequest $request ) {
+               $info = array(
+                       'id' => $this->getCookie( $request, $this->params['sessionName'], '' ),
+                       'provider' => $this,
+                       'forceHTTPS' => $this->getCookie( $request, 'forceHTTPS', '', false )
+               );
+               if ( !SessionManager::validateSessionId( $info['id'] ) ) {
+                       unset( $info['id'] );
+               }
+               $info['persisted'] = isset( $info['id'] );
+
+               list( $userId, $userName, $token ) = $this->getUserInfoFromCookies( $request );
+               if ( $userId !== null ) {
+                       try {
+                               $userInfo = UserInfo::newFromId( $userId );
+                       } catch ( \InvalidArgumentException $ex ) {
+                               return null;
+                       }
+
+                       // Sanity check
+                       if ( $userName !== null && $userInfo->getName() !== $userName ) {
+                               return null;
+                       }
+
+                       if ( $token !== null ) {
+                               if ( !hash_equals( $userInfo->getToken(), $token ) ) {
+                                       return null;
+                               }
+                               $info['userInfo'] = $userInfo->verified();
+                       } elseif ( isset( $info['id'] ) ) {
+                               $info['userInfo'] = $userInfo;
+                       } else {
+                               // No point in returning, loadSessionInfoFromStore() will
+                               // reject it anyway.
+                               return null;
+                       }
+               } elseif ( isset( $info['id'] ) ) {
+                       // No UserID cookie, so insist that the session is anonymous.
+                       $info['userInfo'] = UserInfo::newAnonymous();
+               } else {
+                       // No session ID and no user is the same as an empty session, so
+                       // there's no point.
+                       return null;
+               }
+
+               return new SessionInfo( $this->priority, $info );
+       }
+
+       public function persistsSessionId() {
+               return true;
+       }
+
+       public function canChangeUser() {
+               return true;
+       }
+
+       public function persistSession( SessionBackend $session, WebRequest $request ) {
+               $response = $request->response();
+               if ( $response->headersSent() ) {
+                       // Can't do anything now
+                       $this->logger->debug( __METHOD__ . ': Headers already sent' );
+                       return;
+               }
+
+               $user = $session->getUser();
+
+               $cookies = $this->cookieDataToExport( $user, $session->shouldRememberUser() );
+               $sessionData = $this->sessionDataToExport( $user );
+
+               // Legacy hook
+               if ( $this->params['callUserSetCookiesHook'] && !$user->isAnon() ) {
+                       \Hooks::run( 'UserSetCookies', array( $user, &$sessionData, &$cookies ) );
+               }
+
+               $options = $this->cookieOptions;
+
+               $forceHTTPS = $session->shouldForceHTTPS() || $user->requiresHTTPS();
+               if ( $forceHTTPS ) {
+                       // Don't set the secure flag if the request came in
+                       // over "http", for backwards compat.
+                       // @todo Break that backwards compat properly.
+                       $options['secure'] = $this->config->get( 'CookieSecure' );
+               }
+
+               $response->setCookie( $this->params['sessionName'], $session->getId(), null,
+                       array( 'prefix' => '' ) + $options
+               );
+
+               $extendedCookies = $this->config->get( 'ExtendedLoginCookies' );
+               $extendedExpiry = $this->config->get( 'ExtendedLoginCookieExpiration' );
+
+               foreach ( $cookies as $key => $value ) {
+                       if ( $value === false ) {
+                               $response->clearCookie( $key, $options );
+                       } else {
+                               if ( $extendedExpiry !== null && in_array( $key, $extendedCookies ) ) {
+                                       $expiry = time() + (int)$extendedExpiry;
+                               } else {
+                                       $expiry = 0; // Default cookie expiration
+                               }
+                               $response->setCookie( $key, (string)$value, $expiry, $options );
+                       }
+               }
+
+               $this->setForceHTTPSCookie( $forceHTTPS, $session, $request );
+               $this->setLoggedOutCookie( $session->getLoggedOutTimestamp(), $request );
+
+               if ( $sessionData ) {
+                       $session->addData( $sessionData );
+               }
+       }
+
+       public function unpersistSession( WebRequest $request ) {
+               $response = $request->response();
+               if ( $response->headersSent() ) {
+                       // Can't do anything now
+                       $this->logger->debug( __METHOD__ . ': Headers already sent' );
+                       return;
+               }
+
+               $cookies = array(
+                       'UserID' => false,
+                       'Token' => false,
+               );
+
+               $response->clearCookie(
+                       $this->params['sessionName'], array( 'prefix' => '' ) + $this->cookieOptions
+               );
+
+               foreach ( $cookies as $key => $value ) {
+                       $response->clearCookie( $key, $this->cookieOptions );
+               }
+
+               $this->setForceHTTPSCookie( false, null, $request );
+       }
+
+       /**
+        * Set the "forceHTTPS" cookie
+        * @param bool $set Whether the cookie should be set or not
+        * @param SessionBackend|null $backend
+        * @param WebRequest $request
+        */
+       protected function setForceHTTPSCookie(
+               $set, SessionBackend $backend = null, WebRequest $request
+       ) {
+               $response = $request->response();
+               if ( $set ) {
+                       $response->setCookie( 'forceHTTPS', 'true', $backend->shouldRememberUser() ? 0 : null,
+                               array( 'prefix' => '', 'secure' => false ) + $this->cookieOptions );
+               } else {
+                       $response->clearCookie( 'forceHTTPS',
+                               array( 'prefix' => '', 'secure' => false ) + $this->cookieOptions );
+               }
+       }
+
+       /**
+        * Set the "logged out" cookie
+        * @param int $loggedOut timestamp
+        * @param WebRequest $request
+        */
+       protected function setLoggedOutCookie( $loggedOut, WebRequest $request ) {
+               if ( $loggedOut + 86400 > time() &&
+                       $loggedOut !== (int)$this->getCookie( $request, 'LoggedOut', $this->cookieOptions['prefix'] )
+               ) {
+                       $request->response()->setCookie( 'LoggedOut', $loggedOut, $loggedOut + 86400,
+                               $this->cookieOptions );
+               }
+       }
+
+       public function getVaryCookies() {
+               return array(
+                       // Vary on token and session because those are the real authn
+                       // determiners. UserID and UserName don't matter without those.
+                       $this->cookieOptions['prefix'] . 'Token',
+                       $this->cookieOptions['prefix'] . 'LoggedOut',
+                       $this->params['sessionName'],
+                       'forceHTTPS',
+               );
+       }
+
+       public function suggestLoginUsername( WebRequest $request ) {
+                $name = $this->getCookie( $request, 'UserName', $this->cookieOptions['prefix'] );
+                if ( $name !== null ) {
+                        $name = User::getCanonicalName( $name, 'usable' );
+                }
+                return $name === false ? null : $name;
+       }
+
+       /**
+        * Fetch the user identity from cookies
+        * @param \WebRequest $request
+        * @return array (string|null $id, string|null $username, string|null $token)
+        */
+       protected function getUserInfoFromCookies( $request ) {
+               $prefix = $this->cookieOptions['prefix'];
+               return array(
+                       $this->getCookie( $request, 'UserID', $prefix ),
+                       $this->getCookie( $request, 'UserName', $prefix ),
+                       $this->getCookie( $request, 'Token', $prefix ),
+               );
+       }
+
+       /**
+        * Get a cookie. Contains an auth-specific hack.
+        * @param \WebRequest $request
+        * @param string $key
+        * @param string $prefix
+        * @param mixed $default
+        * @return mixed
+        */
+       protected function getCookie( $request, $key, $prefix, $default = null ) {
+               $value = $request->getCookie( $key, $prefix, $default );
+               if ( $value === 'deleted' ) {
+                       // PHP uses this value when deleting cookies. A legitimate cookie will never have
+                       // this value (usernames start with uppercase, token is longer, other auth cookies
+                       // are booleans or integers). Seeing this means that in a previous request we told the
+                       // client to delete the cookie, but it has poor cookie handling. Pretend the cookie is
+                       // not there to avoid invalidating the session.
+                       return null;
+               }
+               return $value;
+       }
+
+       /**
+        * Return the data to store in cookies
+        * @param User $user
+        * @param bool $remember
+        * @return array $cookies Set value false to unset the cookie
+        */
+       protected function cookieDataToExport( $user, $remember ) {
+               if ( $user->isAnon() ) {
+                       return array(
+                               'UserID' => false,
+                               'Token' => false,
+                       );
+               } else {
+                       return array(
+                               'UserID' => $user->getId(),
+                               'UserName' => $user->getName(),
+                               'Token' => $remember ? (string)$user->getToken() : false,
+                       );
+               }
+       }
+
+       /**
+        * Return extra data to store in the session
+        * @param User $user
+        * @return array $session
+        */
+       protected function sessionDataToExport( $user ) {
+               // If we're calling the legacy hook, we should populate $session
+               // like User::setCookies() did.
+               if ( !$user->isAnon() && $this->params['callUserSetCookiesHook'] ) {
+                       return array(
+                               'wsUserID' => $user->getId(),
+                               'wsToken' => $user->getToken(),
+                               'wsUserName' => $user->getName(),
+                       );
+               }
+
+               return array();
+       }
+
+       public function whyNoSession() {
+               return wfMessage( 'sessionprovider-nocookies' );
+       }
+
+}
diff --git a/includes/session/ImmutableSessionProviderWithCookie.php b/includes/session/ImmutableSessionProviderWithCookie.php
new file mode 100644 (file)
index 0000000..98f7e5c
--- /dev/null
@@ -0,0 +1,153 @@
+<?php
+/**
+ * MediaWiki session provider base class
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use WebRequest;
+
+/**
+ * An ImmutableSessionProviderWithCookie doesn't persist the user, but
+ * optionally can use a cookie to support multiple IDs per session.
+ *
+ * As mentioned in the documentation for SessionProvider, many methods that are
+ * technically "cannot persist ID" could be turned into "can persist ID but
+ * not changing User" using a session cookie. This class implements such an
+ * optional session cookie.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+abstract class ImmutableSessionProviderWithCookie extends SessionProvider {
+
+       /** @var string|null */
+       protected $sessionCookieName = null;
+       protected $sessionCookieOptions = array();
+
+       /**
+        * @param array $params Keys include:
+        *  - sessionCookieName: Session cookie name, if multiple sessions per
+        *    client are to be supported.
+        *  - sessionCookieOptions: Options to pass to WebResponse::setCookie().
+        */
+       public function __construct( $params = array() ) {
+               parent::__construct();
+
+               if ( isset( $params['sessionCookieName'] ) ) {
+                       if ( !is_string( $params['sessionCookieName'] ) ) {
+                               throw new \InvalidArgumentException( 'sessionCookieName must be a string' );
+                       }
+                       $this->sessionCookieName = $params['sessionCookieName'];
+               }
+               if ( isset( $params['sessionCookieOptions'] ) ) {
+                       if ( !is_array( $params['sessionCookieOptions'] ) ) {
+                               throw new \InvalidArgumentException( 'sessionCookieOptions must be an array' );
+                       }
+                       $this->sessionCookieOptions = $params['sessionCookieOptions'];
+               }
+       }
+
+       /**
+        * Get the session ID from the cookie, if any.
+        *
+        * Only call this if $this->sessionCookieName !== null. If
+        * sessionCookieName is null, do some logic (probably involving a call to
+        * $this->hashToSessionId()) to create the single session ID corresponding
+        * to this WebRequest instead of calling this method.
+        *
+        * @param WebRequest $request
+        * @return string|null
+        */
+       protected function getSessionIdFromCookie( WebRequest $request ) {
+               if ( $this->sessionCookieName === null ) {
+                       throw new \BadMethodCallException(
+                               __METHOD__ . ' may not be called when $this->sessionCookieName === null'
+                       );
+               }
+
+               $prefix = isset( $this->sessionCookieOptions['prefix'] )
+                       ? $this->sessionCookieOptions['prefix']
+                       : $this->config->get( 'CookiePrefix' );
+               $id = $request->getCookie( $this->sessionCookieName, $prefix );
+               return SessionManager::validateSessionId( $id ) ? $id : null;
+       }
+
+       public function persistsSessionId() {
+               return $this->sessionCookieName !== null;
+       }
+
+       public function canChangeUser() {
+               return false;
+       }
+
+       public function persistSession( SessionBackend $session, WebRequest $request ) {
+               if ( $this->sessionCookieName === null ) {
+                       return;
+               }
+
+               $response = $request->response();
+               if ( $response->headersSent() ) {
+                       // Can't do anything now
+                       $this->logger->debug( __METHOD__ . ': Headers already sent' );
+                       return;
+               }
+
+               $options = $this->sessionCookieOptions;
+               if ( $session->shouldForceHTTPS() || $session->getUser()->requiresHTTPS() ) {
+                       $response->setCookie( 'forceHTTPS', 'true', $session->shouldRememberUser() ? 0 : null,
+                               array( 'prefix' => '', 'secure' => false ) + $options );
+                       $options['secure'] = true;
+               }
+
+               $response->setCookie( $this->sessionCookieName, $session->getId(), null, $options );
+       }
+
+       public function unpersistSession( WebRequest $request ) {
+               if ( $this->sessionCookieName === null ) {
+                       return;
+               }
+
+               $response = $request->response();
+               if ( $response->headersSent() ) {
+                       // Can't do anything now
+                       $this->logger->debug( __METHOD__ . ': Headers already sent' );
+                       return;
+               }
+
+               $response->clearCookie( $this->sessionCookieName, $this->sessionCookieOptions );
+       }
+
+       public function getVaryCookies() {
+               if ( $this->sessionCookieName === null ) {
+                       return array();
+               }
+
+               $prefix = isset( $this->sessionCookieOptions['prefix'] )
+                       ? $this->sessionCookieOptions['prefix']
+                       : $this->config->get( 'CookiePrefix' );
+               return array( $prefix . $this->sessionCookieName );
+       }
+
+       public function whyNoSession() {
+               return wfMessage( 'sessionprovider-nocookies' );
+       }
+}
diff --git a/includes/session/PHPSessionHandler.php b/includes/session/PHPSessionHandler.php
new file mode 100644 (file)
index 0000000..d21bea9
--- /dev/null
@@ -0,0 +1,377 @@
+<?php
+/**
+ * Session storage in object cache.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LoggerInterface;
+use BagOStuff;
+
+/**
+ * Adapter for PHP's session handling
+ * @todo Once we drop support for PHP < 5.4, use SessionHandlerInterface
+ *  (should just be a matter of adding "implements SessionHandlerInterface" and
+ *  changing the session_set_save_handler() call).
+ * @ingroup Session
+ * @since 1.27
+ */
+class PHPSessionHandler {
+       /** @var PHPSessionHandler */
+       protected static $instance = null;
+
+       /** @var bool Whether PHP session handling is enabled */
+       protected $enable = false;
+       protected $warn = true;
+
+       /** @var SessionManager|null */
+       protected $manager;
+
+       /** @var BagOStuff|null */
+       protected $store;
+
+       /** @var LoggerInterface */
+       protected $logger;
+
+       /** @var array Track original session fields for later modification check */
+       protected $sessionFieldCache = array();
+
+       protected function __construct( SessionManager $manager ) {
+               $this->setEnableFlags(
+                       \RequestContext::getMain()->getConfig()->get( 'PHPSessionHandling' )
+               );
+               $manager->setupPHPSessionHandler( $this );
+       }
+
+       /**
+        * Set $this->enable and $this->warn
+        *
+        * Separate just because there doesn't seem to be a good way to test it
+        * otherwise.
+        *
+        * @param string $PHPSessionHandling See $wgPHPSessionHandling
+        */
+       private function setEnableFlags( $PHPSessionHandling ) {
+               switch ( $PHPSessionHandling ) {
+                       case 'enable':
+                               $this->enable = true;
+                               $this->warn = false;
+                               break;
+
+                       case 'warn':
+                               $this->enable = true;
+                               $this->warn = true;
+                               break;
+
+                       case 'disable':
+                               $this->enable = false;
+                               $this->warn = false;
+                               break;
+               }
+       }
+
+       /**
+        * Test whether the handler is installed
+        * @return bool
+        */
+       public static function isInstalled() {
+               return (bool)self::$instance;
+       }
+
+       /**
+        * Test whether the handler is installed and enabled
+        * @return bool
+        */
+       public static function isEnabled() {
+               return self::$instance && self::$instance->enable;
+       }
+
+       /**
+        * Install a session handler for the current web request
+        * @param SessionManager $manager
+        */
+       public static function install( SessionManager $manager ) {
+               if ( self::$instance ) {
+                       $manager->setupPHPSessionHandler( self::$instance );
+                       return;
+               }
+
+               self::$instance = new self( $manager );
+
+               // Close any auto-started session, before we replace it
+               session_write_close();
+
+               // Tell PHP not to mess with cookies itself
+               ini_set( 'session.use_cookies', 0 );
+               ini_set( 'session.use_trans_sid', 0 );
+
+               // Also set a sane serialization handler
+               \Wikimedia\PhpSessionSerializer::setSerializeHandler();
+
+               session_set_save_handler(
+                       array( self::$instance, 'open' ),
+                       array( self::$instance, 'close' ),
+                       array( self::$instance, 'read' ),
+                       array( self::$instance, 'write' ),
+                       array( self::$instance, 'destroy' ),
+                       array( self::$instance, 'gc' )
+               );
+
+               // It's necessary to register a shutdown function to call session_write_close(),
+               // because by the time the request shutdown function for the session module is
+               // called, other needed objects may have already been destroyed. Shutdown functions
+               // registered this way are called before object destruction.
+               register_shutdown_function( array( self::$instance, 'handleShutdown' ) );
+       }
+
+       /**
+        * Set the manager, store, and logger
+        * @private Use self::install().
+        * @param SessionManager $manager
+        * @param BagOStuff $store
+        * @param LoggerInterface $store
+        */
+       public function setManager(
+               SessionManager $manager, BagOStuff $store, LoggerInterface $logger
+       ) {
+               if ( $this->manager !== $manager ) {
+                       // Close any existing session before we change stores
+                       if ( $this->manager ) {
+                               session_write_close();
+                       }
+                       $this->manager = $manager;
+                       $this->store = $store;
+                       $this->logger = $logger;
+                       \Wikimedia\PhpSessionSerializer::setLogger( $this->logger );
+               }
+       }
+
+       /**
+        * Initialize the session (handler)
+        * @private For internal use only
+        * @param string $save_path Path used to store session files (ignored)
+        * @param string $session_name Session name (ignored)
+        * @return bool Success
+        */
+       public function open( $save_path, $session_name ) {
+               if ( self::$instance !== $this ) {
+                       throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
+               }
+               if ( !$this->enable ) {
+                       throw new \BadMethodCallException( 'Attempt to use PHP session management' );
+               }
+               return true;
+       }
+
+       /**
+        * Close the session (handler)
+        * @private For internal use only
+        * @return bool Success
+        */
+       public function close() {
+               if ( self::$instance !== $this ) {
+                       throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
+               }
+               $this->sessionFieldCache = array();
+               return true;
+       }
+
+       /**
+        * Read session data
+        * @private For internal use only
+        * @param string $id Session id
+        * @return string Session data
+        */
+       public function read( $id ) {
+               if ( self::$instance !== $this ) {
+                       throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
+               }
+               if ( !$this->enable ) {
+                       throw new \BadMethodCallException( 'Attempt to use PHP session management' );
+               }
+
+               $session = $this->manager->getSessionById( $id, false );
+               if ( !$session ) {
+                       return '';
+               }
+               $session->persist();
+
+               $data = iterator_to_array( $session );
+               $this->sessionFieldCache[$id] = $data;
+               return (string)\Wikimedia\PhpSessionSerializer::encode( $data );
+       }
+
+       /**
+        * Write session data
+        * @private For internal use only
+        * @param string $id Session id
+        * @param string $dataStr Session data. Not that you should ever call this
+        *   directly, but note that this has the same issues with code injection
+        *   via user-controlled data as does PHP's unserialize function.
+        * @return bool Success
+        */
+       public function write( $id, $dataStr ) {
+               if ( self::$instance !== $this ) {
+                       throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
+               }
+               if ( !$this->enable ) {
+                       throw new \BadMethodCallException( 'Attempt to use PHP session management' );
+               }
+
+               $session = $this->manager->getSessionById( $id, true );
+               if ( !$session ) {
+                       // This can happen under normal circumstances, if the session exists but is
+                       // invalid. Let's emit a log warning instead of a PHP warning.
+                       $this->logger->warning(
+                               __METHOD__ . ": Session \"$id\" cannot be loaded, skipping write."
+                       );
+                       return true;
+               }
+
+               // First, decode the string PHP handed us
+               $data = \Wikimedia\PhpSessionSerializer::decode( $dataStr );
+               if ( $data === null ) {
+                       // @codeCoverageIgnoreStart
+                       return false;
+                       // @codeCoverageIgnoreEnd
+               }
+
+               // Now merge the data into the Session object.
+               $changed = false;
+               $cache = isset( $this->sessionFieldCache[$id] ) ? $this->sessionFieldCache[$id] : array();
+               foreach ( $data as $key => $value ) {
+                       if ( !array_key_exists( $key, $cache ) ) {
+                               if ( $session->exists( $key ) ) {
+                                       // New in both, so ignore and log
+                                       $this->logger->warning(
+                                               __METHOD__ . ": Key \"$key\" added in both Session and \$_SESSION!"
+                                       );
+                               } else {
+                                       // New in $_SESSION, keep it
+                                       $session->set( $key, $value );
+                                       $changed = true;
+                               }
+                       } elseif ( $cache[$key] === $value ) {
+                               // Unchanged in $_SESSION, so ignore it
+                       } elseif ( !$session->exists( $key ) ) {
+                               // Deleted in Session, keep but log
+                               $this->logger->warning(
+                                       __METHOD__ . ": Key \"$key\" deleted in Session and changed in \$_SESSION!"
+                               );
+                               $session->set( $key, $value );
+                               $changed = true;
+                       } elseif ( $cache[$key] === $session->get( $key ) ) {
+                               // Unchanged in Session, so keep it
+                               $session->set( $key, $value );
+                               $changed = true;
+                       } else {
+                               // Changed in both, so ignore and log
+                               $this->logger->warning(
+                                       __METHOD__ . ": Key \"$key\" changed in both Session and \$_SESSION!"
+                               );
+                       }
+               }
+               // Anything deleted in $_SESSION and unchanged in Session should be deleted too
+               // (but not if $_SESSION can't represent it at all)
+               \Wikimedia\PhpSessionSerializer::setLogger( new \Psr\Log\NullLogger() );
+               foreach ( $cache as $key => $value ) {
+                       if ( !array_key_exists( $key, $data ) && $session->exists( $key ) &&
+                               \Wikimedia\PhpSessionSerializer::encode( array( $key => true ) )
+                       ) {
+                               if ( $cache[$key] === $session->get( $key ) ) {
+                                       // Unchanged in Session, delete it
+                                       $session->remove( $key );
+                                       $changed = true;
+                               } else {
+                                       // Changed in Session, ignore deletion and log
+                                       $this->logger->warning(
+                                               __METHOD__ . ": Key \"$key\" changed in Session and deleted in \$_SESSION!"
+                                       );
+                               }
+                       }
+               }
+               \Wikimedia\PhpSessionSerializer::setLogger( $this->logger );
+
+               // Save and update cache if anything changed
+               if ( $changed ) {
+                       if ( $this->warn ) {
+                               wfDeprecated( '$_SESSION', '1.27' );
+                               $this->logger->warning( 'Something wrote to $_SESSION!' );
+                       }
+
+                       $session->save();
+                       $this->sessionFieldCache[$id] = iterator_to_array( $session );
+               }
+
+               $session->persist();
+
+               return true;
+       }
+
+       /**
+        * Destroy a session
+        * @private For internal use only
+        * @param string $id Session id
+        * @return bool Success
+        */
+       public function destroy( $id ) {
+               if ( self::$instance !== $this ) {
+                       throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
+               }
+               if ( !$this->enable ) {
+                       throw new \BadMethodCallException( 'Attempt to use PHP session management' );
+               }
+               $session = $this->manager->getSessionById( $id, false );
+               if ( $session ) {
+                       $session->clear();
+               }
+               return true;
+       }
+
+       /**
+        * Execute garbage collection.
+        * @private For internal use only
+        * @param int $maxlifetime Maximum session life time (ignored)
+        * @return bool Success
+        */
+       public function gc( $maxlifetime ) {
+               if ( self::$instance !== $this ) {
+                       throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
+               }
+               $before = date( 'YmdHis', time() );
+               $this->store->deleteObjectsExpiringBefore( $before );
+               return true;
+       }
+
+       /**
+        * Shutdown function.
+        *
+        * See the comment inside self::install for rationale.
+        * @codeCoverageIgnore
+        * @private For internal use only
+        */
+       public function handleShutdown() {
+               if ( $this->enable ) {
+                       session_write_close();
+               }
+       }
+
+}
diff --git a/includes/session/Session.php b/includes/session/Session.php
new file mode 100644 (file)
index 0000000..4ad69ae
--- /dev/null
@@ -0,0 +1,424 @@
+<?php
+/**
+ * MediaWiki session
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use User;
+use WebRequest;
+
+/**
+ * Manages data for an an authenticated session
+ *
+ * A Session represents the fact that the current HTTP request is part of a
+ * session. There are two broad types of Sessions, based on whether they
+ * return true or false from self::canSetUser():
+ * * When true (mutable), the Session identifies multiple requests as part of
+ *   a session generically, with no tie to a particular user.
+ * * When false (immutable), the Session identifies multiple requests as part
+ *   of a session by identifying and authenticating the request itself as
+ *   belonging to a particular user.
+ *
+ * The Session object also serves as a replacement for PHP's $_SESSION,
+ * managing access to per-session data.
+ *
+ * @todo Once we drop support for PHP 5.3.3, implementing ArrayAccess would be nice.
+ * @ingroup Session
+ * @since 1.27
+ */
+final class Session implements \Countable, \Iterator {
+       /** @var SessionBackend Session backend */
+       private $backend;
+
+       /** @var int Session index */
+       private $index;
+
+       /**
+        * @param SessionBackend $backend
+        * @param int $index
+        */
+       public function __construct( SessionBackend $backend, $index ) {
+               $this->backend = $backend;
+               $this->index = $index;
+       }
+
+       public function __destruct() {
+               $this->backend->deregisterSession( $this->index );
+       }
+
+       /**
+        * Returns the session ID
+        * @return string
+        */
+       public function getId() {
+               return $this->backend->getId();
+       }
+
+       /**
+        * Returns the SessionId object
+        * @private For internal use by WebRequest
+        * @return SessionId
+        */
+       public function getSessionId() {
+               return $this->backend->getSessionId();
+       }
+
+       /**
+        * Changes the session ID
+        * @return string New ID (might be the same as the old)
+        */
+       public function resetId() {
+               return $this->backend->resetId();
+       }
+
+       /**
+        * Fetch the SessionProvider for this session
+        * @return SessionProviderInterface
+        */
+       public function getProvider() {
+               return $this->backend->getProvider();
+       }
+
+       /**
+        * Indicate whether this session is persisted across requests
+        *
+        * For example, if cookies are set.
+        *
+        * @return bool
+        */
+       public function isPersistent() {
+               return $this->backend->isPersistent();
+       }
+
+       /**
+        * Make this session persisted across requests
+        *
+        * If the session is already persistent, equivalent to calling
+        * $this->renew().
+        */
+       public function persist() {
+               $this->backend->persist();
+       }
+
+       /**
+        * Indicate whether the user should be remembered independently of the
+        * session ID.
+        * @return bool
+        */
+       public function shouldRememberUser() {
+               return $this->backend->shouldRememberUser();
+       }
+
+       /**
+        * Set whether the user should be remembered independently of the session
+        * ID.
+        * @param bool $remember
+        */
+       public function setRememberUser( $remember ) {
+               $this->backend->setRememberUser( $remember );
+       }
+
+       /**
+        * Returns the request associated with this session
+        * @return WebRequest
+        */
+       public function getRequest() {
+               return $this->backend->getRequest( $this->index );
+       }
+
+       /**
+        * Returns the authenticated user for this session
+        * @return User
+        */
+       public function getUser() {
+               return $this->backend->getUser();
+       }
+
+       /**
+        * Fetch the rights allowed the user when this session is active.
+        * @return null|string[] Allowed user rights, or null to allow all.
+        */
+       public function getAllowedUserRights() {
+               return $this->backend->getAllowedUserRights();
+       }
+
+       /**
+        * Indicate whether the session user info can be changed
+        * @return bool
+        */
+       public function canSetUser() {
+               return $this->backend->canSetUser();
+       }
+
+       /**
+        * Set a new user for this session
+        * @note This should only be called when the user has been authenticated
+        * @param User $user User to set on the session.
+        *   This may become a "UserValue" in the future, or User may be refactored
+        *   into such.
+        */
+       public function setUser( $user ) {
+               $this->backend->setUser( $user );
+       }
+
+       /**
+        * Get a suggested username for the login form
+        * @return string|null
+        */
+       public function suggestLoginUsername() {
+               return $this->backend->suggestLoginUsername( $this->index );
+       }
+
+       /**
+        * Whether HTTPS should be forced
+        * @return bool
+        */
+       public function shouldForceHTTPS() {
+               return $this->backend->shouldForceHTTPS();
+       }
+
+       /**
+        * Set whether HTTPS should be forced
+        * @param bool $force
+        */
+       public function setForceHTTPS( $force ) {
+               $this->backend->setForceHTTPS( $force );
+       }
+
+       /**
+        * Fetch the "logged out" timestamp
+        * @return int
+        */
+       public function getLoggedOutTimestamp() {
+               return $this->backend->getLoggedOutTimestamp();
+       }
+
+       /**
+        * Set the "logged out" timestamp
+        * @param int $ts
+        */
+       public function setLoggedOutTimestamp( $ts ) {
+               $this->backend->setLoggedOutTimestamp( $ts );
+       }
+
+       /**
+        * Fetch provider metadata
+        * @protected For use by SessionProvider subclasses only
+        * @return mixed
+        */
+       public function getProviderMetadata() {
+               return $this->backend->getProviderMetadata();
+       }
+
+       /**
+        * Delete all session data and clear the user (if possible)
+        */
+       public function clear() {
+               $data = &$this->backend->getData();
+               if ( $data ) {
+                       $data = array();
+                       $this->backend->dirty();
+               }
+               if ( $this->backend->canSetUser() ) {
+                       $this->backend->setUser( new User );
+               }
+               $this->backend->save();
+       }
+
+       /**
+        * Renew the session
+        *
+        * Resets the TTL in the backend store if the session is near expiring, and
+        * re-persists the session to any active WebRequests if persistent.
+        */
+       public function renew() {
+               $this->backend->renew();
+       }
+
+       /**
+        * Fetch a copy of this session attached to an alternative WebRequest
+        *
+        * Actions on the copy will affect this session too, and vice versa.
+        *
+        * @param WebRequest $request Any existing session associated with this
+        *  WebRequest object will be overwritten.
+        * @return Session
+        */
+       public function sessionWithRequest( WebRequest $request ) {
+               $request->setSessionId( $this->backend->getSessionId() );
+               return $this->backend->getSession( $request );
+       }
+
+       /**
+        * Fetch a value from the session
+        * @param string|int $key
+        * @param mixed $default
+        * @return mixed
+        */
+       public function get( $key, $default = null ) {
+               $data = &$this->backend->getData();
+               return array_key_exists( $key, $data ) ? $data[$key] : $default;
+       }
+
+       /**
+        * Test if a value exists in the session
+        * @param string|int $key
+        * @return bool
+        */
+       public function exists( $key ) {
+               $data = &$this->backend->getData();
+               return array_key_exists( $key, $data );
+       }
+
+       /**
+        * Set a value in the session
+        * @param string|int $key
+        * @param mixed $value
+        */
+       public function set( $key, $value ) {
+               $data = &$this->backend->getData();
+               if ( !array_key_exists( $key, $data ) || $data[$key] !== $value ) {
+                       $data[$key] = $value;
+                       $this->backend->dirty();
+               }
+       }
+
+       /**
+        * Remove a value from the session
+        * @param string|int $key
+        */
+       public function remove( $key ) {
+               $data = &$this->backend->getData();
+               if ( array_key_exists( $key, $data ) ) {
+                       unset( $data[$key] );
+                       $this->backend->dirty();
+               }
+       }
+
+       /**
+        * Fetch a CSRF token from the session
+        *
+        * Note that this does not persist the session, which you'll probably want
+        * to do if you want the token to actually be useful.
+        *
+        * @param string|string[] $salt Token salt
+        * @param string $key Token key
+        * @return MediaWiki\\Session\\SessionToken
+        */
+       public function getToken( $salt = '', $key = 'default' ) {
+               $new = false;
+               $secrets = $this->get( 'wsTokenSecrets' );
+               if ( !is_array( $secrets ) ) {
+                       $secrets = array();
+               }
+               if ( isset( $secrets[$key] ) && is_string( $secrets[$key] ) ) {
+                       $secret = $secrets[$key];
+               } else {
+                       $secret = \MWCryptRand::generateHex( 32 );
+                       $secrets[$key] = $secret;
+                       $this->set( 'wsTokenSecrets', $secrets );
+                       $new = true;
+               }
+               if ( is_array( $salt ) ) {
+                       $salt = join( '|', $salt );
+               }
+               return new Token( $secret, (string)$salt, $new );
+       }
+
+       /**
+        * Remove a CSRF token from the session
+        *
+        * The next call to self::getToken() with $key will generate a new secret.
+        *
+        * @param string $key Token key
+        */
+       public function resetToken( $key = 'default' ) {
+               $secrets = $this->get( 'wsTokenSecrets' );
+               if ( is_array( $secrets ) && isset( $secrets[$key] ) ) {
+                       unset( $secrets[$key] );
+                       $this->set( 'wsTokenSecrets', $secrets );
+               }
+       }
+
+       /**
+        * Remove all CSRF tokens from the session
+        */
+       public function resetAllTokens() {
+               $this->remove( 'wsTokenSecrets' );
+       }
+
+       /**
+        * Delay automatic saving while multiple updates are being made
+        *
+        * Calls to save() or clear() will not be delayed.
+        *
+        * @return \ScopedCallback When this goes out of scope, a save will be triggered
+        */
+       public function delaySave() {
+               return $this->backend->delaySave();
+       }
+
+       /**
+        * Save the session
+        */
+       public function save() {
+               $this->backend->save();
+       }
+
+       /**
+        * @name Interface methods
+        * @{
+        */
+
+       public function count() {
+               $data = &$this->backend->getData();
+               return count( $data );
+       }
+
+       public function current() {
+               $data = &$this->backend->getData();
+               return current( $data );
+       }
+
+       public function key() {
+               $data = &$this->backend->getData();
+               return key( $data );
+       }
+
+       public function next() {
+               $data = &$this->backend->getData();
+               next( $data );
+       }
+
+       public function rewind() {
+               $data = &$this->backend->getData();
+               reset( $data );
+       }
+
+       public function valid() {
+               $data = &$this->backend->getData();
+               return key( $data ) !== null;
+       }
+
+       /**@}*/
+
+}
diff --git a/includes/session/SessionBackend.php b/includes/session/SessionBackend.php
new file mode 100644 (file)
index 0000000..488f6e7
--- /dev/null
@@ -0,0 +1,655 @@
+<?php
+/**
+ * MediaWiki session backend
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use CachedBagOStuff;
+use Psr\Log\LoggerInterface;
+use User;
+use WebRequest;
+
+/**
+ * This is the actual workhorse for Session.
+ *
+ * Most code does not need to use this class, you want \\MediaWiki\\Session\\Session.
+ * The exceptions are SessionProviders and SessionMetadata hook functions,
+ * which get an instance of this class rather than Session.
+ *
+ * The reasons for this split are:
+ * 1. A session can be attached to multiple requests, but we want the Session
+ *    object to have some features that correspond to just one of those
+ *    requests.
+ * 2. We want reasonable garbage collection behavior, but we also want the
+ *    SessionManager to hold a reference to every active session so it can be
+ *    saved when the request ends.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+final class SessionBackend {
+       /** @var SessionId */
+       private $id;
+
+       private $persist = false;
+       private $remember = false;
+       private $forceHTTPS = false;
+
+       /** @var array|null */
+       private $data = null;
+
+       private $forcePersist = false;
+       private $metaDirty = false;
+       private $dataDirty = false;
+
+       /** @var string Used to detect subarray modifications */
+       private $dataHash = null;
+
+       /** @var CachedBagOStuff */
+       private $store;
+
+       /** @var LoggerInterface */
+       private $logger;
+
+       /** @var int */
+       private $lifetime;
+
+       /** @var User */
+       private $user;
+
+       private $curIndex = 0;
+
+       /** @var WebRequest[] Session requests */
+       private $requests = array();
+
+       /** @var SessionProvider provider */
+       private $provider;
+
+       /** @var array|null provider-specified metadata */
+       private $providerMetadata = null;
+
+       private $expires = 0;
+       private $loggedOut = 0;
+       private $delaySave = 0;
+
+       private $usePhpSessionHandling = true;
+       private $checkPHPSessionRecursionGuard = false;
+
+       /**
+        * @param SessionId $id Session ID object
+        * @param SessionInfo $info Session info to populate from
+        * @param CachedBagOStuff $store Backend data store
+        * @param LoggerInterface $logger
+        * @param int $lifetime Session data lifetime in seconds
+        */
+       public function __construct(
+               SessionId $id, SessionInfo $info, CachedBagOStuff $store, LoggerInterface $logger, $lifetime
+       ) {
+               $phpSessionHandling = \RequestContext::getMain()->getConfig()->get( 'PHPSessionHandling' );
+               $this->usePhpSessionHandling = $phpSessionHandling !== 'disable';
+
+               if ( $info->getUserInfo() && !$info->getUserInfo()->isVerified() ) {
+                       throw new \InvalidArgumentException(
+                               "Refusing to create session for unverified user {$info->getUserInfo()}"
+                       );
+               }
+               if ( $info->getProvider() === null ) {
+                       throw new \InvalidArgumentException( 'Cannot create session without a provider' );
+               }
+               if ( $info->getId() !== $id->getId() ) {
+                       throw new \InvalidArgumentException( 'SessionId and SessionInfo don\'t match' );
+               }
+
+               $this->id = $id;
+               $this->user = $info->getUserInfo() ? $info->getUserInfo()->getUser() : new User;
+               $this->store = $store;
+               $this->logger = $logger;
+               $this->lifetime = $lifetime;
+               $this->provider = $info->getProvider();
+               $this->persist = $info->wasPersisted();
+               $this->remember = $info->wasRemembered();
+               $this->forceHTTPS = $info->forceHTTPS();
+               $this->providerMetadata = $info->getProviderMetadata();
+
+               $blob = $store->get( wfMemcKey( 'MWSession', (string)$this->id ) );
+               if ( !is_array( $blob ) ||
+                       !isset( $blob['metadata'] ) || !is_array( $blob['metadata'] ) ||
+                       !isset( $blob['data'] ) || !is_array( $blob['data'] )
+               ) {
+                       $this->data = array();
+                       $this->dataDirty = true;
+                       $this->metaDirty = true;
+                       $this->logger->debug( "SessionBackend $this->id is unsaved, marking dirty in constructor" );
+               } else {
+                       $this->data = $blob['data'];
+                       if ( isset( $blob['metadata']['loggedOut'] ) ) {
+                               $this->loggedOut = (int)$blob['metadata']['loggedOut'];
+                       }
+                       if ( isset( $blob['metadata']['expires'] ) ) {
+                               $this->expires = (int)$blob['metadata']['expires'];
+                       } else {
+                               $this->metaDirty = true;
+                               $this->logger->debug(
+                                       "SessionBackend $this->id metadata dirty due to missing expiration timestamp"
+                               );
+                       }
+               }
+               $this->dataHash = md5( serialize( $this->data ) );
+       }
+
+       /**
+        * Return a new Session for this backend
+        * @param WebRequest $request
+        * @return Session
+        */
+       public function getSession( WebRequest $request ) {
+               $index = ++$this->curIndex;
+               $this->requests[$index] = $request;
+               $session = new Session( $this, $index );
+               return $session;
+       }
+
+       /**
+        * Deregister a Session
+        * @private For use by \\MediaWiki\\Session\\Session::__destruct() only
+        * @param int $index
+        */
+       public function deregisterSession( $index ) {
+               unset( $this->requests[$index] );
+               if ( !count( $this->requests ) ) {
+                       $this->save( true );
+                       $this->provider->getManager()->deregisterSessionBackend( $this );
+               }
+       }
+
+       /**
+        * Returns the session ID.
+        * @return string
+        */
+       public function getId() {
+               return (string)$this->id;
+       }
+
+       /**
+        * Fetch the SessionId object
+        * @private For internal use by WebRequest
+        * @return SessionId
+        */
+       public function getSessionId() {
+               return $this->id;
+       }
+
+       /**
+        * Changes the session ID
+        * @return string New ID (might be the same as the old)
+        */
+       public function resetId() {
+               if ( $this->provider->persistsSessionId() ) {
+                       $oldId = (string)$this->id;
+                       $restart = $this->usePhpSessionHandling && $oldId === session_id() &&
+                               PHPSessionHandler::isEnabled();
+
+                       if ( $restart ) {
+                               // If this session is the one behind PHP's $_SESSION, we need
+                               // to close then reopen it.
+                               session_write_close();
+                       }
+
+                       $this->provider->getManager()->changeBackendId( $this );
+                       $this->provider->sessionIdWasReset( $this, $oldId );
+                       $this->metaDirty = true;
+                       $this->logger->debug(
+                               "SessionBackend $this->id metadata dirty due to ID reset (formerly $oldId)"
+                       );
+
+                       if ( $restart ) {
+                               session_id( (string)$this->id );
+                               \MediaWiki\quietCall( 'session_start' );
+                       }
+
+                       $this->autosave();
+
+                       // Delete the data for the old session ID now
+                       $this->store->delete( wfMemcKey( 'MWSession', $oldId ) );
+               }
+       }
+
+       /**
+        * Fetch the SessionProvider for this session
+        * @return SessionProviderInterface
+        */
+       public function getProvider() {
+               return $this->provider;
+       }
+
+       /**
+        * Indicate whether this session is persisted across requests
+        *
+        * For example, if cookies are set.
+        *
+        * @return bool
+        */
+       public function isPersistent() {
+               return $this->persist;
+       }
+
+       /**
+        * Make this session persisted across requests
+        *
+        * If the session is already persistent, equivalent to calling
+        * $this->renew().
+        */
+       public function persist() {
+               if ( !$this->persist ) {
+                       $this->persist = true;
+                       $this->forcePersist = true;
+                       $this->metaDirty = true;
+                       $this->logger->debug( "SessionBackend $this->id force-persist due to persist()" );
+                       $this->autosave();
+               } else {
+                       $this->renew();
+               }
+       }
+
+       /**
+        * Indicate whether the user should be remembered independently of the
+        * session ID.
+        * @return bool
+        */
+       public function shouldRememberUser() {
+               return $this->remember;
+       }
+
+       /**
+        * Set whether the user should be remembered independently of the session
+        * ID.
+        * @param bool $remember
+        */
+       public function setRememberUser( $remember ) {
+               if ( $this->remember !== (bool)$remember ) {
+                       $this->remember = (bool)$remember;
+                       $this->metaDirty = true;
+                       $this->logger->debug( "SessionBackend $this->id metadata dirty due to remember-user change" );
+                       $this->autosave();
+               }
+       }
+
+       /**
+        * Returns the request associated with a Session
+        * @param int $index Session index
+        * @return WebRequest
+        */
+       public function getRequest( $index ) {
+               if ( !isset( $this->requests[$index] ) ) {
+                       throw new \InvalidArgumentException( 'Invalid session index' );
+               }
+               return $this->requests[$index];
+       }
+
+       /**
+        * Returns the authenticated user for this session
+        * @return User
+        */
+       public function getUser() {
+               return $this->user;
+       }
+
+       /**
+        * Fetch the rights allowed the user when this session is active.
+        * @return null|string[] Allowed user rights, or null to allow all.
+        */
+       public function getAllowedUserRights() {
+               return $this->provider->getAllowedUserRights( $this );
+       }
+
+       /**
+        * Indicate whether the session user info can be changed
+        * @return bool
+        */
+       public function canSetUser() {
+               return $this->provider->canChangeUser();
+       }
+
+       /**
+        * Set a new user for this session
+        * @note This should only be called when the user has been authenticated via a login process
+        * @param User $user User to set on the session.
+        *   This may become a "UserValue" in the future, or User may be refactored
+        *   into such.
+        */
+       public function setUser( $user ) {
+               if ( !$this->canSetUser() ) {
+                       throw new \BadMethodCallException(
+                               'Cannot set user on this session; check $session->canSetUser() first'
+                       );
+               }
+
+               $this->user = $user;
+               $this->metaDirty = true;
+               $this->logger->debug( "SessionBackend $this->id metadata dirty due to user change" );
+               $this->autosave();
+       }
+
+       /**
+        * Get a suggested username for the login form
+        * @param int $index Session index
+        * @return string|null
+        */
+       public function suggestLoginUsername( $index ) {
+               if ( !isset( $this->requests[$index] ) ) {
+                       throw new \InvalidArgumentException( 'Invalid session index' );
+               }
+               return $this->provider->suggestLoginUsername( $this->requests[$index] );
+       }
+
+       /**
+        * Whether HTTPS should be forced
+        * @return bool
+        */
+       public function shouldForceHTTPS() {
+               return $this->forceHTTPS;
+       }
+
+       /**
+        * Set whether HTTPS should be forced
+        * @param bool $force
+        */
+       public function setForceHTTPS( $force ) {
+               if ( $this->forceHTTPS !== (bool)$force ) {
+                       $this->forceHTTPS = (bool)$force;
+                       $this->metaDirty = true;
+                       $this->logger->debug( "SessionBackend $this->id metadata dirty due to force-HTTPS change" );
+                       $this->autosave();
+               }
+       }
+
+       /**
+        * Fetch the "logged out" timestamp
+        * @return int
+        */
+       public function getLoggedOutTimestamp() {
+               return $this->loggedOut;
+       }
+
+       /**
+        * Set the "logged out" timestamp
+        * @param int $ts
+        */
+       public function setLoggedOutTimestamp( $ts = null ) {
+               $ts = (int)$ts;
+               if ( $this->loggedOut !== $ts ) {
+                       $this->loggedOut = $ts;
+                       $this->metaDirty = true;
+                       $this->logger->debug(
+                               "SessionBackend $this->id metadata dirty due to logged-out-timestamp change"
+                       );
+                       $this->autosave();
+               }
+       }
+
+       /**
+        * Fetch provider metadata
+        * @protected For use by SessionProvider subclasses only
+        * @return array|null
+        */
+       public function getProviderMetadata() {
+               return $this->providerMetadata;
+       }
+
+       /**
+        * Set provider metadata
+        * @protected For use by SessionProvider subclasses only
+        * @param array|null $metadata
+        */
+       public function setProviderMetadata( $metadata ) {
+               if ( $metadata !== null && !is_array( $metadata ) ) {
+                       throw new \InvalidArgumentException( '$metadata must be an array or null' );
+               }
+               if ( $this->providerMetadata !== $metadata ) {
+                       $this->providerMetadata = $metadata;
+                       $this->metaDirty = true;
+                       $this->logger->debug(
+                               "SessionBackend $this->id metadata dirty due to provider metadata change"
+                       );
+                       $this->autosave();
+               }
+       }
+
+       /**
+        * Fetch the session data array
+        *
+        * Note the caller is responsible for calling $this->dirty() if anything in
+        * the array is changed.
+        *
+        * @private For use by \\MediaWiki\\Session\\Session only.
+        * @return array
+        */
+       public function &getData() {
+               return $this->data;
+       }
+
+       /**
+        * Add data to the session.
+        *
+        * Overwrites any existing data under the same keys.
+        *
+        * @param array $newData Key-value pairs to add to the session
+        */
+       public function addData( array $newData ) {
+               $data = &$this->getData();
+               foreach ( $newData as $key => $value ) {
+                       if ( !array_key_exists( $key, $data ) || $data[$key] !== $value ) {
+                               $data[$key] = $value;
+                               $this->dataDirty = true;
+                               $this->logger->debug(
+                                       "SessionBackend $this->id data dirty due to addData(): " . wfGetAllCallers( 5 )
+                               );
+                       }
+               }
+       }
+
+       /**
+        * Mark data as dirty
+        * @private For use by \\MediaWiki\\Session\\Session only.
+        */
+       public function dirty() {
+               $this->dataDirty = true;
+               $this->logger->debug(
+                       "SessionBackend $this->id data dirty due to dirty(): " . wfGetAllCallers( 5 )
+               );
+       }
+
+       /**
+        * Renew the session by resaving everything
+        *
+        * Resets the TTL in the backend store if the session is near expiring, and
+        * re-persists the session to any active WebRequests if persistent.
+        */
+       public function renew() {
+               if ( time() + $this->lifetime / 2 > $this->expires ) {
+                       $this->metaDirty = true;
+                       $this->logger->debug(
+                               "SessionBackend $this->id metadata dirty for renew(): " . wfGetAllCallers( 5 )
+                       );
+                       if ( $this->persist ) {
+                               $this->forcePersist = true;
+                               $this->logger->debug(
+                                       "SessionBackend $this->id force-persist for renew(): " . wfGetAllCallers( 5 )
+                               );
+                       }
+               }
+               $this->autosave();
+       }
+
+       /**
+        * Delay automatic saving while multiple updates are being made
+        *
+        * Calls to save() will not be delayed.
+        *
+        * @return \ScopedCallback When this goes out of scope, a save will be triggered
+        */
+       public function delaySave() {
+               $that = $this;
+               $this->delaySave++;
+               $ref = &$this->delaySave;
+               return new \ScopedCallback( function () use ( $that, &$ref ) {
+                       if ( --$ref <= 0 ) {
+                               $ref = 0;
+                               $that->save();
+                       }
+               } );
+       }
+
+       /**
+        * Save and persist session data, unless delayed
+        */
+       private function autosave() {
+               if ( $this->delaySave <= 0 ) {
+                       $this->save();
+               }
+       }
+
+       /**
+        * Save and persist session data
+        * @param bool $closing Whether the session is being closed
+        */
+       public function save( $closing = false ) {
+               if ( $this->provider->getManager()->isUserSessionPrevented( $this->user->getName() ) ) {
+                       $this->logger->debug(
+                               "SessionBackend $this->id not saving, " .
+                                       "user {$this->user} was passed to SessionManager::preventSessionsForUser"
+                       );
+                       return;
+               }
+
+               // Ensure the user has a token
+               // @codeCoverageIgnoreStart
+               $anon = $this->user->isAnon();
+               if ( !$anon && !$this->user->getToken( false ) ) {
+                       $this->logger->debug(
+                               "SessionBackend $this->id creating token for user {$this->user} on save"
+                       );
+                       $this->user->setToken();
+                       if ( !wfReadOnly() ) {
+                               $this->user->saveSettings();
+                       }
+                       $this->metaDirty = true;
+               }
+               // @codeCoverageIgnoreEnd
+
+               if ( !$this->metaDirty && !$this->dataDirty &&
+                       $this->dataHash !== md5( serialize( $this->data ) )
+               ) {
+                       $this->logger->debug( "SessionBackend $this->id data dirty due to hash mismatch, " .
+                               "$this->dataHash !== " . md5( serialize( $this->data ) ) );
+                       $this->dataDirty = true;
+               }
+
+               if ( !$this->metaDirty && !$this->dataDirty && !$this->forcePersist ) {
+                       return;
+               }
+
+               $this->logger->debug( "SessionBackend $this->id save: " .
+                       'dataDirty=' . (int)$this->dataDirty . ' ' .
+                       'metaDirty=' . (int)$this->metaDirty . ' ' .
+                       'forcePersist=' . (int)$this->forcePersist
+               );
+
+               // Persist to the provider, if flagged
+               if ( $this->persist && ( $this->metaDirty || $this->forcePersist ) ) {
+                       foreach ( $this->requests as $request ) {
+                               $request->setSessionId( $this->getSessionId() );
+                               $this->provider->persistSession( $this, $request );
+                       }
+                       if ( !$closing ) {
+                               $this->checkPHPSession();
+                       }
+               }
+
+               $this->forcePersist = false;
+
+               if ( !$this->metaDirty && !$this->dataDirty ) {
+                       return;
+               }
+
+               // Save session data to store, if necessary
+               $metadata = $origMetadata = array(
+                       'provider' => (string)$this->provider,
+                       'providerMetadata' => $this->providerMetadata,
+                       'userId' => $anon ? 0 : $this->user->getId(),
+                       'userName' => User::isValidUserName( $this->user->getName() ) ? $this->user->getName() : null,
+                       'userToken' => $anon ? null : $this->user->getToken(),
+                       'remember' => !$anon && $this->remember,
+                       'forceHTTPS' => $this->forceHTTPS,
+                       'expires' => time() + $this->lifetime,
+                       'loggedOut' => $this->loggedOut,
+                       'persisted' => $this->persist,
+               );
+
+               \Hooks::run( 'SessionMetadata', array( $this, &$metadata, $this->requests ) );
+
+               foreach ( $origMetadata as $k => $v ) {
+                       if ( $metadata[$k] !== $v ) {
+                               throw new \UnexpectedValueException( "SessionMetadata hook changed metadata key \"$k\"" );
+                       }
+               }
+
+               $this->store->set(
+                       wfMemcKey( 'MWSession', (string)$this->id ),
+                       array(
+                               'data' => $this->data,
+                               'metadata' => $metadata,
+                       ),
+                       $metadata['expires'],
+                       $this->persist ? 0 : CachedBagOStuff::WRITE_CACHE_ONLY
+               );
+
+               $this->metaDirty = false;
+               $this->dataDirty = false;
+               $this->dataHash = md5( serialize( $this->data ) );
+               $this->expires = $metadata['expires'];
+       }
+
+       /**
+        * For backwards compatibility, open the PHP session when the global
+        * session is persisted
+        */
+       private function checkPHPSession() {
+               if ( !$this->checkPHPSessionRecursionGuard ) {
+                       $this->checkPHPSessionRecursionGuard = true;
+                       $ref = &$this->checkPHPSessionRecursionGuard;
+                       $reset = new \ScopedCallback( function () use ( &$ref ) {
+                               $ref = false;
+                       } );
+
+                       if ( $this->usePhpSessionHandling && session_id() === '' && PHPSessionHandler::isEnabled() &&
+                               SessionManager::getGlobalSession()->getId() === (string)$this->id
+                       ) {
+                               $this->logger->debug( "SessionBackend $this->id: Taking over PHP session" );
+                               session_id( (string)$this->id );
+                               \MediaWiki\quietCall( 'session_cache_limiter', 'private, must-revalidate' );
+                               \MediaWiki\quietCall( 'session_start' );
+                       }
+               }
+       }
+
+}
diff --git a/includes/session/SessionId.php b/includes/session/SessionId.php
new file mode 100644 (file)
index 0000000..0669100
--- /dev/null
@@ -0,0 +1,70 @@
+<?php
+/**
+ * MediaWiki session ID holder
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+/**
+ * Value object holding the session ID in a manner that can be globally
+ * updated.
+ *
+ * This class exists because we want WebRequest to refer to the session, but it
+ * can't hold the Session itself due to issues with circular references and it
+ * can't just hold the ID as a string because we need to be able to update the
+ * ID when SessionBackend::resetId() is called.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+final class SessionId {
+       /** @var string */
+       private $id;
+
+       /**
+        * @param string $id
+        */
+       public function __construct( $id ) {
+               $this->id = $id;
+       }
+
+       /**
+        * Get the ID
+        * @return string
+        */
+       public function getId() {
+               return $this->id;
+       }
+
+       /**
+        * Set the ID
+        * @private For use by \\MediaWiki\\Session\\SessionManager only
+        * @param string $id
+        */
+       public function setId( $id ) {
+               $this->id = $id;
+       }
+
+       public function __toString() {
+               return $this->id;
+       }
+
+}
diff --git a/includes/session/SessionInfo.php b/includes/session/SessionInfo.php
new file mode 100644 (file)
index 0000000..9fe2cdf
--- /dev/null
@@ -0,0 +1,270 @@
+<?php
+/**
+ * MediaWiki session info
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LoggerInterface;
+use BagOStuff;
+use WebRequest;
+
+/**
+ * Value object returned by SessionProvider
+ *
+ * This holds the data necessary to construct a Session.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+class SessionInfo {
+       /** Minimum allowed priority */
+       const MIN_PRIORITY = 1;
+
+       /** Maximum allowed priority */
+       const MAX_PRIORITY = 100;
+
+       /** @var SessionProvider|null */
+       private $provider;
+
+       /** @var string */
+       private $id;
+
+       /** @var int */
+       private $priority;
+
+       /** @var UserInfo|null */
+       private $userInfo = null;
+
+       private $persisted = false;
+       private $remembered = false;
+       private $forceHTTPS = false;
+       private $idIsSafe = false;
+
+       /** @var array|null */
+       private $providerMetadata = null;
+
+       /**
+        * @param int $priority Session priority
+        * @param array $data
+        *  - provider: (SessionProvider|null) If not given, the provider will be
+        *    determined from the saved session data.
+        *  - id: (string|null) Session ID
+        *  - userInfo: (UserInfo|null) User known from the request. If
+        *    $provider->canChangeUser() is false, a verified user
+        *    must be provided.
+        *  - persisted: (bool) Whether this session was persisted
+        *  - remembered: (bool) Whether the verified user was remembered.
+        *    Defaults to true.
+        *  - forceHTTPS: (bool) Whether to force HTTPS for this session
+        *  - metadata: (array) Provider metadata, to be returned by
+        *    Session::getProviderMetadata().
+        *  - idIsSafe: (bool) Set true if the 'id' did not come from the user.
+        *    Generally you'll use this from SessionProvider::newEmptySession(),
+        *    and not from any other method.
+        *  - copyFrom: (SessionInfo) SessionInfo to copy other data items from.
+        */
+       public function __construct( $priority, array $data ) {
+               if ( $priority < self::MIN_PRIORITY || $priority > self::MAX_PRIORITY ) {
+                       throw new \InvalidArgumentException( 'Invalid priority' );
+               }
+
+               if ( isset( $data['copyFrom'] ) ) {
+                       $from = $data['copyFrom'];
+                       if ( !$from instanceof SessionInfo ) {
+                               throw new \InvalidArgumentException( 'Invalid copyFrom' );
+                       }
+                       $data += array(
+                               'provider' => $from->provider,
+                               'id' => $from->id,
+                               'userInfo' => $from->userInfo,
+                               'persisted' => $from->persisted,
+                               'remembered' => $from->remembered,
+                               'forceHTTPS' => $from->forceHTTPS,
+                               'metadata' => $from->providerMetadata,
+                               'idIsSafe' => $from->idIsSafe,
+                               // @codeCoverageIgnoreStart
+                       );
+                       // @codeCoverageIgnoreEnd
+               } else {
+                       $data += array(
+                               'provider' => null,
+                               'id' => null,
+                               'userInfo' => null,
+                               'persisted' => false,
+                               'remembered' => true,
+                               'forceHTTPS' => false,
+                               'metadata' => null,
+                               'idIsSafe' => false,
+                               // @codeCoverageIgnoreStart
+                       );
+                       // @codeCoverageIgnoreEnd
+               }
+
+               if ( $data['id'] !== null && !SessionManager::validateSessionId( $data['id'] ) ) {
+                       throw new \InvalidArgumentException( 'Invalid session ID' );
+               }
+
+               if ( $data['userInfo'] !== null && !$data['userInfo'] instanceof UserInfo ) {
+                       throw new \InvalidArgumentException( 'Invalid userInfo' );
+               }
+
+               if ( !$data['provider'] && $data['id'] === null ) {
+                       throw new \InvalidArgumentException(
+                               'Must supply an ID when no provider is given'
+                       );
+               }
+
+               if ( $data['metadata'] !== null && !is_array( $data['metadata'] ) ) {
+                       throw new \InvalidArgumentException( 'Invalid metadata' );
+               }
+
+               $this->provider = $data['provider'];
+               if ( $data['id'] !== null ) {
+                       $this->id = $data['id'];
+                       $this->idIsSafe = $data['idIsSafe'];
+               } else {
+                       $this->id = $this->provider->getManager()->generateSessionId();
+                       $this->idIsSafe = true;
+               }
+               $this->priority = (int)$priority;
+               $this->userInfo = $data['userInfo'];
+               $this->persisted = (bool)$data['persisted'];
+               if ( $data['provider'] !== null ) {
+                       if ( $this->userInfo !== null && !$this->userInfo->isAnon() && $this->userInfo->isVerified() ) {
+                               $this->remembered = (bool)$data['remembered'];
+                       }
+                       $this->providerMetadata = $data['metadata'];
+               }
+               $this->forceHTTPS = (bool)$data['forceHTTPS'];
+       }
+
+       /**
+        * Return the provider
+        * @return SessionProvider|null
+        */
+       final public function getProvider() {
+               return $this->provider;
+       }
+
+       /**
+        * Return the session ID
+        * @return string
+        */
+       final public function getId() {
+               return $this->id;
+       }
+
+       /**
+        * Indicate whether the ID is "safe"
+        *
+        * The ID is safe in the following cases:
+        * - The ID was randomly generated by the constructor.
+        * - The ID was found in the backend data store.
+        * - $this->getProvider()->persistsSessionId() is false.
+        * - The constructor was explicitly told it's safe using the 'idIsSafe'
+        *   parameter.
+        *
+        * @return bool
+        */
+       final public function isIdSafe() {
+               return $this->idIsSafe;
+       }
+
+       /**
+        * Return the priority
+        * @return int
+        */
+       final public function getPriority() {
+               return $this->priority;
+       }
+
+       /**
+        * Return the user
+        * @return UserInfo|null
+        */
+       final public function getUserInfo() {
+               return $this->userInfo;
+       }
+
+       /**
+        * Return whether the session is persisted
+        *
+        * i.e. a session ID was given to the constuctor
+        *
+        * @return bool
+        */
+       final public function wasPersisted() {
+               return $this->persisted;
+       }
+
+       /**
+        * Return provider metadata
+        * @return array|null
+        */
+       final public function getProviderMetadata() {
+               return $this->providerMetadata;
+       }
+
+       /**
+        * Return whether the user was remembered
+        *
+        * For providers that can persist the user separately from the session,
+        * the human using it may not actually *want* that to be done. For example,
+        * a cookie-based provider can set cookies that are longer-lived than the
+        * backend session data, but on a public terminal the human likely doesn't
+        * want those cookies set.
+        *
+        * This is false unless a non-anonymous verified user was passed to
+        * the SessionInfo constructor by the provider, and the provider didn't
+        * pass false for the 'remembered' data item.
+        *
+        * @return bool
+        */
+       final public function wasRemembered() {
+               return $this->remembered;
+       }
+
+       /**
+        * Whether this session should only be used over HTTPS
+        * @return bool
+        */
+       final public function forceHTTPS() {
+               return $this->forceHTTPS;
+       }
+
+       public function __toString() {
+               return '[' . $this->getPriority() . ']' .
+                       ( $this->getProvider() ?: 'null' ) .
+                       ( $this->userInfo ?: '<null>' ) . $this->getId();
+       }
+
+       /**
+        * Compare two SessionInfo objects by priority
+        * @param SessionInfo $a
+        * @param SessionInfo $b
+        * @return int Negative if $a < $b, positive if $a > $b, zero if equal
+        */
+       public static function compare( $a, $b ) {
+               return $a->getPriority() - $b->getPriority();
+       }
+
+}
diff --git a/includes/session/SessionManager.php b/includes/session/SessionManager.php
new file mode 100644 (file)
index 0000000..57d5664
--- /dev/null
@@ -0,0 +1,1004 @@
+<?php
+/**
+ * MediaWiki\Session entry point
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LoggerInterface;
+use BagOStuff;
+use CachedBagOStuff;
+use Config;
+use FauxRequest;
+use Language;
+use Message;
+use User;
+use WebRequest;
+
+/**
+ * This serves as the entry point to the MediaWiki session handling system.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+final class SessionManager implements SessionManagerInterface {
+       /** @var SessionManager|null */
+       private static $instance = null;
+
+       /** @var Session|null */
+       private static $globalSession = null;
+
+       /** @var WebRequest|null */
+       private static $globalSessionRequest = null;
+
+       /** @var LoggerInterface */
+       private $logger;
+
+       /** @var Config */
+       private $config;
+
+       /** @var CachedBagOStuff|null */
+       private $store;
+
+       /** @var SessionProvider[] */
+       private $sessionProviders = null;
+
+       /** @var string[] */
+       private $varyCookies = null;
+
+       /** @var array */
+       private $varyHeaders = null;
+
+       /** @var SessionBackend[] */
+       private $allSessionBackends = array();
+
+       /** @var SessionId[] */
+       private $allSessionIds = array();
+
+       /** @var string[] */
+       private $preventUsers = array();
+
+       /**
+        * Get the global SessionManager
+        * @return SessionManagerInterface
+        *  (really a SessionManager, but this is to make IDEs less confused)
+        */
+       public static function singleton() {
+               if ( self::$instance === null ) {
+                       self::$instance = new self();
+               }
+               return self::$instance;
+       }
+
+       /**
+        * Get the "global" session
+        *
+        * If PHP's session_id() has been set, returns that session. Otherwise
+        * returns the session for RequestContext::getMain()->getRequest().
+        *
+        * @return Session
+        */
+       public static function getGlobalSession() {
+               if ( !PHPSessionHandler::isEnabled() ) {
+                       $id = '';
+               } else {
+                       $id = session_id();
+               }
+
+               $request = \RequestContext::getMain()->getRequest();
+               if (
+                       !self::$globalSession // No global session is set up yet
+                       || self::$globalSessionRequest !== $request // The global WebRequest changed
+                       || $id !== '' && self::$globalSession->getId() !== $id // Someone messed with session_id()
+               ) {
+                       self::$globalSessionRequest = $request;
+                       if ( $id === '' ) {
+                               // session_id() wasn't used, so fetch the Session from the WebRequest.
+                               // We use $request->getSession() instead of $singleton->getSessionForRequest()
+                               // because doing the latter would require a public
+                               // "$request->getSessionId()" method that would confuse end
+                               // users by returning SessionId|null where they'd expect it to
+                               // be short for $request->getSession()->getId(), and would
+                               // wind up being a duplicate of the code in
+                               // $request->getSession() anyway.
+                               self::$globalSession = $request->getSession();
+                       } else {
+                               // Someone used session_id(), so we need to follow suit.
+                               // Note this overwrites whatever session might already be
+                               // associated with $request with the one for $id.
+                               self::$globalSession = self::singleton()->getSessionById( $id, true, $request )
+                                       ?: $request->getSession();
+                       }
+               }
+               return self::$globalSession;
+       }
+
+       /**
+        * @param array $options
+        *  - config: Config to fetch configuration from. Defaults to the default 'main' config.
+        *  - logger: LoggerInterface to use for logging. Defaults to the 'session' channel.
+        *  - store: BagOStuff to store session data in.
+        */
+       public function __construct( $options = array() ) {
+               if ( isset( $options['config'] ) ) {
+                       $this->config = $options['config'];
+                       if ( !$this->config instanceof Config ) {
+                               throw new \InvalidArgumentException(
+                                       '$options[\'config\'] must be an instance of Config'
+                               );
+                       }
+               } else {
+                       $this->config = \ConfigFactory::getDefaultInstance()->makeConfig( 'main' );
+               }
+
+               if ( isset( $options['logger'] ) ) {
+                       if ( !$options['logger'] instanceof LoggerInterface ) {
+                               throw new \InvalidArgumentException(
+                                       '$options[\'logger\'] must be an instance of LoggerInterface'
+                               );
+                       }
+                       $this->setLogger( $options['logger'] );
+               } else {
+                       $this->setLogger( \MediaWiki\Logger\LoggerFactory::getInstance( 'session' ) );
+               }
+
+               if ( isset( $options['store'] ) ) {
+                       if ( !$options['store'] instanceof BagOStuff ) {
+                               throw new \InvalidArgumentException(
+                                       '$options[\'store\'] must be an instance of BagOStuff'
+                               );
+                       }
+                       $store = $options['store'];
+               } else {
+                       $store = \ObjectCache::getInstance( $this->config->get( 'SessionCacheType' ) );
+                       $store->setLogger( $this->logger );
+               }
+               $this->store = $store instanceof CachedBagOStuff ? $store : new CachedBagOStuff( $store );
+
+               register_shutdown_function( array( $this, 'shutdown' ) );
+       }
+
+       public function setLogger( LoggerInterface $logger ) {
+               $this->logger = $logger;
+       }
+
+       public function getSessionForRequest( WebRequest $request ) {
+               $info = $this->getSessionInfoForRequest( $request );
+
+               if ( !$info ) {
+                       $session = $this->getEmptySession( $request );
+               } else {
+                       $session = $this->getSessionFromInfo( $info, $request );
+               }
+               return $session;
+       }
+
+       public function getSessionById( $id, $create = false, WebRequest $request = null ) {
+               if ( !self::validateSessionId( $id ) ) {
+                       throw new \InvalidArgumentException( 'Invalid session ID' );
+               }
+               if ( !$request ) {
+                       $request = new FauxRequest;
+               }
+
+               $session = null;
+
+               // Test this here to provide a better log message for the common case
+               // of "no such ID"
+               $key = wfMemcKey( 'MWSession', $id );
+               if ( is_array( $this->store->get( $key ) ) ) {
+                       $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( 'id' => $id, 'idIsSafe' => true ) );
+                       if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
+                               $session = $this->getSessionFromInfo( $info, $request );
+                       }
+               }
+
+               if ( $create && $session === null ) {
+                       $ex = null;
+                       try {
+                               $session = $this->getEmptySessionInternal( $request, $id );
+                       } catch ( \Exception $ex ) {
+                               $this->logger->error( __METHOD__ . ': failed to create empty session: ' .
+                                       $ex->getMessage() );
+                               $session = null;
+                       }
+               }
+
+               return $session;
+       }
+
+       public function getEmptySession( WebRequest $request = null ) {
+               return $this->getEmptySessionInternal( $request );
+       }
+
+       /**
+        * @see SessionManagerInterface::getEmptySession
+        * @param WebRequest|null $request
+        * @param string|null $id ID to force on the new session
+        * @return Session
+        */
+       private function getEmptySessionInternal( WebRequest $request = null, $id = null ) {
+               if ( $id !== null ) {
+                       if ( !self::validateSessionId( $id ) ) {
+                               throw new \InvalidArgumentException( 'Invalid session ID' );
+                       }
+
+                       $key = wfMemcKey( 'MWSession', $id );
+                       if ( is_array( $this->store->get( $key ) ) ) {
+                               throw new \InvalidArgumentException( 'Session ID already exists' );
+                       }
+               }
+               if ( !$request ) {
+                       $request = new FauxRequest;
+               }
+
+               $infos = array();
+               foreach ( $this->getProviders() as $provider ) {
+                       $info = $provider->newSessionInfo( $id );
+                       if ( !$info ) {
+                               continue;
+                       }
+                       if ( $info->getProvider() !== $provider ) {
+                               throw new \UnexpectedValueException(
+                                       "$provider returned an empty session info for a different provider: $info"
+                               );
+                       }
+                       if ( $id !== null && $info->getId() !== $id ) {
+                               throw new \UnexpectedValueException(
+                                       "$provider returned empty session info with a wrong id: " .
+                                               $info->getId() . ' != ' . $id
+                               );
+                       }
+                       if ( !$info->isIdSafe() ) {
+                               throw new \UnexpectedValueException(
+                                       "$provider returned empty session info with id flagged unsafe"
+                               );
+                       }
+                       $compare = $infos ? SessionInfo::compare( $infos[0], $info ) : -1;
+                       if ( $compare > 0 ) {
+                               continue;
+                       }
+                       if ( $compare === 0 ) {
+                               $infos[] = $info;
+                       } else {
+                               $infos = array( $info );
+                       }
+               }
+
+               // Make sure there's exactly one
+               if ( count( $infos ) > 1 ) {
+                       throw new \UnexpectedValueException(
+                               'Multiple empty sessions tied for top priority: ' . join( ', ', $infos )
+                       );
+               } elseif ( count( $infos ) < 1 ) {
+                       throw new \UnexpectedValueException( 'No provider could provide an empty session!' );
+               }
+
+               return $this->getSessionFromInfo( $infos[0], $request );
+       }
+
+       public function getVaryHeaders() {
+               if ( $this->varyHeaders === null ) {
+                       $headers = array();
+                       foreach ( $this->getProviders() as $provider ) {
+                               foreach ( $provider->getVaryHeaders() as $header => $options ) {
+                                       if ( !isset( $headers[$header] ) ) {
+                                               $headers[$header] = array();
+                                       }
+                                       if ( is_array( $options ) ) {
+                                               $headers[$header] = array_unique( array_merge( $headers[$header], $options ) );
+                                       }
+                               }
+                       }
+                       $this->varyHeaders = $headers;
+               }
+               return $this->varyHeaders;
+       }
+
+       public function getVaryCookies() {
+               if ( $this->varyCookies === null ) {
+                       $cookies = array();
+                       foreach ( $this->getProviders() as $provider ) {
+                               $cookies = array_merge( $cookies, $provider->getVaryCookies() );
+                       }
+                       $this->varyCookies = array_values( array_unique( $cookies ) );
+               }
+               return $this->varyCookies;
+       }
+
+       /**
+        * Validate a session ID
+        * @param string $id
+        * @return bool
+        */
+       public static function validateSessionId( $id ) {
+               return is_string( $id ) && preg_match( '/^[a-zA-Z0-9_-]{32,}$/', $id );
+       }
+
+       /**
+        * @name Internal methods
+        * @{
+        */
+
+       /**
+        * Auto-create the given user, if necessary
+        * @private Don't call this yourself. Let Setup.php do it for you at the right time.
+        * @note This more properly belongs in AuthManager, but we need it now.
+        *  When AuthManager comes, this will be deprecated and will pass-through
+        *  to the corresponding AuthManager method.
+        * @param User $user User to auto-create
+        * @return bool Success
+        */
+       public static function autoCreateUser( User $user ) {
+               global $wgAuth;
+
+               $logger = self::singleton()->logger;
+
+               // Much of this code is based on that in CentralAuth
+
+               // Try the local user from the slave DB
+               $localId = User::idFromName( $user->getName() );
+
+               // Fetch the user ID from the master, so that we don't try to create the user
+               // when they already exist, due to replication lag
+               // @codeCoverageIgnoreStart
+               if ( !$localId && wfGetLB()->getReaderIndex() != 0 ) {
+                       $localId = User::idFromName( $user->getName(), User::READ_LATEST );
+               }
+               // @codeCoverageIgnoreEnd
+
+               if ( $localId ) {
+                       // User exists after all.
+                       $user->setId( $localId );
+                       $user->loadFromId();
+                       return false;
+               }
+
+               // Denied by AuthPlugin? But ignore AuthPlugin itself.
+               if ( get_class( $wgAuth ) !== 'AuthPlugin' && !$wgAuth->autoCreate() ) {
+                       $logger->debug( __METHOD__ . ': denied by AuthPlugin' );
+                       $user->setId( 0 );
+                       $user->loadFromId();
+                       return false;
+               }
+
+               // Wiki is read-only?
+               if ( wfReadOnly() ) {
+                       $logger->debug( __METHOD__ . ': denied by wfReadOnly()' );
+                       $user->setId( 0 );
+                       $user->loadFromId();
+                       return false;
+               }
+
+               $userName = $user->getName();
+
+               // Check the session, if we tried to create this user already there's
+               // no point in retrying.
+               $session = self::getGlobalSession();
+               $reason = $session->get( 'MWSession::AutoCreateBlacklist' );
+               if ( $reason ) {
+                       $logger->debug( __METHOD__ . ": blacklisted in session ($reason)" );
+                       $user->setId( 0 );
+                       $user->loadFromId();
+                       return false;
+               }
+
+               // Is the IP user able to create accounts?
+               $anon = new User;
+               if ( !$anon->isAllowedAny( 'createaccount', 'autocreateaccount' )
+                       || $anon->isBlockedFromCreateAccount()
+               ) {
+                       // Blacklist the user to avoid repeated DB queries subsequently
+                       $logger->debug( __METHOD__ . ': user is blocked from this wiki, blacklisting' );
+                       $session->set( 'MWSession::AutoCreateBlacklist', 'blocked', 600 );
+                       $session->persist();
+                       $user->setId( 0 );
+                       $user->loadFromId();
+                       return false;
+               }
+
+               // Check for validity of username
+               if ( !User::isCreatableName( $userName ) ) {
+                       $logger->debug( __METHOD__ . ': Invalid username, blacklisting' );
+                       $session->set( 'MWSession::AutoCreateBlacklist', 'invalid username', 600 );
+                       $session->persist();
+                       $user->setId( 0 );
+                       $user->loadFromId();
+                       return false;
+               }
+
+               // Give other extensions a chance to stop auto creation.
+               $user->loadDefaults( $userName );
+               $abortMessage = '';
+               if ( !\Hooks::run( 'AbortAutoAccount', array( $user, &$abortMessage ) ) ) {
+                       // In this case we have no way to return the message to the user,
+                       // but we can log it.
+                       $logger->debug( __METHOD__ . ": denied by hook: $abortMessage" );
+                       $session->set( 'MWSession::AutoCreateBlacklist', "hook aborted: $abortMessage", 600 );
+                       $session->persist();
+                       $user->setId( 0 );
+                       $user->loadFromId();
+                       return false;
+               }
+
+               // Make sure the name has not been changed
+               if ( $user->getName() !== $userName ) {
+                       $user->setId( 0 );
+                       $user->loadFromId();
+                       throw new \UnexpectedValueException(
+                               'AbortAutoAccount hook tried to change the user name'
+                       );
+               }
+
+               // Ignore warnings about master connections/writes...hard to avoid here
+               \Profiler::instance()->getTransactionProfiler()->resetExpectations();
+
+               $cache = \ObjectCache::getLocalClusterInstance();
+               $backoffKey = wfMemcKey( 'MWSession', 'autocreate-failed', md5( $userName ) );
+               if ( $cache->get( $backoffKey ) ) {
+                       $logger->debug( __METHOD__ . ': denied by prior creation attempt failures' );
+                       $user->setId( 0 );
+                       $user->loadFromId();
+                       return false;
+               }
+
+               // Checks passed, create the user...
+               $from = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : 'CLI';
+               $logger->info( __METHOD__ . ": creating new user ($userName) - from: $from" );
+
+               try {
+                       // Insert the user into the local DB master
+                       $status = $user->addToDatabase();
+                       if ( !$status->isOK() ) {
+                               // @codeCoverageIgnoreStart
+                               $logger->error( __METHOD__ . ': failed with message ' . $status->getWikiText() );
+                               $user->setId( 0 );
+                               $user->loadFromId();
+                               return false;
+                               // @codeCoverageIgnoreEnd
+                       }
+               } catch ( \Exception $ex ) {
+                       // @codeCoverageIgnoreStart
+                       $logger->error( __METHOD__ . ': failed with exception ' . $ex->getMessage() );
+                       // Do not keep throwing errors for a while
+                       $cache->set( $backoffKey, 1, 600 );
+                       // Bubble up error; which should normally trigger DB rollbacks
+                       throw $ex;
+                       // @codeCoverageIgnoreEnd
+               }
+
+               # Notify AuthPlugin
+               $tmpUser = $user;
+               $wgAuth->initUser( $tmpUser, true );
+               if ( $tmpUser !== $user ) {
+                       $logger->warning( __METHOD__ . ': ' .
+                               get_class( $wgAuth ) . '::initUser() replaced the user object' );
+               }
+
+               # Notify hooks (e.g. Newuserlog)
+               \Hooks::run( 'AuthPluginAutoCreate', array( $user ) );
+               \Hooks::run( 'LocalUserCreated', array( $user, true ) );
+
+               $user->saveSettings();
+
+               # Update user count
+               \DeferredUpdates::addUpdate( new \SiteStatsUpdate( 0, 0, 0, 0, 1 ) );
+
+               # Watch user's userpage and talk page
+               $user->addWatch( $user->getUserPage(), \WatchedItem::IGNORE_USER_RIGHTS );
+
+               return true;
+       }
+
+       /**
+        * Prevent future sessions for the user
+        *
+        * The intention is that the named account will never again be usable for
+        * normal login (i.e. there is no way to undo the prevention of access).
+        *
+        * @private For use from \\User::newSystemUser only
+        * @param string $username
+        */
+       public function preventSessionsForUser( $username ) {
+               $this->preventUsers[$username] = true;
+
+               // Reset the user's token to kill existing sessions
+               $user = User::newFromName( $username );
+               if ( $user && $user->getToken( false ) ) {
+                       $user->setToken();
+                       $user->saveSettings();
+               }
+
+               // Instruct the session providers to kill any other sessions too.
+               foreach ( $this->getProviders() as $provider ) {
+                       $provider->preventSessionsForUser( $username );
+               }
+       }
+
+       /**
+        * Test if a user is prevented
+        * @private For use from SessionBackend only
+        * @param string $username
+        * @return bool
+        */
+       public function isUserSessionPrevented( $username ) {
+               return !empty( $this->preventUsers[$username] );
+       }
+
+       /**
+        * Get the available SessionProviders
+        * @return SessionProvider[]
+        */
+       protected function getProviders() {
+               if ( $this->sessionProviders === null ) {
+                       $this->sessionProviders = array();
+                       foreach ( $this->config->get( 'SessionProviders' ) as $spec ) {
+                               $provider = \ObjectFactory::getObjectFromSpec( $spec );
+                               $provider->setLogger( $this->logger );
+                               $provider->setConfig( $this->config );
+                               $provider->setManager( $this );
+                               if ( isset( $this->sessionProviders[(string)$provider] ) ) {
+                                       throw new \UnexpectedValueException( "Duplicate provider name \"$provider\"" );
+                               }
+                               $this->sessionProviders[(string)$provider] = $provider;
+                       }
+               }
+               return $this->sessionProviders;
+       }
+
+       /**
+        * Get a session provider by name
+        *
+        * Generally, this will only be used by internal implementation of some
+        * special session-providing mechanism. General purpose code, if it needs
+        * to access a SessionProvider at all, will use Session::getProvider().
+        *
+        * @param string $name
+        * @return SessionProvider|null
+        */
+       public function getProvider( $name ) {
+               $providers = $this->getProviders();
+               return isset( $providers[$name] ) ? $providers[$name] : null;
+       }
+
+       /**
+        * Save all active sessions on shutdown
+        * @private For internal use with register_shutdown_function()
+        */
+       public function shutdown() {
+               if ( $this->allSessionBackends ) {
+                       $this->logger->debug( 'Saving all sessions on shutdown' );
+                       if ( session_id() !== '' ) {
+                               // @codeCoverageIgnoreStart
+                               session_write_close();
+                       }
+                       // @codeCoverageIgnoreEnd
+                       foreach ( $this->allSessionBackends as $backend ) {
+                               $backend->save( true );
+                       }
+               }
+       }
+
+       /**
+        * Fetch the SessionInfo(s) for a request
+        * @param WebRequest $request
+        * @return SessionInfo|null
+        */
+       private function getSessionInfoForRequest( WebRequest $request ) {
+               // Call all providers to fetch "the" session
+               $infos = array();
+               foreach ( $this->getProviders() as $provider ) {
+                       $info = $provider->provideSessionInfo( $request );
+                       if ( !$info ) {
+                               continue;
+                       }
+                       if ( $info->getProvider() !== $provider ) {
+                               throw new \UnexpectedValueException(
+                                       "$provider returned session info for a different provider: $info"
+                               );
+                       }
+                       $infos[] = $info;
+               }
+
+               // Sort the SessionInfos. Then find the first one that can be
+               // successfully loaded, and then all the ones after it with the same
+               // priority.
+               usort( $infos, 'MediaWiki\\Session\\SessionInfo::compare' );
+               $retInfos = array();
+               while ( $infos ) {
+                       $info = array_pop( $infos );
+                       if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
+                               $retInfos[] = $info;
+                               while ( $infos ) {
+                                       $info = array_pop( $infos );
+                                       if ( SessionInfo::compare( $retInfos[0], $info ) ) {
+                                               // We hit a lower priority, stop checking.
+                                               break;
+                                       }
+                                       if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
+                                               // This is going to error out below, but we want to
+                                               // provide a complete list.
+                                               $retInfos[] = $info;
+                                       }
+                               }
+                       }
+               }
+
+               if ( count( $retInfos ) > 1 ) {
+                       $ex = new \OverflowException(
+                               'Multiple sessions for this request tied for top priority: ' . join( ', ', $retInfos )
+                       );
+                       $ex->sessionInfos = $retInfos;
+                       throw $ex;
+               }
+
+               return $retInfos ? $retInfos[0] : null;
+       }
+
+       /**
+        * Load and verify the session info against the store
+        *
+        * @param SessionInfo &$info Will likely be replaced with an updated SessionInfo instance
+        * @param WebRequest $request
+        * @return bool Whether the session info matches the stored data (if any)
+        */
+       private function loadSessionInfoFromStore( SessionInfo &$info, WebRequest $request ) {
+               $key = wfMemcKey( 'MWSession', $info->getId() );
+               $blob = $this->store->get( $key );
+
+               $newParams = array();
+
+               if ( $blob !== false ) {
+                       // Sanity check: blob must be an array, if it's saved at all
+                       if ( !is_array( $blob ) ) {
+                               $this->logger->warning( "Session $info: Bad data" );
+                               $this->store->delete( $key );
+                               return false;
+                       }
+
+                       // Sanity check: blob has data and metadata arrays
+                       if ( !isset( $blob['data'] ) || !is_array( $blob['data'] ) ||
+                               !isset( $blob['metadata'] ) || !is_array( $blob['metadata'] )
+                       ) {
+                               $this->logger->warning( "Session $info: Bad data structure" );
+                               $this->store->delete( $key );
+                               return false;
+                       }
+
+                       $data = $blob['data'];
+                       $metadata = $blob['metadata'];
+
+                       // Sanity check: metadata must be an array and must contain certain
+                       // keys, if it's saved at all
+                       if ( !array_key_exists( 'userId', $metadata ) ||
+                               !array_key_exists( 'userName', $metadata ) ||
+                               !array_key_exists( 'userToken', $metadata ) ||
+                               !array_key_exists( 'provider', $metadata )
+                       ) {
+                               $this->logger->warning( "Session $info: Bad metadata" );
+                               $this->store->delete( $key );
+                               return false;
+                       }
+
+                       // First, load the provider from metadata, or validate it against the metadata.
+                       $provider = $info->getProvider();
+                       if ( $provider === null ) {
+                               $newParams['provider'] = $provider = $this->getProvider( $metadata['provider'] );
+                               if ( !$provider ) {
+                                       $this->logger->warning( "Session $info: Unknown provider, " . $metadata['provider'] );
+                                       $this->store->delete( $key );
+                                       return false;
+                               }
+                       } elseif ( $metadata['provider'] !== (string)$provider ) {
+                               $this->logger->warning( "Session $info: Wrong provider, " .
+                                       $metadata['provider'] . ' !== ' . $provider );
+                               return false;
+                       }
+
+                       // Load provider metadata from metadata, or validate it against the metadata
+                       $providerMetadata = $info->getProviderMetadata();
+                       if ( isset( $metadata['providerMetadata'] ) ) {
+                               if ( $providerMetadata === null ) {
+                                       $newParams['metadata'] = $metadata['providerMetadata'];
+                               } else {
+                                       try {
+                                               $newProviderMetadata = $provider->mergeMetadata(
+                                                       $metadata['providerMetadata'], $providerMetadata
+                                               );
+                                               if ( $newProviderMetadata !== $providerMetadata ) {
+                                                       $newParams['metadata'] = $newProviderMetadata;
+                                               }
+                                       } catch ( \UnexpectedValueException $ex ) {
+                                               $this->logger->warning( "Session $info: Metadata merge failed: " . $ex->getMessage() );
+                                               return false;
+                                       }
+                               }
+                       }
+
+                       // Next, load the user from metadata, or validate it against the metadata.
+                       $userInfo = $info->getUserInfo();
+                       if ( !$userInfo ) {
+                               // For loading, id is preferred to name.
+                               try {
+                                       if ( $metadata['userId'] ) {
+                                               $userInfo = UserInfo::newFromId( $metadata['userId'] );
+                                       } elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case
+                                               $userInfo = UserInfo::newFromName( $metadata['userName'] );
+                                       } else {
+                                               $userInfo = UserInfo::newAnonymous();
+                                       }
+                               } catch ( \InvalidArgumentException $ex ) {
+                                       $this->logger->error( "Session $info: " . $ex->getMessage() );
+                                       return false;
+                               }
+                               $newParams['userInfo'] = $userInfo;
+                       } else {
+                               // User validation passes if user ID matches, or if there
+                               // is no saved ID and the names match.
+                               if ( $metadata['userId'] ) {
+                                       if ( $metadata['userId'] !== $userInfo->getId() ) {
+                                               $this->logger->warning( "Session $info: User ID mismatch, " .
+                                                       $metadata['userId'] . ' !== ' . $userInfo->getId() );
+                                               return false;
+                                       }
+
+                                       // If the user was renamed, probably best to fail here.
+                                       if ( $metadata['userName'] !== null &&
+                                               $userInfo->getName() !== $metadata['userName']
+                                       ) {
+                                               $this->logger->warning( "Session $info: User ID matched but name didn't (rename?), " .
+                                                       $metadata['userName'] . ' !== ' . $userInfo->getName() );
+                                               return false;
+                                       }
+
+                               } elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case
+                                       if ( $metadata['userName'] !== $userInfo->getName() ) {
+                                               $this->logger->warning( "Session $info: User name mismatch, " .
+                                                       $metadata['userName'] . ' !== ' . $userInfo->getName() );
+                                               return false;
+                                       }
+                               } elseif ( !$userInfo->isAnon() ) {
+                                       // Metadata specifies an anonymous user, but the passed-in
+                                       // user isn't anonymous.
+                                       $this->logger->warning(
+                                               "Session $info: Metadata has an anonymous user, " .
+                                                       'but a non-anon user was provided'
+                                       );
+                                       return false;
+                               }
+                       }
+
+                       // And if we have a token in the metadata, it must match the loaded/provided user.
+                       if ( $metadata['userToken'] !== null &&
+                               $userInfo->getToken() !== $metadata['userToken']
+                       ) {
+                               $this->logger->warning( "Session $info: User token mismatch" );
+                               return false;
+                       }
+                       if ( !$userInfo->isVerified() ) {
+                               $newParams['userInfo'] = $userInfo->verified();
+                       }
+
+                       if ( !empty( $metadata['remember'] ) && !$info->wasRemembered() ) {
+                               $newParams['remembered'] = true;
+                       }
+                       if ( !empty( $metadata['forceHTTPS'] ) && !$info->forceHTTPS() ) {
+                               $newParams['forceHTTPS'] = true;
+                       }
+                       if ( !empty( $metadata['persisted'] ) && !$info->wasPersisted() ) {
+                               $newParams['persisted'] = true;
+                       }
+
+                       if ( !$info->isIdSafe() ) {
+                               $newParams['idIsSafe'] = true;
+                       }
+               } else {
+                       // No metadata, so we can't load the provider if one wasn't given.
+                       if ( $info->getProvider() === null ) {
+                               $this->logger->warning( "Session $info: Null provider and no metadata" );
+                               return false;
+                       }
+
+                       // If no user was provided and no metadata, it must be anon.
+                       if ( !$info->getUserInfo() ) {
+                               if ( $info->getProvider()->canChangeUser() ) {
+                                       $newParams['userInfo'] = UserInfo::newAnonymous();
+                               } else {
+                                       $this->logger->info(
+                                               "Session $info: No user provided and provider cannot set user"
+                                       );
+                                       return false;
+                               }
+                       } elseif ( !$info->getUserInfo()->isVerified() ) {
+                               $this->logger->warning(
+                                       "Session $info: Unverified user provided and no metadata to auth it"
+                               );
+                               return false;
+                       }
+
+                       $data = false;
+                       $metadata = false;
+
+                       if ( !$info->getProvider()->persistsSessionId() && !$info->isIdSafe() ) {
+                               // The ID doesn't come from the user, so it should be safe
+                               // (and if not, nothing we can do about it anyway)
+                               $newParams['idIsSafe'] = true;
+                       }
+               }
+
+               // Construct the replacement SessionInfo, if necessary
+               if ( $newParams ) {
+                       $newParams['copyFrom'] = $info;
+                       $info = new SessionInfo( $info->getPriority(), $newParams );
+               }
+
+               // Allow the provider to check the loaded SessionInfo
+               $providerMetadata = $info->getProviderMetadata();
+               if ( !$info->getProvider()->refreshSessionInfo( $info, $request, $providerMetadata ) ) {
+                       return false;
+               }
+               if ( $providerMetadata !== $info->getProviderMetadata() ) {
+                       $info = new SessionInfo( $info->getPriority(), array(
+                               'metadata' => $providerMetadata,
+                               'copyFrom' => $info,
+                       ) );
+               }
+
+               // Give hooks a chance to abort. Combined with the SessionMetadata
+               // hook, this can allow for tying a session to an IP address or the
+               // like.
+               $reason = 'Hook aborted';
+               if ( !\Hooks::run(
+                       'SessionCheckInfo',
+                       array( &$reason, $info, $request, $metadata, $data )
+               ) ) {
+                       $this->logger->warning( "Session $info: $reason" );
+                       return false;
+               }
+
+               return true;
+       }
+
+       /**
+        * Create a session corresponding to the passed SessionInfo
+        * @private For use by a SessionProvider that needs to specially create its
+        *  own session.
+        * @param SessionInfo $info
+        * @param WebRequest $request
+        * @return Session
+        */
+       public function getSessionFromInfo( SessionInfo $info, WebRequest $request ) {
+               $id = $info->getId();
+
+               if ( !isset( $this->allSessionBackends[$id] ) ) {
+                       if ( !isset( $this->allSessionIds[$id] ) ) {
+                               $this->allSessionIds[$id] = new SessionId( $id );
+                       }
+                       $backend = new SessionBackend(
+                               $this->allSessionIds[$id],
+                               $info,
+                               $this->store,
+                               $this->logger,
+                               $this->config->get( 'ObjectCacheSessionExpiry' )
+                       );
+                       $this->allSessionBackends[$id] = $backend;
+                       $delay = $backend->delaySave();
+               } else {
+                       $backend = $this->allSessionBackends[$id];
+                       $delay = $backend->delaySave();
+                       if ( $info->wasPersisted() ) {
+                               $backend->persist();
+                       }
+                       if ( $info->wasRemembered() ) {
+                               $backend->setRememberUser( true );
+                       }
+               }
+
+               $request->setSessionId( $backend->getSessionId() );
+               $session = $backend->getSession( $request );
+
+               if ( !$info->isIdSafe() ) {
+                       $session->resetId();
+               }
+
+               \ScopedCallback::consume( $delay );
+               return $session;
+       }
+
+       /**
+        * Deregister a SessionBackend
+        * @private For use from \\MediaWiki\\Session\\SessionBackend only
+        * @param SessionBackend $backend
+        */
+       public function deregisterSessionBackend( SessionBackend $backend ) {
+               $id = $backend->getId();
+               if ( !isset( $this->allSessionBackends[$id] ) || !isset( $this->allSessionIds[$id] ) ||
+                       $this->allSessionBackends[$id] !== $backend ||
+                       $this->allSessionIds[$id] !== $backend->getSessionId()
+               ) {
+                       throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' );
+               }
+
+               unset( $this->allSessionBackends[$id] );
+               // Explicitly do not unset $this->allSessionIds[$id]
+       }
+
+       /**
+        * Change a SessionBackend's ID
+        * @private For use from \\MediaWiki\\Session\\SessionBackend only
+        * @param SessionBackend $backend
+        */
+       public function changeBackendId( SessionBackend $backend ) {
+               $sessionId = $backend->getSessionId();
+               $oldId = (string)$sessionId;
+               if ( !isset( $this->allSessionBackends[$oldId] ) || !isset( $this->allSessionIds[$oldId] ) ||
+                       $this->allSessionBackends[$oldId] !== $backend ||
+                       $this->allSessionIds[$oldId] !== $sessionId
+               ) {
+                       throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' );
+               }
+
+               $newId = $this->generateSessionId();
+
+               unset( $this->allSessionBackends[$oldId], $this->allSessionIds[$oldId] );
+               $sessionId->setId( $newId );
+               $this->allSessionBackends[$newId] = $backend;
+               $this->allSessionIds[$newId] = $sessionId;
+       }
+
+       /**
+        * Generate a new random session ID
+        * @return string
+        */
+       public function generateSessionId() {
+               do {
+                       $id = wfBaseConvert( \MWCryptRand::generateHex( 40 ), 16, 32, 32 );
+                       $key = wfMemcKey( 'MWSession', $id );
+               } while ( isset( $this->allSessionIds[$id] ) || is_array( $this->store->get( $key ) ) );
+               return $id;
+       }
+
+       /**
+        * Call setters on a PHPSessionHandler
+        * @private Use PhpSessionHandler::install()
+        * @param PHPSessionHandler $handler
+        */
+       public function setupPHPSessionHandler( PHPSessionHandler $handler ) {
+               $handler->setManager( $this, $this->store, $this->logger );
+       }
+
+       /**
+        * Reset the internal caching for unit testing
+        */
+       public static function resetCache() {
+               if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+                       // @codeCoverageIgnoreStart
+                       throw new MWException( __METHOD__ . ' may only be called from unit tests!' );
+                       // @codeCoverageIgnoreEnd
+               }
+
+               self::$globalSession = null;
+               self::$globalSessionRequest = null;
+       }
+
+       /**@}*/
+
+}
diff --git a/includes/session/SessionManagerInterface.php b/includes/session/SessionManagerInterface.php
new file mode 100644 (file)
index 0000000..14af630
--- /dev/null
@@ -0,0 +1,95 @@
+<?php
+/**
+ * MediaWiki\Session entry point interface
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LoggerAwareInterface;
+use WebRequest;
+
+/**
+ * This exists to make IDEs happy, so they don't see the
+ * internal-but-required-to-be-public methods on SessionManager.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+interface SessionManagerInterface extends LoggerAwareInterface {
+       /**
+        * Fetch the session for a request
+        *
+        * @note You probably want to use $request->getSession() instead. It's more
+        *  efficient and doesn't break FauxRequests or sessions that were changed
+        *  by $this->getSessionById() or $this->getEmptySession().
+        * @param WebRequest $request Any existing associated session will be reset
+        *  to the session corresponding to the data in the request itself.
+        * @return Session
+        * @throws \\OverflowException if there are multiple sessions tied for top
+        *  priority in the request. Exception has a property "sessionInfos"
+        *  holding the SessionInfo objects for the sessions involved.
+        */
+       public function getSessionForRequest( WebRequest $request );
+
+       /**
+        * Fetch a session by ID
+        * @param string $id
+        * @param bool $create If no session exists for $id, try to create a new one.
+        *  May still return null if a session for $id exists but cannot be loaded.
+        * @param WebRequest|null $request Corresponding request. Any existing
+        *  session associated with this WebRequest object will be overwritten.
+        * @return Session|null
+        */
+       public function getSessionById( $id, $create = false, WebRequest $request = null );
+
+       /**
+        * Fetch a new, empty session
+        *
+        * The first provider configured that is able to provide an empty session
+        * will be used.
+        *
+        * @param WebRequest|null $request Corresponding request. Any existing
+        *  session associated with this WebRequest object will be overwritten.
+        * @return Session
+        */
+       public function getEmptySession( WebRequest $request = null );
+
+       /**
+        * Return the HTTP headers that need varying on.
+        *
+        * The return value is such that someone could theoretically do this:
+        * @code
+        *  foreach ( $provider->getVaryHeaders() as $header => $options ) {
+        *      $outputPage->addVaryHeader( $header, $options );
+        *  }
+        * @endcode
+        *
+        * @return array
+        */
+       public function getVaryHeaders();
+
+       /**
+        * Return the list of cookies that need varying on.
+        * @return string[]
+        */
+       public function getVaryCookies();
+
+}
diff --git a/includes/session/SessionProvider.php b/includes/session/SessionProvider.php
new file mode 100644 (file)
index 0000000..0fd3a71
--- /dev/null
@@ -0,0 +1,487 @@
+<?php
+/**
+ * MediaWiki session provider base class
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Config;
+use Language;
+use WebRequest;
+
+/**
+ * A SessionProvider provides SessionInfo and support for Session
+ *
+ * A SessionProvider is responsible for taking a WebRequest and determining
+ * the authenticated session that it's a part of. It does this by returning an
+ * SessionInfo object with basic information about the session it thinks is
+ * associated with the request, namely the session ID and possibly the
+ * authenticated user the session belongs to.
+ *
+ * The SessionProvider also provides for updating the WebResponse with
+ * information necessary to provide the client with data that the client will
+ * send with later requests, and for populating the Vary and Key headers with
+ * the data necessary to correctly vary the cache on these client requests.
+ *
+ * An important part of the latter is indicating whether it even *can* tell the
+ * client to include such data in future requests, via the persistsSessionId()
+ * and canChangeUser() methods. The cases are (in order of decreasing
+ * commonness):
+ *  - Cannot persist ID, no changing User: The request identifies and
+ *    authenticates a particular local user, and the client cannot be
+ *    instructed to include an arbitrary session ID with future requests. For
+ *    example, OAuth or SSL certificate auth.
+ *  - Can persist ID and can change User: The client can be instructed to
+ *    return at least one piece of arbitrary data, that being the session ID.
+ *    The user identity might also be given to the client, otherwise it's saved
+ *    in the session data. For example, cookie-based sessions.
+ *  - Can persist ID but no changing User: The request uniquely identifies and
+ *    authenticates a local user, and the client can be instructed to return an
+ *    arbitrary session ID with future requests. For example, HTTP Digest
+ *    authentication might somehow use the 'opaque' field as a session ID
+ *    (although getting MediaWiki to return 401 responses without breaking
+ *    other stuff might be a challenge).
+ *  - Cannot persist ID but can change User: I can't think of a way this
+ *    would make sense.
+ *
+ * Note that many methods that are technically "cannot persist ID" could be
+ * turned into "can persist ID but not changing User" using a session cookie,
+ * as implemented by ImmutableSessionProviderWithCookie. If doing so, different
+ * session cookie names should be used for different providers to avoid
+ * collisions.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+abstract class SessionProvider implements SessionProviderInterface, LoggerAwareInterface {
+
+       /** @var LoggerInterface */
+       protected $logger;
+
+       /** @var Config */
+       protected $config;
+
+       /** @var SessionManager */
+       protected $manager;
+
+       /** @var int Session priority. Used for the default newSessionInfo(), but
+        * could be used by subclasses too.
+        */
+       protected $priority;
+
+       /**
+        * @note To fully initialize a SessionProvider, the setLogger(),
+        *  setConfig(), and setManager() methods must be called (and should be
+        *  called in that order). Failure to do so is liable to cause things to
+        *  fail unexpectedly.
+        */
+       public function __construct() {
+               $this->priority = SessionInfo::MIN_PRIORITY + 10;
+       }
+
+       public function setLogger( LoggerInterface $logger ) {
+               $this->logger = $logger;
+       }
+
+       /**
+        * Set configuration
+        * @param Config $config
+        */
+       public function setConfig( Config $config ) {
+               $this->config = $config;
+       }
+
+       /**
+        * Set the session manager
+        * @param SessionManager $manager
+        */
+       public function setManager( SessionManager $manager ) {
+               $this->manager = $manager;
+       }
+
+       /**
+        * Get the session manager
+        * @return SessionManager
+        */
+       public function getManager() {
+               return $this->manager;
+       }
+
+       /**
+        * Provide session info for a request
+        *
+        * If no session exists for the request, return null. Otherwise return an
+        * SessionInfo object identifying the session.
+        *
+        * If multiple SessionProviders provide sessions, the one with highest
+        * priority wins. In case of a tie, an exception is thrown.
+        * SessionProviders are encouraged to make priorities user-configurable
+        * unless only max-priority makes sense.
+        *
+        * @warning This will be called early in the MediaWiki setup process,
+        *  before $wgUser, $wgLang, $wgOut, $wgParser, $wgTitle, and corresponding
+        *  pieces of the main RequestContext are set up! If you try to use these,
+        *  things *will* break.
+        * @note The SessionProvider must not attempt to auto-create users.
+        *  MediaWiki will do this later (when it's safe) if the chosen session has
+        *  a user with a valid name but no ID.
+        * @protected For use by \\MediaWiki\\Session\\SessionManager only
+        * @param WebRequest $request
+        * @return SessionInfo|null
+        */
+       abstract public function provideSessionInfo( WebRequest $request );
+
+       /**
+        * Provide session info for a new, empty session
+        *
+        * Return null if such a session cannot be created. This base
+        * implementation assumes that it only makes sense if a session ID can be
+        * persisted and changing users is allowed.
+        *
+        * @protected For use by \\MediaWiki\\Session\\SessionManager only
+        * @param string|null $id ID to force for the new session
+        * @return SessionInfo|null
+        *  If non-null, must return true for $info->isIdSafe(); pass true for
+        *  $data['idIsSafe'] to ensure this.
+        */
+       public function newSessionInfo( $id = null ) {
+               if ( $this->canChangeUser() && $this->persistsSessionId() ) {
+                       return new SessionInfo( $this->priority, array(
+                               'id' => $id,
+                               'provider' => $this,
+                               'persisted' => false,
+                               'idIsSafe' => true,
+                       ) );
+               }
+               return null;
+       }
+
+       /**
+        * Merge saved session provider metadata
+        *
+        * The default implementation checks that anything in both arrays is
+        * identical, then returns $providedMetadata.
+        *
+        * @protected For use by \\MediaWiki\\Session\\SessionManager only
+        * @param array $savedMetadata Saved provider metadata
+        * @param array $providedMetadata Provided provider metadata
+        * @return array Resulting metadata
+        * @throws \UnexpectedValueException If the metadata cannot be merged
+        */
+       public function mergeMetadata( array $savedMetadata, array $providedMetadata ) {
+               foreach ( $providedMetadata as $k => $v ) {
+                       if ( array_key_exists( $k, $savedMetadata ) && $savedMetadata[$k] !== $v ) {
+                               throw new \UnexpectedValueException( "Key \"$k\" changed" );
+                       }
+               }
+               return $providedMetadata;
+       }
+
+       /**
+        * Validate a loaded SessionInfo and refresh provider metadata
+        *
+        * This is similar in purpose to the 'SessionCheckInfo' hook, and also
+        * allows for updating the provider metadata. On failure, the provider is
+        * expected to write an appropriate message to its logger.
+        *
+        * @protected For use by \\MediaWiki\\Session\\SessionManager only
+        * @param SessionInfo $info
+        * @param WebRequest $request
+        * @param array|null &$metadata Provider metadata, may be altered.
+        * @return bool Return false to reject the SessionInfo after all.
+        */
+       public function refreshSessionInfo( SessionInfo $info, WebRequest $request, &$metadata ) {
+               return true;
+       }
+
+       /**
+        * Indicate whether self::persistSession() can save arbitrary session IDs
+        *
+        * If false, any session passed to self::persistSession() will have an ID
+        * that was originally provided by self::provideSessionInfo().
+        *
+        * If true, the provider may be passed sessions with arbitrary session IDs,
+        * and will be expected to manipulate the request in such a way that future
+        * requests will cause self::provideSessionInfo() to provide a SessionInfo
+        * with that ID.
+        *
+        * For example, a session provider for OAuth would function by matching the
+        * OAuth headers to a particular user, and then would use self::hashToSessionId()
+        * to turn the user and OAuth client ID (and maybe also the user token and
+        * client secret) into a session ID, and therefore can't easily assign that
+        * user+client a different ID. Similarly, a session provider for SSL client
+        * certificates would function by matching the certificate to a particular
+        * user, and then would use self::hashToSessionId() to turn the user and
+        * certificate fingerprint into a session ID, and therefore can't easily
+        * assign a different ID either. On the other hand, a provider that saves
+        * the session ID into a cookie can easily just set the cookie to a
+        * different value.
+        *
+        * @protected For use by \\MediaWiki\\Session\\SessionBackend only
+        * @return bool
+        */
+       abstract public function persistsSessionId();
+
+       /**
+        * Indicate whether the user associated with the request can be changed
+        *
+        * If false, any session passed to self::persistSession() will have a user
+        * that was originally provided by self::provideSessionInfo(). Further,
+        * self::provideSessionInfo() may only provide sessions that have a user
+        * already set.
+        *
+        * If true, the provider may be passed sessions with arbitrary users, and
+        * will be expected to manipulate the request in such a way that future
+        * requests will cause self::provideSessionInfo() to provide a SessionInfo
+        * with that ID. This can be as simple as not passing any 'userInfo' into
+        * SessionInfo's constructor, in which case SessionInfo will load the user
+        * from the saved session's metadata.
+        *
+        * For example, a session provider for OAuth or SSL client certificates
+        * would function by matching the OAuth headers or certificate to a
+        * particular user, and thus would return false here since it can't
+        * arbitrarily assign those OAuth credentials or that certificate to a
+        * different user. A session provider that shoves information into cookies,
+        * on the other hand, could easily do so.
+        *
+        * @protected For use by \\MediaWiki\\Session\\SessionBackend only
+        * @return bool
+        */
+       abstract public function canChangeUser();
+
+       /**
+        * Notification that the session ID was reset
+        *
+        * No need to persist here, persistSession() will be called if appropriate.
+        *
+        * @protected For use by \\MediaWiki\\Session\\SessionBackend only
+        * @param SessionBackend $session Session to persist
+        * @param string $oldId Old session ID
+        * @codeCoverageIgnore
+        */
+       public function sessionIdWasReset( SessionBackend $session, $oldId ) {
+       }
+
+       /**
+        * Persist a session into a request/response
+        *
+        * For example, you might set cookies for the session's ID, user ID, user
+        * name, and user token on the passed request.
+        *
+        * To correctly persist a user independently of the session ID, the
+        * provider should persist both the user ID (or name, but preferably the
+        * ID) and the user token. When reading the data from the request, it
+        * should construct a User object from the ID/name and then verify that the
+        * User object's token matches the token included in the request. Should
+        * the tokens not match, an anonymous user *must* be passed to
+        * SessionInfo::__construct().
+        *
+        * When persisting a user independently of the session ID,
+        * $session->shouldRememberUser() should be checked first. If this returns
+        * false, the user token *must not* be saved to cookies. The user name
+        * and/or ID may be persisted, and should be used to construct an
+        * unverified UserInfo to pass to SessionInfo::__construct().
+        *
+        * A backend that cannot persist sesison ID or user info should implement
+        * this as a no-op.
+        *
+        * @protected For use by \\MediaWiki\\Session\\SessionBackend only
+        * @param SessionBackend $session Session to persist
+        * @param WebRequest $request Request into which to persist the session
+        */
+       abstract public function persistSession( SessionBackend $session, WebRequest $request );
+
+       /**
+        * Remove any persisted session from a request/response
+        *
+        * For example, blank and expire any cookies set by self::persistSession().
+        *
+        * A backend that cannot persist sesison ID or user info should implement
+        * this as a no-op.
+        *
+        * @protected For use by \\MediaWiki\\Session\\SessionManager only
+        * @param WebRequest $request Request from which to remove any session data
+        */
+       abstract public function unpersistSession( WebRequest $request );
+
+       /**
+        * Prevent future sessions for the user
+        *
+        * If the provider is capable of returning a SessionInfo with a verified
+        * UserInfo for the named user in some manner other than by validating
+        * against $user->getToken(), steps must be taken to prevent that from
+        * occurring in the future. This might add the username to a blacklist, or
+        * it might just delete whatever authentication credentials would allow
+        * such a session in the first place (e.g. remove all OAuth grants or
+        * delete record of the SSL client certificate).
+        *
+        * The intention is that the named account will never again be usable for
+        * normal login (i.e. there is no way to undo the prevention of access).
+        *
+        * Note that the passed user name might not exist locally (i.e.
+        * User::idFromName( $username ) === 0); the name should still be
+        * prevented, if applicable.
+        *
+        * @protected For use by \\MediaWiki\\Session\\SessionManager only
+        * @param string $username
+        */
+       public function preventSessionsForUser( $username ) {
+               if ( !$this->canChangeUser() ) {
+                       throw new \BadMethodCallException(
+                               __METHOD__ . ' must be implmented when canChangeUser() is false'
+                       );
+               }
+       }
+
+       /**
+        * Return the HTTP headers that need varying on.
+        *
+        * The return value is such that someone could theoretically do this:
+        * @code
+        *  foreach ( $provider->getVaryHeaders() as $header => $options ) {
+        *      $outputPage->addVaryHeader( $header, $options );
+        *  }
+        * @endcode
+        *
+        * @protected For use by \\MediaWiki\\Session\\SessionManager only
+        * @return array
+        */
+       public function getVaryHeaders() {
+               return array();
+       }
+
+       /**
+        * Return the list of cookies that need varying on.
+        * @protected For use by \\MediaWiki\\Session\\SessionManager only
+        * @return string[]
+        */
+       public function getVaryCookies() {
+               return array();
+       }
+
+       /**
+        * Get a suggested username for the login form
+        * @protected For use by \\MediaWiki\\Session\\SessionBackend only
+        * @param WebRequest $request
+        * @return string|null
+        */
+       public function suggestLoginUsername( WebRequest $request ) {
+               return null;
+       }
+
+       /**
+        * Fetch the rights allowed the user when the specified session is active.
+        * @param SessionBackend $backend
+        * @return null|string[] Allowed user rights, or null to allow all.
+        */
+       public function getAllowedUserRights( SessionBackend $backend ) {
+               if ( $backend->getProvider() !== $this ) {
+                       // Not that this should ever happen...
+                       throw new \InvalidArgumentException( 'Backend\'s provider isn\'t $this' );
+               }
+
+               return null;
+       }
+
+       /**
+        * @note Only override this if it makes sense to instantiate multiple
+        *  instances of the provider. Value returned must be unique across
+        *  configured providers. If you override this, you'll likely need to
+        *  override self::describeMessage() as well.
+        * @return string
+        */
+       public function __toString() {
+               return get_class( $this );
+       }
+
+       /**
+        * Return a Message identifying this session type
+        *
+        * This default implementation takes the class name, lowercases it,
+        * replaces backslashes with dashes, and prefixes 'sessionprovider-' to
+        * determine the message key. For example, MediaWiki\\Session\\CookieSessionProvider
+        * produces 'sessionprovider-mediawiki-session-cookiesessionprovider'.
+        *
+        * @note If self::__toString() is overridden, this will likely need to be
+        *  overridden as well.
+        * @warning This will be called early during MediaWiki startup. Do not
+        *  use $wgUser, $wgLang, $wgOut, $wgParser, or their equivalents via
+        *  RequestContext from this method!
+        * @return Message
+        */
+       protected function describeMessage() {
+               return wfMessage(
+                       'sessionprovider-' . str_replace( '\\', '-', strtolower( get_class( $this ) ) )
+               );
+       }
+
+       public function describe( Language $lang ) {
+               $msg = $this->describeMessage();
+               $msg->inLanguage( $lang );
+               if ( $msg->isDisabled() ) {
+                       $msg = wfMessage( 'sessionprovider-generic', (string)$this )->inLanguage( $lang );
+               }
+               return $msg->plain();
+       }
+
+       public function whyNoSession() {
+               return null;
+       }
+
+       /**
+        * Hash data as a session ID
+        *
+        * Generally this will only be used when self::persistsSessionId() is false and
+        * the provider has to base the session ID on the verified user's identity
+        * or other static data.
+        *
+        * @param string $data
+        * @param string|null $key Defaults to $this->config->get( 'SecretKey' )
+        * @return string
+        */
+       final protected function hashToSessionId( $data, $key = null ) {
+               if ( !is_string( $data ) ) {
+                       throw new \InvalidArgumentException(
+                               '$data must be a string, ' . gettype( $data ) . ' was passed'
+                       );
+               }
+               if ( $key !== null && !is_string( $key ) ) {
+                       throw new \InvalidArgumentException(
+                               '$key must be a string or null, ' . gettype( $key ) . ' was passed'
+                       );
+               }
+
+               $hash = \MWCryptHash::hmac( "$this\n$data", $key ?: $this->config->get( 'SecretKey' ), false );
+               if ( strlen( $hash ) < 32 ) {
+                       // Should never happen, even md5 is 128 bits
+                       // @codeCoverageIgnoreStart
+                       throw new \UnexpectedValueException( 'Hash fuction returned less than 128 bits' );
+                       // @codeCoverageIgnoreEnd
+               }
+               if ( strlen( $hash ) >= 40 ) {
+                       $hash = wfBaseConvert( $hash, 16, 32, 32 );
+               }
+               return substr( $hash, -32 );
+       }
+
+}
diff --git a/includes/session/SessionProviderInterface.php b/includes/session/SessionProviderInterface.php
new file mode 100644 (file)
index 0000000..02ae23d
--- /dev/null
@@ -0,0 +1,54 @@
+<?php
+/**
+ * MediaWiki\Session\Provider interface
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use Language;
+
+/**
+ * This exists to make IDEs happy, so they don't see the
+ * internal-but-required-to-be-public methods on SessionProvider.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+interface SessionProviderInterface {
+
+       /**
+        * Return an identifier for this session type
+        *
+        * @param Language $lang Language to use.
+        * @return string
+        */
+       public function describe( Language $lang );
+
+       /**
+        * Return a Message for why sessions might not be being persisted.
+        *
+        * For example, "check whether you're blocking our cookies".
+        *
+        * @return Message|null
+        */
+       public function whyNoSession();
+
+}
diff --git a/includes/session/Token.php b/includes/session/Token.php
new file mode 100644 (file)
index 0000000..9b4a73c
--- /dev/null
@@ -0,0 +1,125 @@
+<?php
+/**
+ * MediaWiki session token
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+/**
+ * Value object representing a CSRF token
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+class Token {
+       /** CSRF token suffix. Plus and terminal backslash are included to stop
+        * editing from certain broken proxies. */
+       const SUFFIX = '+\\';
+
+       private $secret = '';
+       private $salt = '';
+       private $new = false;
+
+       /**
+        * @param string $secret Token secret
+        * @param string $salt Token salt
+        * @param bool $new Whether the secret was newly-created
+        */
+       public function __construct( $secret, $salt, $new = false ) {
+               $this->secret = $secret;
+               $this->salt = $salt;
+               $this->new = $new;
+       }
+
+       /**
+        * Decode the timestamp from a token string
+        *
+        * Does not validate the token beyond the syntactic checks necessary to
+        * be able to extract the timestamp.
+        *
+        * @param string $token
+        * @param int|null
+        */
+       public static function getTimestamp( $token ) {
+               $suffixLen = strlen( self::SUFFIX );
+               $len = strlen( $token );
+               if ( $len <= 32 + $suffixLen ||
+                       substr( $token, -$suffixLen ) !== self::SUFFIX ||
+                       strspn( $token, '0123456789abcdef' ) + $suffixLen !== $len
+               ) {
+                       return null;
+               }
+
+               return hexdec( substr( $token, 32, -$suffixLen ) );
+       }
+
+       /**
+        * Get the string representation of the token at a timestamp
+        * @param int timestamp
+        * @return string
+        */
+       protected function toStringAtTimestamp( $timestamp ) {
+               return hash_hmac( 'md5', $timestamp . $this->salt, $this->secret, false ) .
+                       dechex( $timestamp ) .
+                       self::SUFFIX;
+       }
+
+       /**
+        * Get the string representation of the token
+        * @return string
+        */
+       public function toString() {
+               return $this->toStringAtTimestamp( wfTimestamp() );
+       }
+
+       public function __toString() {
+               return $this->toString();
+       }
+
+       /**
+        * Test if the token-string matches this token
+        * @param string $userToken
+        * @param int|null $maxAge Return false if $userToken is older than this many seconds
+        * @return bool
+        */
+       public function match( $userToken, $maxAge = null ) {
+               $timestamp = self::getTimestamp( $userToken );
+               if ( $timestamp === null ) {
+                       return false;
+               }
+               if ( $maxAge !== null && $timestamp < wfTimestamp() - $maxAge ) {
+                       // Expired token
+                       return false;
+               }
+
+               $sessionToken = $this->toStringAtTimestamp( $timestamp );
+               return hash_equals( $sessionToken, $userToken );
+       }
+
+       /**
+        * Indicate whether this token was just created
+        * @return bool
+        */
+       public function wasNew() {
+               return $this->new;
+       }
+
+}
diff --git a/includes/session/UserInfo.php b/includes/session/UserInfo.php
new file mode 100644 (file)
index 0000000..c01b9ec
--- /dev/null
@@ -0,0 +1,187 @@
+<?php
+/**
+ * MediaWiki session user info
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use User;
+
+/**
+ * Object holding data about a session's user
+ *
+ * In general, this class exists for two purposes:
+ * - User doesn't distinguish between "anonymous user" and "non-anonymous user
+ *   that doesn't exist locally", while we do need to.
+ * - We also need the "verified" property described below; tracking it via
+ *   another data item to SessionInfo's constructor makes things much more
+ *   confusing.
+ *
+ * A UserInfo may be "verified". This indicates that the creator knows that the
+ * request really comes from that user, whether that's by validating OAuth
+ * credentials, SSL client certificates, or by having both the user ID and
+ * token available from cookies.
+ *
+ * An "unverified" UserInfo should be used when it's not possible to
+ * authenticate the user, e.g. the user ID cookie is set but the user Token
+ * cookie isn't. If the Token is available but doesn't match, don't return a
+ * UserInfo at all.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+final class UserInfo {
+       private $verified = false;
+
+       /** @var User|null */
+       private $user = null;
+
+       private function __construct( User $user = null, $verified ) {
+               if ( $user && $user->isAnon() && !User::isUsableName( $user->getName() ) ) {
+                       $this->verified = true;
+                       $this->user = null;
+               } else {
+                       $this->verified = $verified;
+                       $this->user = $user;
+               }
+       }
+
+       /**
+        * Create an instance for an anonymous (i.e. not logged in) user
+        *
+        * Logged-out users are always "verified".
+        *
+        * @return UserInfo
+        */
+       public static function newAnonymous() {
+               return new self( null, true );
+       }
+
+       /**
+        * Create an instance for a logged-in user by ID
+        * @param int $id User ID
+        * @param bool $verified True if the user is verified
+        * @return UserInfo
+        */
+       public static function newFromId( $id, $verified = false ) {
+               $user = User::newFromId( $id );
+
+               // Ensure the ID actually exists
+               $user->load();
+               if ( $user->isAnon() ) {
+                       throw new \InvalidArgumentException( 'Invalid ID' );
+               }
+
+               return new self( $user, $verified );
+       }
+
+       /**
+        * Create an instance for a logged-in user by name
+        * @param string $name User name (need not exist locally)
+        * @param bool $verified True if the user is verified
+        * @return UserInfo
+        */
+       public static function newFromName( $name, $verified = false ) {
+               $user = User::newFromName( $name, 'usable' );
+               if ( !$user ) {
+                       throw new \InvalidArgumentException( 'Invalid user name' );
+               }
+               return new self( $user, $verified );
+       }
+
+       /**
+        * Create an instance from an existing User object
+        * @param User $user (need not exist locally)
+        * @param bool $verified True if the user is verified
+        * @return UserInfo
+        */
+       public static function newFromUser( User $user, $verified = false ) {
+               return new self( $user, $verified );
+       }
+
+       /**
+        * Return whether this is an anonymous user
+        * @return bool
+        */
+       public function isAnon() {
+               return $this->user === null;
+       }
+
+       /**
+        * Return whether this represents a verified user
+        * @return bool
+        */
+       public function isVerified() {
+               return $this->verified;
+       }
+
+       /**
+        * Return the user ID
+        * @note Do not use this to test for anonymous users!
+        * @return int
+        */
+       public function getId() {
+               return $this->user === null ? 0 : $this->user->getId();
+       }
+
+       /**
+        * Return the user name
+        * @return string|null
+        */
+       public function getName() {
+               return $this->user === null ? null : $this->user->getName();
+       }
+
+       /**
+        * Return the user token
+        * @return string
+        */
+       public function getToken() {
+               return $this->user === null || $this->user->getId() === 0 ? '' : $this->user->getToken( false );
+       }
+
+       /**
+        * Return a User object
+        * @return User
+        */
+       public function getUser() {
+               return $this->user === null ? new User : $this->user;
+       }
+
+       /**
+        * Return a verified version of this object
+        * @return UserInfo
+        */
+       public function verified() {
+               return $this->verified ? $this : new self( $this->user, true );
+       }
+
+       public function __toString() {
+               if ( $this->user === null ) {
+                       return '<anon>';
+               }
+               return '<' .
+                       ( $this->verified ? '+' : '-' ) . ':' .
+                       $this->getId() . ':' . $this->getName() .
+                       '>';
+       }
+
+}
index 0417146..6158df2 100644 (file)
@@ -328,6 +328,29 @@ class SpecialPage {
                return array();
        }
 
+       /**
+        * Perform a regular substring search for prefixSearchSubpages
+        * @param string $search Prefix to search for
+        * @param int $limit Maximum number of results to return (usually 10)
+        * @param int $offset Number of results to skip (usually 0)
+        * @return string[] Matching subpages
+        */
+       protected function prefixSearchString( $search, $limit, $offset ) {
+               $title = Title::newFromText( $search );
+               if ( !$title || !$title->canExist() ) {
+                       // No prefix suggestion in special and media namespace
+                       return array();
+               }
+
+               $search = SearchEngine::create();
+               $search->setLimitOffset( $limit, $offset );
+               $search->setNamespaces( array() );
+               $result = $search->defaultPrefixSearch( $search );
+               return array_map( function( Title $t ) {
+                       return $t->getPrefixedText();
+               }, $result );
+       }
+
        /**
         * Helper function for implementations of prefixSearchSubpages() that
         * filter the values in memory (as opposed to making a query).
index 3babafd..030e7e5 100644 (file)
@@ -90,6 +90,7 @@ class SpecialPageFactory {
                'Unblock' => 'SpecialUnblock',
                'BlockList' => 'SpecialBlockList',
                'ChangePassword' => 'SpecialChangePassword',
+               'BotPasswords' => 'SpecialBotPasswords',
                'PasswordReset' => 'SpecialPasswordReset',
                'DeletedContributions' => 'DeletedContributionsPage',
                'Preferences' => 'SpecialPreferences',
@@ -123,6 +124,7 @@ class SpecialPageFactory {
                'ListDuplicatedFiles' => 'ListDuplicatedFilesPage',
 
                // Data and tools
+               'ApiSandbox' => 'SpecialApiSandbox',
                'Statistics' => 'SpecialStatistics',
                'Allmessages' => 'SpecialAllMessages',
                'Version' => 'SpecialVersion',
index 9e75522..0bf93be 100644 (file)
@@ -365,15 +365,7 @@ class SpecialAllPages extends IncludableSpecialPage {
         * @return string[] Matching subpages
         */
        public function prefixSearchSubpages( $search, $limit, $offset ) {
-               $title = Title::newFromText( $search );
-               if ( !$title || !$title->canExist() ) {
-                       // No prefix suggestion in special and media namespace
-                       return array();
-               }
-               // Autocomplete subpage the same as a normal search
-               $prefixSearcher = new StringPrefixSearch;
-               $result = $prefixSearcher->search( $search, $limit, array(), $offset );
-               return $result;
+               return $this->prefixSearchString( $search, $limit, $offset );
        }
 
        protected function getGroupName() {
diff --git a/includes/specials/SpecialApiSandbox.php b/includes/specials/SpecialApiSandbox.php
new file mode 100644 (file)
index 0000000..42101ba
--- /dev/null
@@ -0,0 +1,58 @@
+<?php
+/**
+ * Implements Special:ApiSandbox
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * @ingroup SpecialPage
+ * @since 1.27
+ */
+class SpecialApiSandbox extends SpecialPage {
+       public function __construct() {
+               parent::__construct( 'ApiSandbox' );
+       }
+
+       public function execute( $par ) {
+               $this->setHeaders();
+               $out = $this->getOutput();
+
+               if ( !$this->getConfig()->get( 'EnableAPI' ) ) {
+                       $out->showErrorPage( 'error', 'apisandbox-api-disabled' );
+               }
+
+               $out->addJsConfigVars( 'apihighlimits', $this->getUser()->isAllowed( 'apihighlimits' ) );
+               $out->addModuleStyles( array(
+                       'mediawiki.special.apisandbox.styles',
+               ) );
+               $out->addModules( array(
+                       'mediawiki.special.apisandbox',
+                       'mediawiki.apipretty',
+               ) );
+               $out->wrapWikiMsg(
+                       "<div id='mw-apisandbox'><div class='mw-apisandbox-nojs error'>\n$1\n</div></div>",
+                       'apisandbox-jsonly'
+               );
+       }
+
+       protected function getGroupName() {
+               return 'wiki';
+       }
+}
diff --git a/includes/specials/SpecialBotPasswords.php b/includes/specials/SpecialBotPasswords.php
new file mode 100644 (file)
index 0000000..93c36ab
--- /dev/null
@@ -0,0 +1,357 @@
+<?php
+/**
+ * Implements Special:BotPasswords
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Let users manage bot passwords
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialBotPasswords extends FormSpecialPage {
+
+       /** @var int Central user ID */
+       private $userId = 0;
+
+       /** @var BotPassword|null Bot password being edited, if any */
+       private $botPassword = null;
+
+       /** @var string Operation being performed: create, update, delete */
+       private $operation = null;
+
+       /** @var string New password set, for communication between onSubmit() and onSuccess() */
+       private $password = null;
+
+       public function __construct() {
+               parent::__construct( 'BotPasswords', 'editmyprivateinfo' );
+       }
+
+       /**
+        * @return bool
+        */
+       public function isListed() {
+               return $this->getConfig()->get( 'EnableBotPasswords' );
+       }
+
+       /**
+        * Main execution point
+        * @param string|null $par
+        */
+       function execute( $par ) {
+               $this->getOutput()->disallowUserJs();
+               $this->requireLogin();
+
+               $par = trim( $par );
+               if ( strlen( $par ) === 0 ) {
+                       $par = null;
+               } elseif ( strlen( $par ) > BotPassword::APPID_MAXLENGTH ) {
+                       throw new ErrorPageError( 'botpasswords', 'botpasswords-bad-appid',
+                               array( htmlspecialchars( $par ) ) );
+               }
+
+               parent::execute( $par );
+       }
+
+       protected function checkExecutePermissions( User $user ) {
+               parent::checkExecutePermissions( $user );
+
+               if ( !$this->getConfig()->get( 'EnableBotPasswords' ) ) {
+                       throw new ErrorPageError( 'botpasswords', 'botpasswords-disabled' );
+               }
+
+               $this->userId = CentralIdLookup::factory()->centralIdFromLocalUser( $this->getUser() );
+               if ( !$this->userId ) {
+                       throw new ErrorPageError( 'botpasswords', 'botpasswords-no-central-id' );
+               }
+       }
+
+       protected function getFormFields() {
+               $that = $this;
+               $user = $this->getUser();
+               $request = $this->getRequest();
+
+               $fields = array();
+
+               if ( $this->par !== null ) {
+                       $this->botPassword = BotPassword::newFromCentralId( $this->userId, $this->par );
+                       if ( !$this->botPassword ) {
+                               $this->botPassword = BotPassword::newUnsaved( array(
+                                       'centralId' => $this->userId,
+                                       'appId' => $this->par,
+                               ) );
+                       }
+
+                       $sep = BotPassword::getSeparator();
+                       $fields[] = array(
+                               'type' => 'info',
+                               'label-message' => 'username',
+                               'default' => $this->getUser()->getName() . $sep . $this->par
+                       );
+
+                       if ( $this->botPassword->isSaved() ) {
+                               $fields['resetPassword'] = array(
+                                       'type' => 'check',
+                                       'label-message' => 'botpasswords-label-resetpassword',
+                               );
+                       }
+
+                       $lang = $this->getLanguage();
+                       $showGrants = MWGrants::getValidGrants();
+                       $fields['grants'] = array(
+                               'type' => 'checkmatrix',
+                               'label-message' => 'botpasswords-label-grants',
+                               'help-message' => 'botpasswords-help-grants',
+                               'columns' => array(
+                                       $this->msg( 'botpasswords-label-grants-column' )->escaped() => 'grant'
+                               ),
+                               'rows' => array_combine(
+                                       array_map( 'MWGrants::getGrantsLink', $showGrants ),
+                                       $showGrants
+                               ),
+                               'default' => array_map(
+                                       function( $g ) {
+                                               return "grant-$g";
+                                       },
+                                       $this->botPassword->getGrants()
+                               ),
+                               'tooltips' => array_combine(
+                                       array_map( 'MWGrants::getGrantsLink', $showGrants ),
+                                       array_map(
+                                               function( $rights ) use ( $lang ) {
+                                                       return $lang->semicolonList( array_map( 'User::getRightDescription', $rights ) );
+                                               },
+                                               array_intersect_key( MWGrants::getRightsByGrant(), array_flip( $showGrants ) )
+                                       )
+                               ),
+                               'force-options-on' => array_map(
+                                       function( $g ) {
+                                               return "grant-$g";
+                                       },
+                                       MWGrants::getHiddenGrants()
+                               ),
+                       );
+
+                       $fields['restrictions'] = array(
+                               'type' => 'textarea',
+                               'label-message' => 'botpasswords-label-restrictions',
+                               'required' => true,
+                               'default' => $this->botPassword->getRestrictions()->toJson( true ),
+                               'rows' => 5,
+                               'validation-callback' => function ( $v ) {
+                                       try {
+                                               MWRestrictions::newFromJson( $v );
+                                               return true;
+                                       } catch ( InvalidArgumentException $ex ) {
+                                               return $ex->getMessage();
+                                       }
+                               },
+                       );
+
+               } else {
+                       $dbr = BotPassword::getDB( DB_SLAVE );
+                       $res = $dbr->select(
+                               'bot_passwords',
+                               array( 'bp_app_id' ),
+                               array( 'bp_user' => $this->userId ),
+                               __METHOD__
+                       );
+                       foreach ( $res as $row ) {
+                               $fields[] = array(
+                                       'section' => 'existing',
+                                       'type' => 'info',
+                                       'raw' => true,
+                                       'default' => Linker::link(
+                                               $this->getPageTitle( $row->bp_app_id ),
+                                               htmlspecialchars( $row->bp_app_id ),
+                                               array(),
+                                               array(),
+                                               array( 'known' )
+                                       ),
+                               );
+                       }
+
+                       $fields['appId'] = array(
+                               'section' => 'createnew',
+                               'type' => 'textwithbutton',
+                               'label-message' => 'botpasswords-label-appid',
+                               'buttondefault' => $this->msg( 'botpasswords-label-create' )->text(),
+                               'required' => true,
+                               'size' => BotPassword::APPID_MAXLENGTH,
+                               'maxlength' => BotPassword::APPID_MAXLENGTH,
+                               'validation-callback' => function ( $v ) {
+                                       $v = trim( $v );
+                                       return $v !== '' && strlen( $v ) <= BotPassword::APPID_MAXLENGTH;
+                               },
+                       );
+
+                       $fields[] = array(
+                               'type' => 'hidden',
+                               'default' => 'new',
+                               'name' => 'op',
+                       );
+               }
+
+               return $fields;
+       }
+
+       protected function alterForm( HTMLForm $form ) {
+               $form->setId( 'mw-botpasswords-form' );
+               $form->setTableId( 'mw-botpasswords-table' );
+               $form->addPreText( $this->msg( 'botpasswords-summary' )->parseAsBlock() );
+               $form->suppressDefaultSubmit();
+
+               if ( $this->par !== null ) {
+                       if ( $this->botPassword->isSaved() ) {
+                               $form->setWrapperLegendMsg( 'botpasswords-editexisting' );
+                               $form->addButton( array(
+                                       'name' => 'op',
+                                       'value' => 'update',
+                                       'label-message' => 'botpasswords-label-update',
+                                       'flags' => array( 'primary', 'progressive' ),
+                               ) );
+                               $form->addButton( array(
+                                       'name' => 'op',
+                                       'value' => 'delete',
+                                       'label-message' => 'botpasswords-label-delete',
+                                       'flags' => array( 'destructive' ),
+                               ) );
+                       } else {
+                               $form->setWrapperLegendMsg( 'botpasswords-createnew' );
+                               $form->addButton( array(
+                                       'name' => 'op',
+                                       'value' => 'create',
+                                       'label-message' => 'botpasswords-label-create',
+                                       'flags' => array( 'primary', 'constructive' ),
+                               ) );
+                       }
+
+                       $form->addButton( array(
+                               'name' => 'op',
+                               'value' => 'cancel',
+                               'label-message' => 'botpasswords-label-cancel'
+                       ) );
+               }
+       }
+
+       public function onSubmit( array $data ) {
+               $op = $this->getRequest()->getVal( 'op', '' );
+
+               switch ( $op ) {
+                       case 'new':
+                               $this->getOutput()->redirect( $this->getPageTitle( $data['appId'] )->getFullURL() );
+                               return false;
+
+                       case 'create':
+                               $this->operation = 'insert';
+                               return $this->save( $data );
+
+                       case 'update':
+                               $this->operation = 'update';
+                               return $this->save( $data );
+
+                       case 'delete':
+                               $this->operation = 'delete';
+                               $bp = BotPassword::newFromCentralId( $this->userId, $this->par );
+                               if ( $bp ) {
+                                       $bp->delete();
+                               }
+                               return Status::newGood();
+
+                       case 'cancel':
+                               $this->getOutput()->redirect( $this->getPageTitle()->getFullURL() );
+                               return false;
+               }
+
+               return false;
+       }
+
+       private function save( array $data ) {
+               $bp = BotPassword::newUnsaved( array(
+                       'centralId' => $this->userId,
+                       'appId' => $this->par,
+                       'restrictions' => MWRestrictions::newFromJson( $data['restrictions'] ),
+                       'grants' => array_merge(
+                               MWGrants::getHiddenGrants(),
+                               preg_replace( '/^grant-/', '', $data['grants'] )
+                       )
+               ) );
+
+               if ( $this->operation === 'insert' || !empty( $data['resetPassword'] ) ) {
+                       $this->password = PasswordFactory::generateRandomPasswordString(
+                               max( 32, $this->getConfig()->get( 'MinimalPasswordLength' ) )
+                       );
+                       $passwordFactory = new PasswordFactory();
+                       $passwordFactory->init( RequestContext::getMain()->getConfig() );
+                       $password = $passwordFactory->newFromPlaintext( $this->password );
+               } else {
+                       $password = null;
+               }
+
+               if ( $bp->save( $this->operation, $password ) ) {
+                       return Status::newGood();
+               } else {
+                       // Messages: botpasswords-insert-failed, botpasswords-update-failed
+                       return Status::newFatal( "botpasswords-{$this->operation}-failed", $this->par );
+               }
+       }
+
+       public function onSuccess() {
+               $out = $this->getOutput();
+
+               switch ( $this->operation ) {
+                       case 'insert':
+                               $out->setPageTitle( $this->msg( 'botpasswords-created-title' )->text() );
+                               $out->addWikiMsg( 'botpasswords-created-body', $this->par );
+                               break;
+
+                       case 'update':
+                               $out->setPageTitle( $this->msg( 'botpasswords-updated-title' )->text() );
+                               $out->addWikiMsg( 'botpasswords-updated-body', $this->par );
+                               break;
+
+                       case 'delete':
+                               $out->setPageTitle( $this->msg( 'botpasswords-deleted-title' )->text() );
+                               $out->addWikiMsg( 'botpasswords-deleted-body', $this->par );
+                               $this->password = null;
+                               break;
+               }
+
+               if ( $this->password !== null ) {
+                       $sep = BotPassword::getSeparator();
+                       $out->addWikiMsg(
+                               'botpasswords-newpassword',
+                               htmlspecialchars( $this->getUser()->getName() . $sep . $this->par ),
+                               htmlspecialchars( $this->password )
+                       );
+                       $this->password = null;
+               }
+
+               $out->addReturnTo( $this->getPageTitle() );
+       }
+
+       protected function getGroupName() {
+               return 'users';
+       }
+
+       protected function getDisplayFormat() {
+               return 'ooui';
+       }
+}
index a9a7f97..1f32e3f 100644 (file)
@@ -234,15 +234,7 @@ class SpecialChangeContentModel extends FormSpecialPage {
         * @return string[] Matching subpages
         */
        public function prefixSearchSubpages( $search, $limit, $offset ) {
-               $title = Title::newFromText( $search );
-               if ( !$title || !$title->canExist() ) {
-                       // No prefix suggestion in special and media namespace
-                       return array();
-               }
-               // Autocomplete subpage the same as a normal search
-               $prefixSearcher = new StringPrefixSearch;
-               $result = $prefixSearcher->search( $search, $limit, array(), $offset );
-               return $result;
+               return $this->prefixSearchString( $search, $limit, $offset );
        }
 
        protected function getGroupName() {
index 8656798..3e5a936 100644 (file)
@@ -111,13 +111,10 @@ class SpecialChangePassword extends FormSpecialPage {
                );
 
                if ( !$this->getUser()->isLoggedIn() ) {
-                       if ( !LoginForm::getLoginToken() ) {
-                               LoginForm::setLoginToken();
-                       }
                        $fields['LoginOnChangeToken'] = array(
                                'type' => 'hidden',
                                'label' => 'Change Password Token',
-                               'default' => LoginForm::getLoginToken(),
+                               'default' => LoginForm::getLoginToken()->toString(),
                        );
                }
 
@@ -179,7 +176,7 @@ class SpecialChangePassword extends FormSpecialPage {
                }
 
                if ( !$this->getUser()->isLoggedIn()
-                       && $request->getVal( 'wpLoginOnChangeToken' ) !== LoginForm::getLoginToken()
+                       && !LoginForm::getLoginToken()->match( $request->getVal( 'wpLoginOnChangeToken' ) )
                ) {
                        // Potential CSRF (bug 62497)
                        return false;
@@ -218,8 +215,8 @@ class SpecialChangePassword extends FormSpecialPage {
                        $this->getOutput()->returnToMain();
                } else {
                        $request = $this->getRequest();
-                       LoginForm::setLoginToken();
-                       $token = LoginForm::getLoginToken();
+                       LoginForm::clearLoginToken();
+                       $token = LoginForm::getLoginToken()->toString();
                        $data = array(
                                'action' => 'submitlogin',
                                'wpName' => $this->mUserName,
index 323903e..9970dfa 100644 (file)
@@ -246,9 +246,11 @@ class FileDuplicateSearchPage extends QueryPage {
                        // No prefix suggestion outside of file namespace
                        return array();
                }
+               $search = SearchEngine::create();
+               $search->setLimitOffset( $limit, $offset );
                // Autocomplete subpage the same as a normal search, but just for files
-               $prefixSearcher = new TitlePrefixSearch;
-               $result = $prefixSearcher->search( $search, $limit, array( NS_FILE ), $offset );
+               $search->setNamespaces( array( NS_FILE ) );
+               $result = $search->defaultPrefixSearch( $search );
 
                return array_map( function ( Title $t ) {
                        // Remove namespace in search suggestion
index a7e5e02..339c1d9 100644 (file)
@@ -820,15 +820,7 @@ class MovePageForm extends UnlistedSpecialPage {
         * @return string[] Matching subpages
         */
        public function prefixSearchSubpages( $search, $limit, $offset ) {
-               $title = Title::newFromText( $search );
-               if ( !$title || !$title->canExist() ) {
-                       // No prefix suggestion in special and media namespace
-                       return array();
-               }
-               // Autocomplete subpage the same as a normal search
-               $prefixSearcher = new StringPrefixSearch;
-               $result = $prefixSearcher->search( $search, $limit, array(), $offset );
-               return $result;
+               return $this->prefixSearchString( $search, $limit, $offset );
        }
 
        protected function getGroupName() {
index 69a9d48..38093be 100644 (file)
@@ -214,15 +214,7 @@ class SpecialPageLanguage extends FormSpecialPage {
         * @return string[] Matching subpages
         */
        public function prefixSearchSubpages( $search, $limit, $offset ) {
-               $title = Title::newFromText( $search );
-               if ( !$title || !$title->canExist() ) {
-                       // No prefix suggestion in special and media namespace
-                       return array();
-               }
-               // Autocomplete subpage the same as a normal search
-               $prefixSearcher = new StringPrefixSearch;
-               $result = $prefixSearcher->search( $search, $limit, array(), $offset );
-               return $result;
+               return $this->prefixSearchString( $search, $limit, $offset );
        }
 
        protected function getGroupName() {
index a6c0423..6401063 100644 (file)
@@ -303,15 +303,7 @@ class SpecialPrefixindex extends SpecialAllPages {
         * @return string[] Matching subpages
         */
        public function prefixSearchSubpages( $search, $limit, $offset ) {
-               $title = Title::newFromText( $search );
-               if ( !$title || !$title->canExist() ) {
-                       // No prefix suggestion in special and media namespace
-                       return array();
-               }
-               // Autocomplete subpage the same as a normal search
-               $prefixSearcher = new StringPrefixSearch;
-               $result = $prefixSearcher->search( $search, $limit, array(), $offset );
-               return $result;
+               return $this->prefixSearchString( $search, $limit, $offset );
        }
 
        protected function getGroupName() {
index 8db8f24..dc210db 100644 (file)
@@ -273,14 +273,6 @@ class SpecialRecentChangesLinked extends SpecialRecentChanges {
         * @return string[] Matching subpages
         */
        public function prefixSearchSubpages( $search, $limit, $offset ) {
-               $title = Title::newFromText( $search );
-               if ( !$title || !$title->canExist() ) {
-                       // No prefix suggestion in special and media namespace
-                       return array();
-               }
-               // Autocomplete subpage the same as a normal search
-               $prefixSearcher = new StringPrefixSearch;
-               $result = $prefixSearcher->search( $search, $limit, array(), $offset );
-               return $result;
+               return $this->prefixSearchString( $search, $limit, $offset );
        }
 }
index f99a52d..078f032 100644 (file)
@@ -1709,15 +1709,7 @@ class SpecialUndelete extends SpecialPage {
         * @return string[] Matching subpages
         */
        public function prefixSearchSubpages( $search, $limit, $offset ) {
-               $title = Title::newFromText( $search );
-               if ( !$title || !$title->canExist() ) {
-                       // No prefix suggestion in special and media namespace
-                       return array();
-               }
-               // Autocomplete subpage the same as a normal search
-               $prefixSearcher = new StringPrefixSearch;
-               $result = $prefixSearcher->search( $search, $limit, array(), $offset );
-               return $result;
+               return $this->prefixSearchString( $search, $limit, $offset );
        }
 
        protected function getGroupName() {
index 62e200e..d9a5e4f 100644 (file)
@@ -21,6 +21,7 @@
  * @ingroup SpecialPage
  */
 use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\Session\SessionManager;
 
 /**
  * Implements Special:UserLogin
@@ -267,9 +268,9 @@ class LoginForm extends SpecialPage {
         * @param string|null $subPage
         */
        public function execute( $subPage ) {
-               if ( session_id() == '' ) {
-                       wfSetupSession();
-               }
+               // Make sure session is persisted
+               $session = MediaWiki\Session\SessionManager::getGlobalSession();
+               $session->persist();
 
                $this->load();
 
@@ -280,6 +281,17 @@ class LoginForm extends SpecialPage {
                }
                $this->setHeaders();
 
+               // Make sure it's possible to log in
+               if ( $this->mType !== 'signup' && !$session->canSetUser() ) {
+                       throw new ErrorPageError(
+                               'cannotloginnow-title',
+                               'cannotloginnow-text',
+                               array(
+                                       $session->getProvider()->describe( RequestContext::getMain()->getLanguage() )
+                               )
+                       );
+               }
+
                /**
                 * In the case where the user is already logged in, and was redirected to
                 * the login form from a page that requires login, do not show the login
@@ -519,9 +531,8 @@ class LoginForm extends SpecialPage {
                }
 
                # Request forgery checks.
-               if ( !self::getCreateaccountToken() ) {
-                       self::setCreateaccountToken();
-
+               $token = self::getCreateaccountToken();
+               if ( $token->wasNew() ) {
                        return Status::newFatal( 'nocookiesfornew' );
                }
 
@@ -531,7 +542,7 @@ class LoginForm extends SpecialPage {
                }
 
                # Validate the createaccount token
-               if ( $this->mToken !== self::getCreateaccountToken() ) {
+               if ( !$token->match( $this->mToken ) ) {
                        return Status::newFatal( 'sessionfailure' );
                }
 
@@ -725,9 +736,8 @@ class LoginForm extends SpecialPage {
                // but wrong-token attempts do.
 
                // If the user doesn't have a login token yet, set one.
-               if ( !self::getLoginToken() ) {
-                       self::setLoginToken();
-
+               $token = self::getLoginToken();
+               if ( $token->wasNew() ) {
                        return self::NEED_TOKEN;
                }
                // If the user didn't pass a login token, tell them we need one
@@ -741,7 +751,7 @@ class LoginForm extends SpecialPage {
                }
 
                // Validate the login token
-               if ( $this->mToken !== self::getLoginToken() ) {
+               if ( !$token->match( $this->mToken ) ) {
                        return self::WRONG_TOKEN;
                }
 
@@ -1380,7 +1390,7 @@ class LoginForm extends SpecialPage {
                        if ( $user->isLoggedIn() ) {
                                $this->mUsername = $user->getName();
                        } else {
-                               $this->mUsername = $this->getRequest()->getCookie( 'UserName' );
+                               $this->mUsername = $this->getRequest()->getSession()->suggestLoginUsername();
                        }
                }
 
@@ -1480,15 +1490,9 @@ class LoginForm extends SpecialPage {
                $template->set( 'loggedinuser', $user->getName() );
 
                if ( $this->mType == 'signup' ) {
-                       if ( !self::getCreateaccountToken() ) {
-                               self::setCreateaccountToken();
-                       }
-                       $template->set( 'token', self::getCreateaccountToken() );
+                       $template->set( 'token', self::getCreateaccountToken()->toString() );
                } else {
-                       if ( !self::getLoginToken() ) {
-                               self::setLoginToken();
-                       }
-                       $template->set( 'token', self::getLoginToken() );
+                       $template->set( 'token', self::getLoginToken()->toString() );
                }
 
                # Prepare language selection links as needed
@@ -1554,29 +1558,35 @@ class LoginForm extends SpecialPage {
         * @return bool
         */
        function hasSessionCookie() {
-               global $wgDisableCookieCheck;
+               global $wgDisableCookieCheck, $wgInitialSessionId;
 
-               return $wgDisableCookieCheck ? true : $this->getRequest()->checkSessionCookie();
+               return $wgDisableCookieCheck || (
+                       $wgInitialSessionId &&
+                       $this->getRequest()->getSession()->getId() === (string)$wgInitialSessionId
+               );
        }
 
        /**
         * Get the login token from the current session
-        * @return mixed
+        * @since 1.27 returns a MediaWiki\\Session\\Token instead of a string
+        * @return MediaWiki\\Session\\Token
         */
        public static function getLoginToken() {
                global $wgRequest;
-
-               return $wgRequest->getSessionData( 'wsLoginToken' );
+               return $wgRequest->getSession()->getToken( '', 'login' );
        }
 
        /**
-        * Randomly generate a new login token and attach it to the current session
+        * Formerly randomly generated a login token that would be returned by
+        * $this->getLoginToken().
+        *
+        * Since 1.27, this is a no-op. The token is generated as necessary by
+        * $this->getLoginToken().
+        *
+        * @deprecated since 1.27
         */
        public static function setLoginToken() {
-               global $wgRequest;
-               // Generate a token directly instead of using $user->getEditToken()
-               // because the latter reuses $_SESSION['wsEditToken']
-               $wgRequest->setSessionData( 'wsLoginToken', MWCryptRand::generateHex( 32 ) );
+               wfDeprecated( __METHOD__, '1.27' );
        }
 
        /**
@@ -1584,24 +1594,30 @@ class LoginForm extends SpecialPage {
         */
        public static function clearLoginToken() {
                global $wgRequest;
-               $wgRequest->setSessionData( 'wsLoginToken', null );
+               $wgRequest->getSession()->resetToken( 'login' );
        }
 
        /**
         * Get the createaccount token from the current session
-        * @return mixed
+        * @since 1.27 returns a MediaWiki\\Session\\Token instead of a string
+        * @return MediaWiki\\Session\\Token
         */
        public static function getCreateaccountToken() {
                global $wgRequest;
-               return $wgRequest->getSessionData( 'wsCreateaccountToken' );
+               return $wgRequest->getSession()->getToken( '', 'createaccount' );
        }
 
        /**
-        * Randomly generate a new createaccount token and attach it to the current session
+        * Formerly randomly generated a createaccount token that would be returned
+        * by $this->getCreateaccountToken().
+        *
+        * Since 1.27, this is a no-op. The token is generated as necessary by
+        * $this->getCreateaccountToken().
+        *
+        * @deprecated since 1.27
         */
        public static function setCreateaccountToken() {
-               global $wgRequest;
-               $wgRequest->setSessionData( 'wsCreateaccountToken', MWCryptRand::generateHex( 32 ) );
+               wfDeprecated( __METHOD__, '1.27' );
        }
 
        /**
@@ -1609,7 +1625,7 @@ class LoginForm extends SpecialPage {
         */
        public static function clearCreateaccountToken() {
                global $wgRequest;
-               $wgRequest->setSessionData( 'wsCreateaccountToken', null );
+               $wgRequest->getSession()->resetToken( 'createaccount' );
        }
 
        /**
@@ -1621,7 +1637,7 @@ class LoginForm extends SpecialPage {
                        $wgCookieSecure = false;
                }
 
-               wfResetSessionID();
+               MediaWiki\Session\SessionManager::getGlobalSession()->resetId();
        }
 
        /**
index 722f772..6e34690 100644 (file)
@@ -48,6 +48,18 @@ class SpecialUserlogout extends UnlistedSpecialPage {
                $this->setHeaders();
                $this->outputHeader();
 
+               // Make sure it's possible to log out
+               $session = MediaWiki\Session\SessionManager::getGlobalSession();
+               if ( !$session->canSetUser() ) {
+                       throw new ErrorPageError(
+                               'cannotlogoutnow-title',
+                               'cannotlogoutnow-text',
+                               array(
+                                       $session->getProvider()->describe( RequestContext::getMain()->getLanguage() )
+                               )
+                       );
+               }
+
                $user = $this->getUser();
                $oldName = $user->getName();
                $user->logout();
index 47fd972..45ef9a2 100644 (file)
@@ -548,15 +548,7 @@ class SpecialWhatLinksHere extends IncludableSpecialPage {
         * @return string[] Matching subpages
         */
        public function prefixSearchSubpages( $search, $limit, $offset ) {
-               $title = Title::newFromText( $search );
-               if ( !$title || !$title->canExist() ) {
-                       // No prefix suggestion in special and media namespace
-                       return array();
-               }
-               // Autocomplete subpage the same as a normal search
-               $prefixSearcher = new StringPrefixSearch;
-               $result = $prefixSearcher->search( $search, $limit, array(), $offset );
-               return $result;
+               return $this->prefixSearchString( $search, $limit, $offset );
        }
 
        protected function getGroupName() {
index 27574fa..07060b2 100644 (file)
@@ -62,12 +62,12 @@ class MediaWikiPageLinkRenderer implements PageLinkRenderer {
        /**
         * Returns the (partial) URL for the given page (including any section identifier).
         *
-        * @param TitleValue $page The link's target
+        * @param LinkTarget $page The link's target
         * @param array $params Any additional URL parameters.
         *
         * @return string
         */
-       public function getPageUrl( TitleValue $page, $params = array() ) {
+       public function getPageUrl( LinkTarget $page, $params = array() ) {
                // TODO: move the code from Linker::linkUrl here!
                // The below is just a rough estimation!
 
@@ -93,20 +93,24 @@ class MediaWikiPageLinkRenderer implements PageLinkRenderer {
        /**
         * Returns an HTML link to the given page, using the given surface text.
         *
-        * @param TitleValue $page The link's target
+        * @param LinkTarget $linkTarget The link's target
         * @param string $text The link's surface text (will be derived from $page if not given).
         *
         * @return string
         */
-       public function renderHtmlLink( TitleValue $page, $text = null ) {
+       public function renderHtmlLink( LinkTarget $linkTarget, $text = null ) {
                if ( $text === null ) {
-                       $text = $this->formatter->getFullText( $page );
+                       $text = $this->formatter->getFullText( $linkTarget );
                }
 
                // TODO: move the logic implemented by Linker here,
                // using $this->formatter and $this->baseUrl, and
                // re-implement Linker to use a HtmlPageLinkRenderer.
-               $title = Title::newFromTitleValue( $page );
+               if ( $linkTarget instanceof Title ) {
+                       $title = $linkTarget;
+               } else {
+                       $title = Title::newFromLinkTarget( $linkTarget );
+               }
                $link = Linker::link( $title, htmlspecialchars( $text ) );
 
                return $link;
@@ -115,12 +119,12 @@ class MediaWikiPageLinkRenderer implements PageLinkRenderer {
        /**
         * Returns a wikitext link to the given page, using the given surface text.
         *
-        * @param TitleValue $page The link's target
+        * @param LinkTarget $page The link's target
         * @param string $text The link's surface text (will be derived from $page if not given).
         *
         * @return string
         */
-       public function renderWikitextLink( TitleValue $page, $text = null ) {
+       public function renderWikitextLink( LinkTarget $page, $text = null ) {
                if ( $text === null ) {
                        $text = $this->formatter->getFullText( $page );
                }
index c497865..1de4247 100644 (file)
@@ -151,33 +151,33 @@ class MediaWikiTitleCodec implements TitleFormatter, TitleParser {
        /**
         * @see TitleFormatter::getText()
         *
-        * @param TitleValue $title
+        * @param LinkTarget $title
         *
         * @return string $title->getText()
         */
-       public function getText( TitleValue $title ) {
+       public function getText( LinkTarget $title ) {
                return $this->formatTitle( false, $title->getText(), '' );
        }
 
        /**
         * @see TitleFormatter::getText()
         *
-        * @param TitleValue $title
+        * @param LinkTarget $title
         *
         * @return string
         */
-       public function getPrefixedText( TitleValue $title ) {
+       public function getPrefixedText( LinkTarget $title ) {
                return $this->formatTitle( $title->getNamespace(), $title->getText(), '' );
        }
 
        /**
         * @see TitleFormatter::getText()
         *
-        * @param TitleValue $title
+        * @param LinkTarget $title
         *
         * @return string
         */
-       public function getFullText( TitleValue $title ) {
+       public function getFullText( LinkTarget $title ) {
                return $this->formatTitle( $title->getNamespace(), $title->getText(), $title->getFragment() );
        }
 
index ca91f58..2ca5707 100644 (file)
@@ -37,32 +37,32 @@ interface PageLinkRenderer {
         *
         * @todo expand this to cover the functionality of Linker::linkUrl
         *
-        * @param TitleValue $page The link's target
+        * @param LinkTarget $page The link's target
         * @param array $params Any additional URL parameters.
         *
         * @return string
         */
-       public function getPageUrl( TitleValue $page, $params = array() );
+       public function getPageUrl( LinkTarget $page, $params = array() );
 
        /**
         * Returns an HTML link to the given page, using the given surface text.
         *
         * @todo expand this to cover the functionality of Linker::link
         *
-        * @param TitleValue $page The link's target
+        * @param LinkTarget $page The link's target
         * @param string $text The link's surface text (will be derived from $page if not given).
         *
         * @return string
         */
-       public function renderHtmlLink( TitleValue $page, $text = null );
+       public function renderHtmlLink( LinkTarget $page, $text = null );
 
        /**
         * Returns a wikitext link to the given page, using the given surface text.
         *
-        * @param TitleValue $page The link's target
+        * @param LinkTarget $page The link's target
         * @param string $text The link's surface text (will be derived from $page if not given).
         *
         * @return string
         */
-       public function renderWikitextLink( TitleValue $page, $text = null );
+       public function renderWikitextLink( LinkTarget $page, $text = null );
 }
index aad8376..4edc5db 100644 (file)
@@ -51,29 +51,29 @@ interface TitleFormatter {
         *
         * @note Only minimal normalization is applied. Consider using TitleValue::getText() directly.
         *
-        * @param TitleValue $title The title to format
+        * @param LinkTarget $title The title to format
         *
         * @return string
         */
-       public function getText( TitleValue $title );
+       public function getText( LinkTarget $title );
 
        /**
         * Returns the title formatted for display, including the namespace name.
         *
-        * @param TitleValue $title The title to format
+        * @param LinkTarget $title The title to format
         *
         * @return string
         */
-       public function getPrefixedText( TitleValue $title );
+       public function getPrefixedText( LinkTarget $title );
 
        /**
         * Returns the title formatted for display, with namespace and fragment.
         *
-        * @param TitleValue $title The title to format
+        * @param LinkTarget $title The title to format
         *
         * @return string
         */
-       public function getFullText( TitleValue $title );
+       public function getFullText( LinkTarget $title );
 
        /**
         * Returns the name of the namespace for the given title.
index a0f3b6f..c8ebc2a 100644 (file)
@@ -35,7 +35,7 @@ use Wikimedia\Assert\Assert;
  * @see https://www.mediawiki.org/wiki/Requests_for_comment/TitleValue
  * @since 1.23
  */
-class TitleValue {
+class TitleValue implements LinkTarget {
        /**
         * @var int
         */
diff --git a/includes/user/BotPassword.php b/includes/user/BotPassword.php
new file mode 100644 (file)
index 0000000..6f713f1
--- /dev/null
@@ -0,0 +1,447 @@
+<?php
+/**
+ * Utility class for bot passwords
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ */
+
+use MediaWiki\Session\BotPasswordSessionProvider;
+use MediaWiki\Session\SessionInfo;
+
+/**
+ * Utility class for bot passwords
+ * @since 1.27
+ */
+class BotPassword implements IDBAccessObject {
+
+       const APPID_MAXLENGTH = 32;
+
+       /** @var bool */
+       private $isSaved;
+
+       /** @var int */
+       private $centralId;
+
+       /** @var string */
+       private $appId;
+
+       /** @var string */
+       private $token;
+
+       /** @var MWRestrictions */
+       private $restrictions;
+
+       /** @var string[] */
+       private $grants;
+
+       /** @var int */
+       private $flags = self::READ_NORMAL;
+
+       /**
+        * @param object $row bot_passwords database row
+        * @param bool $isSaved Whether the bot password was read from the database
+        * @param int $flags IDBAccessObject read flags
+        */
+       protected function __construct( $row, $isSaved, $flags = self::READ_NORMAL ) {
+               $this->isSaved = $isSaved;
+               $this->flags = $flags;
+
+               $this->centralId = (int)$row->bp_user;
+               $this->appId = $row->bp_app_id;
+               $this->token = $row->bp_token;
+               $this->restrictions = MWRestrictions::newFromJson( $row->bp_restrictions );
+               $this->grants = FormatJson::decode( $row->bp_grants );
+       }
+
+       /**
+        * Get a database connection for the bot passwords database
+        * @param int $db Index of the connection to get, e.g. DB_MASTER or DB_SLAVE.
+        * @return DatabaseBase
+        */
+       public static function getDB( $db ) {
+               global $wgBotPasswordsCluster, $wgBotPasswordsDatabase;
+
+               $lb = $wgBotPasswordsCluster
+                       ? wfGetLBFactory()->getExternalLB( $wgBotPasswordsCluster )
+                       : wfGetLB( $wgBotPasswordsDatabase );
+               return $lb->getConnectionRef( $db, array(), $wgBotPasswordsDatabase );
+       }
+
+       /**
+        * Load a BotPassword from the database
+        * @param User $user
+        * @param string $appId
+        * @param int $flags IDBAccessObject read flags
+        * @return BotPassword|null
+        */
+       public static function newFromUser( User $user, $appId, $flags = self::READ_NORMAL ) {
+               $centralId = CentralIdLookup::factory()->centralIdFromLocalUser(
+                       $user, CentralIdLookup::AUDIENCE_RAW, $flags
+               );
+               return $centralId ? self::newFromCentralId( $centralId, $appId, $flags ) : null;
+       }
+
+       /**
+        * Load a BotPassword from the database
+        * @param int $centralId from CentralIdLookup
+        * @param string $appId
+        * @param int $flags IDBAccessObject read flags
+        * @return BotPassword|null
+        */
+       public static function newFromCentralId( $centralId, $appId, $flags = self::READ_NORMAL ) {
+               global $wgEnableBotPasswords;
+
+               if ( !$wgEnableBotPasswords ) {
+                       return null;
+               }
+
+               list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $flags );
+               $db = self::getDB( $index );
+               $row = $db->selectRow(
+                       'bot_passwords',
+                       array( 'bp_user', 'bp_app_id', 'bp_token', 'bp_restrictions', 'bp_grants' ),
+                       array( 'bp_user' => $centralId, 'bp_app_id' => $appId ),
+                       __METHOD__,
+                       $options
+               );
+               return $row ? new self( $row, true, $flags ) : null;
+       }
+
+       /**
+        * Create an unsaved BotPassword
+        * @param array $data Data to use to create the bot password. Keys are:
+        *  - user: (User) User object to create the password for. Overrides username and centralId.
+        *  - username: (string) Username to create the password for. Overrides centralId.
+        *  - centralId: (int) User central ID to create the password for.
+        *  - appId: (string) App ID for the password.
+        *  - restrictions: (MWRestrictions, optional) Restrictions.
+        *  - grants: (string[], optional) Grants.
+        * @param int $flags IDBAccessObject read flags
+        * @return BotPassword|null
+        */
+       public static function newUnsaved( array $data, $flags = self::READ_NORMAL ) {
+               $row = (object)array(
+                       'bp_user' => 0,
+                       'bp_app_id' => isset( $data['appId'] ) ? trim( $data['appId'] ) : '',
+                       'bp_token' => '**unsaved**',
+                       'bp_restrictions' => isset( $data['restrictions'] )
+                               ? $data['restrictions']
+                               : MWRestrictions::newDefault(),
+                       'bp_grants' => isset( $data['grants'] ) ? $data['grants'] : array(),
+               );
+
+               if (
+                       $row->bp_app_id === '' || strlen( $row->bp_app_id ) > self::APPID_MAXLENGTH ||
+                       !$row->bp_restrictions instanceof MWRestrictions ||
+                       !is_array( $row->bp_grants )
+               ) {
+                       return null;
+               }
+
+               $row->bp_restrictions = $row->bp_restrictions->toJson();
+               $row->bp_grants = FormatJson::encode( $row->bp_grants );
+
+               if ( isset( $data['user'] ) ) {
+                       if ( !$data['user'] instanceof User ) {
+                               return null;
+                       }
+                       $row->bp_user = CentralIdLookup::factory()->centralIdFromLocalUser(
+                               $data['user'], CentralIdLookup::AUDIENCE_RAW, $flags
+                       );
+               } elseif ( isset( $data['username'] ) ) {
+                       $row->bp_user = CentralIdLookup::factory()->centralIdFromName(
+                               $data['username'], CentralIdLookup::AUDIENCE_RAW, $flags
+                       );
+               } elseif ( isset( $data['centralId'] ) ) {
+                       $row->bp_user = $data['centralId'];
+               }
+               if ( !$row->bp_user ) {
+                       return null;
+               }
+
+               return new self( $row, false, $flags );
+       }
+
+       /**
+        * Indicate whether this is known to be saved
+        * @return bool
+        */
+       public function isSaved() {
+               return $this->isSaved;
+       }
+
+       /**
+        * Get the central user ID
+        * @return int
+        */
+       public function getUserCentralId() {
+               return $this->centralId;
+       }
+
+       /**
+        * Get the app ID
+        * @return string
+        */
+       public function getAppId() {
+               return $this->appId;
+       }
+
+       /**
+        * Get the token
+        * @return string
+        */
+       public function getToken() {
+               return $this->token;
+       }
+
+       /**
+        * Get the restrictions
+        * @return MWRestrictions
+        */
+       public function getRestrictions() {
+               return $this->restrictions;
+       }
+
+       /**
+        * Get the grants
+        * @return string[]
+        */
+       public function getGrants() {
+               return $this->grants;
+       }
+
+       /**
+        * Get the separator for combined user name + app ID
+        * @return string
+        */
+       public static function getSeparator() {
+               global $wgUserrightsInterwikiDelimiter;
+               return $wgUserrightsInterwikiDelimiter;
+       }
+
+       /**
+        * Get the password
+        * @return Password
+        */
+       protected function getPassword() {
+               list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $this->flags );
+               $db = self::getDB( $index );
+               $password = $db->selectField(
+                       'bot_passwords',
+                       'bp_password',
+                       array( 'bp_user' => $this->centralId, 'bp_app_id' => $this->appId ),
+                       __METHOD__,
+                       $options
+               );
+               if ( $password === false ) {
+                       return PasswordFactory::newInvalidPassword();
+               }
+
+               $passwordFactory = new \PasswordFactory();
+               $passwordFactory->init( \RequestContext::getMain()->getConfig() );
+               try {
+                       return $passwordFactory->newFromCiphertext( $password );
+               } catch ( PasswordError $ex ) {
+                       return PasswordFactory::newInvalidPassword();
+               }
+       }
+
+       /**
+        * Save the BotPassword to the database
+        * @param string $operation 'update' or 'insert'
+        * @param Password|null $password Password to set.
+        * @return bool Success
+        */
+       public function save( $operation, Password $password = null ) {
+               $conds = array(
+                       'bp_user' => $this->centralId,
+                       'bp_app_id' => $this->appId,
+               );
+               $fields = array(
+                       'bp_token' => MWCryptRand::generateHex( User::TOKEN_LENGTH ),
+                       'bp_restrictions' => $this->restrictions->toJson(),
+                       'bp_grants' => FormatJson::encode( $this->grants ),
+               );
+
+               if ( $password !== null ) {
+                       $fields['bp_password'] = $password->toString();
+               } elseif ( $operation === 'insert' ) {
+                       $fields['bp_password'] = PasswordFactory::newInvalidPassword()->toString();
+               }
+
+               $dbw = self::getDB( DB_MASTER );
+               switch ( $operation ) {
+                       case 'insert':
+                               $dbw->insert( 'bot_passwords', $fields + $conds, __METHOD__, array( 'IGNORE' ) );
+                               break;
+
+                       case 'update':
+                               $dbw->update( 'bot_passwords', $fields, $conds, __METHOD__ );
+                               break;
+
+                       default:
+                               return false;
+               }
+               $ok = (bool)$dbw->affectedRows();
+               if ( $ok ) {
+                       $this->token = $dbw->selectField( 'bot_passwords', 'bp_token', $conds, __METHOD__ );
+                       $this->isSaved = true;
+               }
+               return $ok;
+       }
+
+       /**
+        * Delete the BotPassword from the database
+        * @return bool Success
+        */
+       public function delete() {
+               $conds = array(
+                       'bp_user' => $this->centralId,
+                       'bp_app_id' => $this->appId,
+               );
+               $dbw = self::getDB( DB_MASTER );
+               $dbw->delete( 'bot_passwords', $conds, __METHOD__ );
+               $ok = (bool)$dbw->affectedRows();
+               if ( $ok ) {
+                       $this->token = '**unsaved**';
+                       $this->isSaved = false;
+               }
+               return $ok;
+       }
+
+       /**
+        * Invalidate all passwords for a user, by name
+        * @param string $username User name
+        * @return bool Whether any passwords were invalidated
+        */
+       public static function invalidateAllPasswordsForUser( $username ) {
+               $centralId = CentralIdLookup::factory()->centralIdFromName(
+                       $username, CentralIdLookup::AUDIENCE_RAW, CentralIdLookup::READ_LATEST
+               );
+               return $centralId && self::invalidateAllPasswordsForCentralId( $centralId );
+       }
+
+       /**
+        * Invalidate all passwords for a user, by central ID
+        * @param int $centralId
+        * @return bool Whether any passwords were invalidated
+        */
+       public static function invalidateAllPasswordsForCentralId( $centralId ) {
+               global $wgEnableBotPasswords;
+
+               if ( !$wgEnableBotPasswords ) {
+                       return false;
+               }
+
+               $dbw = self::getDB( DB_MASTER );
+               $dbw->update(
+                       'bot_passwords',
+                       array( 'bp_password' => PasswordFactory::newInvalidPassword()->toString() ),
+                       array( 'bp_user' => $centralId ),
+                       __METHOD__
+               );
+               return (bool)$dbw->affectedRows();
+       }
+
+       /**
+        * Remove all passwords for a user, by name
+        * @param string $username User name
+        * @return bool Whether any passwords were removed
+        */
+       public static function removeAllPasswordsForUser( $username ) {
+               $centralId = CentralIdLookup::factory()->centralIdFromName(
+                       $username, CentralIdLookup::AUDIENCE_RAW, CentralIdLookup::READ_LATEST
+               );
+               return $centralId && self::removeAllPasswordsForCentralId( $centralId );
+       }
+
+       /**
+        * Remove all passwords for a user, by central ID
+        * @param int $centralId
+        * @return bool Whether any passwords were removed
+        */
+       public static function removeAllPasswordsForCentralId( $centralId ) {
+               global $wgEnableBotPasswords;
+
+               if ( !$wgEnableBotPasswords ) {
+                       return false;
+               }
+
+               $dbw = self::getDB( DB_MASTER );
+               $dbw->delete(
+                       'bot_passwords',
+                       array( 'bp_user' => $centralId ),
+                       __METHOD__
+               );
+               return (bool)$dbw->affectedRows();
+       }
+
+       /**
+        * Try to log the user in
+        * @param string $username Combined user name and app ID
+        * @param string $password Supplied password
+        * @param WebRequest $request
+        * @return Status On success, the good status's value is the new Session object
+        */
+       public static function login( $username, $password, WebRequest $request ) {
+               global $wgEnableBotPasswords;
+
+               if ( !$wgEnableBotPasswords ) {
+                       return Status::newFatal( 'botpasswords-disabled' );
+               }
+
+               $manager = MediaWiki\Session\SessionManager::singleton();
+               $provider = $manager->getProvider(
+                       'MediaWiki\\Session\\BotPasswordSessionProvider'
+               );
+               if ( !$provider ) {
+                       return Status::newFatal( 'botpasswords-no-provider' );
+               }
+
+               // Split name into name+appId
+               $sep = self::getSeparator();
+               if ( strpos( $username, $sep ) === false ) {
+                       return Status::newFatal( 'botpasswords-invalid-name', $sep );
+               }
+               list( $name, $appId ) = explode( $sep, $username, 2 );
+
+               // Find the named user
+               $user = User::newFromName( $name );
+               if ( !$user || $user->isAnon() ) {
+                       return Status::newFatal( 'nosuchuser', $name );
+               }
+
+               // Get the bot password
+               $bp = self::newFromUser( $user, $appId );
+               if ( !$bp ) {
+                       return Status::newFatal( 'botpasswords-not-exist', $name, $appId );
+               }
+
+               // Check restrictions
+               $status = $bp->getRestrictions()->check( $request );
+               if ( !$status->isOk() ) {
+                       return Status::newFatal( 'botpasswords-restriction-failed' );
+               }
+
+               // Check the password
+               if ( !$bp->getPassword()->equals( $password ) ) {
+                       return Status::newFatal( 'wrongpassword' );
+               }
+
+               // Ok! Create the session.
+               return Status::newGood( $provider->newSessionForRequest( $user, $bp, $request ) );
+       }
+}
diff --git a/includes/user/LoggedOutEditToken.php b/includes/user/LoggedOutEditToken.php
new file mode 100644 (file)
index 0000000..14548f4
--- /dev/null
@@ -0,0 +1,47 @@
+<?php
+/**
+ * MediaWiki edit token
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Session
+ */
+
+use MediaWiki\Session\Token;
+
+/**
+ * Value object representing a logged-out user's edit token
+ *
+ * This exists so that code generically dealing with MediaWiki\\Session\\Token
+ * (i.e. the API) doesn't have to have so many special cases for anon edit
+ * tokens.
+ *
+ * @since 1.27
+ */
+class LoggedOutEditToken extends MediaWiki\Session\Token {
+       public function __construct() {
+               parent::__construct( '', '', false );
+       }
+
+       protected function toStringAtTimestamp( $timestamp ) {
+               return self::SUFFIX;
+       }
+
+       public function match( $userToken, $maxAge = null ) {
+               return $userToken === self::SUFFIX;
+       }
+}
index ac78abe..8e3b2ec 100644 (file)
  * @file
  */
 
+use MediaWiki\Session\SessionManager;
+
 /**
  * String Some punctuation to prevent editing from broken text-mangling proxies.
+ * @deprecated since 1.27, use \\MediaWiki\\Session\\Token::SUFFIX
  * @ingroup Constants
  */
-define( 'EDIT_TOKEN_SUFFIX', '+\\' );
+define( 'EDIT_TOKEN_SUFFIX', MediaWiki\Session\Token::SUFFIX );
 
 /**
  * The User object encapsulates all of the user-specific settings (user_id,
@@ -45,6 +48,7 @@ class User implements IDBAccessObject {
        /**
         * Global constant made accessible as class constants so that autoloader
         * magic can be used.
+        * @deprecated since 1.27, use \\MediaWiki\\Session\\Token::SUFFIX
         */
        const EDIT_TOKEN_SUFFIX = EDIT_TOKEN_SUFFIX;
 
@@ -99,6 +103,7 @@ class User implements IDBAccessObject {
                'apihighlimits',
                'applychangetags',
                'autoconfirmed',
+               'autocreateaccount',
                'autopatrol',
                'bigdelete',
                'block',
@@ -226,7 +231,7 @@ class User implements IDBAccessObject {
         *  - 'defaults'   anonymous user initialised from class defaults
         *  - 'name'       initialise from mName
         *  - 'id'         initialise from mId
-        *  - 'session'    log in from cookies or session if possible
+        *  - 'session'    log in from session if possible
         *
         * Use the User::newFrom*() family of functions to set this.
         */
@@ -304,20 +309,43 @@ class User implements IDBAccessObject {
                return $this->getName();
        }
 
+       /**
+        * Test if it's safe to load this User object
+        * @return bool
+        */
+       public function isSafeToLoad() {
+               global $wgFullyInitialised;
+               return $wgFullyInitialised || $this->mLoadedItems === true || $this->mFrom !== 'session';
+       }
+
        /**
         * Load the user table data for this object from the source given by mFrom.
         *
         * @param integer $flags User::READ_* constant bitfield
         */
        public function load( $flags = self::READ_NORMAL ) {
+               global $wgFullyInitialised;
+
                if ( $this->mLoadedItems === true ) {
                        return;
                }
 
                // Set it now to avoid infinite recursion in accessors
+               $oldLoadedItems = $this->mLoadedItems;
                $this->mLoadedItems = true;
                $this->queryFlagsUsed = $flags;
 
+               // If this is called too early, things are likely to break.
+               if ( !$wgFullyInitialised && $this->mFrom === 'session' ) {
+                       \MediaWiki\Logger\LoggerFactory::getInstance( 'session' )
+                               ->warning( 'User::loadFromSession called before the end of Setup.php', array(
+                                       'exception' => new Exception( 'User::loadFromSession called before the end of Setup.php' ),
+                               ) );
+                       $this->loadDefaults();
+                       $this->mLoadedItems = $oldLoadedItems;
+                       return;
+               }
+
                switch ( $this->mFrom ) {
                        case 'defaults':
                                $this->loadDefaults();
@@ -540,8 +568,8 @@ class User implements IDBAccessObject {
        }
 
        /**
-        * Create a new user object using data from session or cookies. If the
-        * login credentials are invalid, the result is an anonymous user.
+        * Create a new user object using data from session. If the login
+        * credentials are invalid, the result is an anonymous user.
         *
         * @param WebRequest|null $request Object to use; $wgRequest will be used if omitted.
         * @return User
@@ -661,6 +689,8 @@ class User implements IDBAccessObject {
                        $user->saveSettings();
                }
 
+               SessionManager::singleton()->preventSessionsForUser( $user->getName() );
+
                return $user;
        }
 
@@ -1068,8 +1098,8 @@ class User implements IDBAccessObject {
                $this->mOptionOverrides = null;
                $this->mOptionsLoaded = false;
 
-               $loggedOut = $this->getRequest()->getCookie( 'LoggedOut' );
-               if ( $loggedOut !== null ) {
+               $loggedOut = $this->mRequest ? $this->mRequest->getSession()->getLoggedOutTimestamp() : 0;
+               if ( $loggedOut !== 0 ) {
                        $this->mTouched = wfTimestamp( TS_MW, $loggedOut );
                } else {
                        $this->mTouched = '1'; # Allow any pages to be cached
@@ -1114,84 +1144,32 @@ class User implements IDBAccessObject {
        }
 
        /**
-        * Load user data from the session or login cookie.
+        * Load user data from the session.
         *
         * @return bool True if the user is logged in, false otherwise.
         */
        private function loadFromSession() {
+               // Deprecated hook
                $result = null;
-               Hooks::run( 'UserLoadFromSession', array( $this, &$result ) );
+               Hooks::run( 'UserLoadFromSession', array( $this, &$result ), '1.27' );
                if ( $result !== null ) {
                        return $result;
                }
 
-               $request = $this->getRequest();
-
-               $cookieId = $request->getCookie( 'UserID' );
-               $sessId = $request->getSessionData( 'wsUserID' );
-
-               if ( $cookieId !== null ) {
-                       $sId = intval( $cookieId );
-                       if ( $sessId !== null && $cookieId != $sessId ) {
-                               wfDebugLog( 'loginSessions', "Session user ID ($sessId) and
-                                       cookie user ID ($sId) don't match!" );
-                               return false;
-                       }
-                       $request->setSessionData( 'wsUserID', $sId );
-               } elseif ( $sessId !== null && $sessId != 0 ) {
-                       $sId = $sessId;
-               } else {
-                       return false;
-               }
-
-               if ( $request->getSessionData( 'wsUserName' ) !== null ) {
-                       $sName = $request->getSessionData( 'wsUserName' );
-               } elseif ( $request->getCookie( 'UserName' ) !== null ) {
-                       $sName = $request->getCookie( 'UserName' );
-                       $request->setSessionData( 'wsUserName', $sName );
-               } else {
-                       return false;
-               }
-
-               $proposedUser = User::newFromId( $sId );
-               if ( !$proposedUser->isLoggedIn() ) {
-                       // Not a valid ID
-                       return false;
-               }
-
-               global $wgBlockDisablesLogin;
-               if ( $wgBlockDisablesLogin && $proposedUser->isBlocked() ) {
-                       // User blocked and we've disabled blocked user logins
-                       return false;
-               }
-
-               if ( $request->getSessionData( 'wsToken' ) ) {
-                       $passwordCorrect =
-                               ( $proposedUser->getToken( false ) === $request->getSessionData( 'wsToken' ) );
-                       $from = 'session';
-               } elseif ( $request->getCookie( 'Token' ) ) {
-                       # Get the token from DB/cache and clean it up to remove garbage padding.
-                       # This deals with historical problems with bugs and the default column value.
-                       $token = rtrim( $proposedUser->getToken( false ) ); // correct token
-                       // Make comparison in constant time (bug 61346)
-                       $passwordCorrect = strlen( $token )
-                               && hash_equals( $token, $request->getCookie( 'Token' ) );
-                       $from = 'cookie';
-               } else {
-                       // No session or persistent login cookie
-                       return false;
-               }
-
-               if ( ( $sName === $proposedUser->getName() ) && $passwordCorrect ) {
-                       $this->loadFromUserObject( $proposedUser );
-                       $request->setSessionData( 'wsToken', $this->getToken( false ) );
-                       wfDebug( "User: logged in from $from\n" );
+               // MediaWiki\Session\Session already did the necessary authentication of the user
+               // returned here, so just use it if applicable.
+               $session = $this->getRequest()->getSession();
+               $user = $session->getUser();
+               if ( $user->isLoggedIn() ) {
+                       $this->loadFromUserObject( $user );
+                       // Other code expects these to be set in the session, so set them.
+                       $session->set( 'wsUserID', $this->getId() );
+                       $session->set( 'wsUserName', $this->getName() );
+                       $session->set( 'wsToken', $this->getToken() );
                        return true;
-               } else {
-                       // Invalid credentials
-                       wfDebug( "User: can't log in from $from, invalid credentials\n" );
-                       return false;
                }
+
+               return false;
        }
 
        /**
@@ -2451,6 +2429,9 @@ class User implements IDBAccessObject {
                        ),
                        __METHOD__
                );
+
+               // When the main password is changed, invalidate all bot passwords too
+               BotPassword::invalidateAllPasswordsForUser( $this->getName() );
        }
 
        /**
@@ -3043,6 +3024,12 @@ class User implements IDBAccessObject {
        public function getRights() {
                if ( is_null( $this->mRights ) ) {
                        $this->mRights = self::getGroupPermissions( $this->getEffectiveGroups() );
+
+                       $allowedRights = $this->getRequest()->getSession()->getAllowedUserRights();
+                       if ( $allowedRights !== null ) {
+                               $this->mRights = array_intersect( $this->mRights, $allowedRights );
+                       }
+
                        Hooks::run( 'UserGetRights', array( $this, &$this->mRights ) );
                        // Force reindexation of rights when a hook has unset one of them
                        $this->mRights = array_values( array_unique( $this->mRights ) );
@@ -3523,6 +3510,7 @@ class User implements IDBAccessObject {
        /**
         * Set a cookie on the user's client. Wrapper for
         * WebResponse::setCookie
+        * @deprecated since 1.27
         * @param string $name Name of the cookie to set
         * @param string $value Value to set
         * @param int $exp Expiration time, as a UNIX time value;
@@ -3538,6 +3526,7 @@ class User implements IDBAccessObject {
        protected function setCookie(
                $name, $value, $exp = 0, $secure = null, $params = array(), $request = null
        ) {
+               wfDeprecated( __METHOD__, '1.27' );
                if ( $request === null ) {
                        $request = $this->getRequest();
                }
@@ -3547,6 +3536,7 @@ class User implements IDBAccessObject {
 
        /**
         * Clear a cookie on the user's client
+        * @deprecated since 1.27
         * @param string $name Name of the cookie to clear
         * @param bool $secure
         *  true: Force setting the secure attribute when setting the cookie
@@ -3555,6 +3545,7 @@ class User implements IDBAccessObject {
         * @param array $params Array of options sent passed to WebResponse::setcookie()
         */
        protected function clearCookie( $name, $secure = null, $params = array() ) {
+               wfDeprecated( __METHOD__, '1.27' );
                $this->setCookie( $name, '', time() - 86400, $secure, $params );
        }
 
@@ -3565,6 +3556,7 @@ class User implements IDBAccessObject {
         *
         * @see User::setCookie
         *
+        * @deprecated since 1.27
         * @param string $name Name of the cookie to set
         * @param string $value Value to set
         * @param bool $secure
@@ -3575,6 +3567,8 @@ class User implements IDBAccessObject {
        protected function setExtendedLoginCookie( $name, $value, $secure ) {
                global $wgExtendedLoginCookieExpiration, $wgCookieExpiration;
 
+               wfDeprecated( __METHOD__, '1.27' );
+
                $exp = time();
                $exp += $wgExtendedLoginCookieExpiration !== null
                        ? $wgExtendedLoginCookieExpiration
@@ -3584,7 +3578,7 @@ class User implements IDBAccessObject {
        }
 
        /**
-        * Set the default cookies for this session on the user's client.
+        * Persist this user's session (e.g. set cookies)
         *
         * @param WebRequest|null $request WebRequest object to use; $wgRequest will be used if null
         *        is passed.
@@ -3592,72 +3586,36 @@ class User implements IDBAccessObject {
         * @param bool $rememberMe Whether to add a Token cookie for elongated sessions
         */
        public function setCookies( $request = null, $secure = null, $rememberMe = false ) {
-               global $wgExtendedLoginCookies;
-
-               if ( $request === null ) {
-                       $request = $this->getRequest();
-               }
-
                $this->load();
                if ( 0 == $this->mId ) {
                        return;
                }
-               if ( !$this->mToken ) {
-                       // When token is empty or NULL generate a new one and then save it to the database
-                       // This allows a wiki to re-secure itself after a leak of it's user table or $wgSecretKey
-                       // Simply by setting every cell in the user_token column to NULL and letting them be
-                       // regenerated as users log back into the wiki.
-                       $this->setToken();
-                       if ( !wfReadOnly() ) {
-                               $this->saveSettings();
-                       }
-               }
-               $session = array(
-                       'wsUserID' => $this->mId,
-                       'wsToken' => $this->getToken( false ),
-                       'wsUserName' => $this->getName()
-               );
-               $cookies = array(
-                       'UserID' => $this->mId,
-                       'UserName' => $this->getName(),
-               );
-               if ( $rememberMe ) {
-                       $cookies['Token'] = $this->getToken( false );
-               } else {
-                       $cookies['Token'] = false;
-               }
-
-               Hooks::run( 'UserSetCookies', array( $this, &$session, &$cookies ) );
 
-               foreach ( $session as $name => $value ) {
-                       $request->setSessionData( $name, $value );
+               $session = $this->getRequest()->getSession();
+               if ( $request && $session->getRequest() !== $request ) {
+                       $session = $session->sessionWithRequest( $request );
                }
-               foreach ( $cookies as $name => $value ) {
-                       if ( $value === false ) {
-                               $this->clearCookie( $name );
-                       } elseif ( $rememberMe && in_array( $name, $wgExtendedLoginCookies ) ) {
-                               $this->setExtendedLoginCookie( $name, $value, $secure );
-                       } else {
-                               $this->setCookie( $name, $value, 0, $secure, array(), $request );
+               $delay = $session->delaySave();
+
+               if ( !$session->getUser()->equals( $this ) ) {
+                       if ( !$session->canSetUser() ) {
+                               \MediaWiki\Logger\LoggerFactory::getInstance( 'session' )
+                                       ->warning( __METHOD__ .
+                                               ": Cannot save user \"$this\" to a user \"{$session->getUser()}\"'s immutable session"
+                                       );
+                               return;
                        }
+                       $session->setUser( $this );
                }
 
-               /**
-                * If wpStickHTTPS was selected, also set an insecure cookie that
-                * will cause the site to redirect the user to HTTPS, if they access
-                * it over HTTP. Bug 29898. Use an un-prefixed cookie, so it's the same
-                * as the one set by centralauth (bug 53538). Also set it to session, or
-                * standard time setting, based on if rememberme was set.
-                */
-               if ( $request->getCheck( 'wpStickHTTPS' ) || $this->requiresHTTPS() ) {
-                       $this->setCookie(
-                               'forceHTTPS',
-                               'true',
-                               $rememberMe ? 0 : null,
-                               false,
-                               array( 'prefix' => '' ) // no prefix
-                       );
+               $session->setRememberUser( $rememberMe );
+               if ( $secure !== null ) {
+                       $session->setForceHTTPS( $secure );
                }
+
+               $session->persist();
+
+               ScopedCallback::consume( $delay );
        }
 
        /**
@@ -3670,20 +3628,29 @@ class User implements IDBAccessObject {
        }
 
        /**
-        * Clear the user's cookies and session, and reset the instance cache.
+        * Clear the user's session, and reset the instance cache.
         * @see logout()
         */
        public function doLogout() {
-               $this->clearInstanceCache( 'defaults' );
-
-               $this->getRequest()->setSessionData( 'wsUserID', 0 );
-
-               $this->clearCookie( 'UserID' );
-               $this->clearCookie( 'Token' );
-               $this->clearCookie( 'forceHTTPS', false, array( 'prefix' => '' ) );
-
-               // Remember when user logged out, to prevent seeing cached pages
-               $this->setCookie( 'LoggedOut', time(), time() + 86400 );
+               $session = $this->getRequest()->getSession();
+               if ( !$session->canSetUser() ) {
+                       \MediaWiki\Logger\LoggerFactory::getInstance( 'session' )
+                               ->warning( __METHOD__ . ": Cannot log out of an immutable session" );
+               } elseif ( !$session->getUser()->equals( $this ) ) {
+                       \MediaWiki\Logger\LoggerFactory::getInstance( 'session' )
+                               ->warning( __METHOD__ .
+                                       ": Cannot log user \"$this\" out of a user \"{$session->getUser()}\"'s session"
+                               );
+                       // But we still may as well make this user object anon
+                       $this->clearInstanceCache( 'defaults' );
+               } else {
+                       $this->clearInstanceCache( 'defaults' );
+                       $delay = $session->delaySave();
+                       $session->setLoggedOutTimestamp( time() );
+                       $session->setUser( new User );
+                       $session->set( 'wsUserID', 0 ); // Other code expects this
+                       ScopedCallback::consume( $delay );
+               }
        }
 
        /**
@@ -4137,30 +4104,25 @@ class User implements IDBAccessObject {
        }
 
        /**
-        * Internal implementation for self::getEditToken() and
-        * self::matchEditToken().
+        * Initialize (if necessary) and return a session token value
+        * which can be used in edit forms to show that the user's
+        * login credentials aren't being hijacked with a foreign form
+        * submission.
         *
-        * @param string|array $salt
-        * @param WebRequest $request
-        * @param string|int $timestamp
-        * @return string
+        * @since 1.27
+        * @param string|array $salt Array of Strings Optional function-specific data for hashing
+        * @param WebRequest|null $request WebRequest object to use or null to use $wgRequest
+        * @return MediaWiki\\Session\\Token The new edit token
         */
-       private function getEditTokenAtTimestamp( $salt, $request, $timestamp ) {
+       public function getEditTokenObject( $salt = '', $request = null ) {
                if ( $this->isAnon() ) {
-                       return self::EDIT_TOKEN_SUFFIX;
-               } else {
-                       $token = $request->getSessionData( 'wsEditToken' );
-                       if ( $token === null ) {
-                               $token = MWCryptRand::generateHex( 32 );
-                               $request->setSessionData( 'wsEditToken', $token );
-                       }
-                       if ( is_array( $salt ) ) {
-                               $salt = implode( '|', $salt );
-                       }
-                       return hash_hmac( 'md5', $timestamp . $salt, $token, false ) .
-                               dechex( $timestamp ) .
-                               self::EDIT_TOKEN_SUFFIX;
+                       return new LoggedOutEditToken();
                }
+
+               if ( !$request ) {
+                       $request = $this->getRequest();
+               }
+               return $request->getSession()->getToken( $salt );
        }
 
        /**
@@ -4170,29 +4132,23 @@ class User implements IDBAccessObject {
         * submission.
         *
         * @since 1.19
-        *
         * @param string|array $salt Array of Strings Optional function-specific data for hashing
         * @param WebRequest|null $request WebRequest object to use or null to use $wgRequest
         * @return string The new edit token
         */
        public function getEditToken( $salt = '', $request = null ) {
-               return $this->getEditTokenAtTimestamp(
-                       $salt, $request ?: $this->getRequest(), wfTimestamp()
-               );
+               return $this->getEditTokenObject( $salt, $request )->toString();
        }
 
        /**
         * Get the embedded timestamp from a token.
+        * @deprecated since 1.27, use \\MediaWiki\\Session\\Token::getTimestamp instead.
         * @param string $val Input token
         * @return int|null
         */
        public static function getEditTokenTimestamp( $val ) {
-               $suffixLen = strlen( self::EDIT_TOKEN_SUFFIX );
-               if ( strlen( $val ) <= 32 + $suffixLen ) {
-                       return null;
-               }
-
-               return hexdec( substr( $val, 32, -$suffixLen ) );
+               wfDeprecated( __METHOD__, '1.27' );
+               return MediaWiki\Session\Token::getTimestamp( $val );
        }
 
        /**
@@ -4208,28 +4164,7 @@ class User implements IDBAccessObject {
         * @return bool Whether the token matches
         */
        public function matchEditToken( $val, $salt = '', $request = null, $maxage = null ) {
-               if ( $this->isAnon() ) {
-                       return $val === self::EDIT_TOKEN_SUFFIX;
-               }
-
-               $timestamp = self::getEditTokenTimestamp( $val );
-               if ( $timestamp === null ) {
-                       return false;
-               }
-               if ( $maxage !== null && $timestamp < wfTimestamp() - $maxage ) {
-                       // Expired token
-                       return false;
-               }
-
-               $sessionToken = $this->getEditTokenAtTimestamp(
-                       $salt, $request ?: $this->getRequest(), $timestamp
-               );
-
-               if ( !hash_equals( $sessionToken, $val ) ) {
-                       wfDebug( "User::matchEditToken: broken session data\n" );
-               }
-
-               return hash_equals( $sessionToken, $val );
+               return $this->getEditTokenObject( $salt, $request )->match( $val, $maxage );
        }
 
        /**
@@ -4616,6 +4551,13 @@ class User implements IDBAccessObject {
                        }
                }
 
+               // Remove any rights that aren't allowed to the global-session user
+               $allowedRights = SessionManager::getGlobalSession()->getAllowedUserRights();
+               if ( $allowedRights !== null && !in_array( $right, $allowedRights, true ) ) {
+                       $cache[$right] = false;
+                       return false;
+               }
+
                // Allow extensions to say false
                if ( !Hooks::run( 'UserIsEveryoneAllowed', array( $right ) ) ) {
                        $cache[$right] = false;
index 20eab1b..c34ce78 100644 (file)
        "querypage-disabled": "تم تعطيل هذه الصفحة الخاصة لأسباب تتعلق بالأداء.",
        "apihelp": "مساعدة API",
        "apihelp-no-such-module": "الوحدة \"$1\" غير موجودة.",
+       "apisandbox": "ملعب API",
+       "apisandbox-submit": "عمل الطلب",
+       "apisandbox-reset": "إفراغ",
+       "apisandbox-examples": "مثال",
+       "apisandbox-results": "النتيجة",
+       "apisandbox-request-url-label": "مسار الطلب:",
+       "apisandbox-request-time": "وقت الطلب: $1",
        "booksources": "مصادر كتاب",
        "booksources-search-legend": "البحث عن مصادر الكتب",
        "booksources-isbn": "ردمك:",
index 033da3b..3631f9f 100644 (file)
        "querypage-disabled": "Esta páxina especial ta desactivada por razones de rindimientu.",
        "apihelp": "Ayuda de la API",
        "apihelp-no-such-module": "Nun s'alcuentra'l módulu «$1».",
+       "apisandbox": "Zona de pruebes API",
+       "apisandbox-api-disabled": "La API ta desactivada nesti sitiu.",
+       "apisandbox-intro": "Usa esta páxina pa esperimentar cola '''API de serviciu web de MediaWiki'''.\nConsulta [//www.mediawiki.org/wiki/API:Main_page la documentación de la API] pa más detalles tocante al so usu. Exemplu: [//www.mediawiki.org/wiki/API#A_simple_example llamar al conteníu d'una Páxina principal]. Seleiciona una aición pa ver más exemplos.\n\nTen presente que, anque esto ye una zona de pruebes, les aiciones que faigas nesta páxina puen camudar la wiki.",
+       "apisandbox-submit": "Facer solicitú",
+       "apisandbox-reset": "Llimpiar",
+       "apisandbox-examples": "Exemplu",
+       "apisandbox-results": "Resultáu",
+       "apisandbox-request-url-label": "URL de la solicitú:",
+       "apisandbox-request-time": "Duración de la solicitú: $1",
        "booksources": "Fontes de llibros",
        "booksources-search-legend": "Busca de fontes de llibros",
        "booksources-search": "Buscar",
index f53fc65..614f0cc 100644 (file)
        "querypage-disabled": "پِرفورمانس اوچون بو اؤزل صحیفه باغلانیب‌دیر.",
        "apihelp": "API یاردیمی",
        "apihelp-no-such-module": "«$1» ماژول تاپیلمادی.",
+       "apisandbox-reset": "تمیزله",
+       "apisandbox-examples": "میثال",
+       "apisandbox-results": "نتیجه",
+       "apisandbox-request-time": "زامان ایستمک:$1",
        "booksources": "کیتاب قایناقلاری",
        "booksources-search-legend": "کیتاب قایناقلارین آختار",
        "booksources-search": "آختار",
        "sp-contributions-toponly": "تکجه سون نوسخه اولان دییشیکلری گؤستر",
        "sp-contributions-newonly": "تکجه صفحه یاراتماق دَییشیکلیکلرینی گؤستر",
        "sp-contributions-submit": "آختار",
-       "whatlinkshere": "بۇ صحیفه‌‌يه باغلانتیلار",
+       "whatlinkshere": "بۇ صفحه‌‌يه باغلانتیلار",
        "whatlinkshere-title": "«$1»-ه باغلانان صحیفه‌لر",
        "whatlinkshere-page": "صفحه:",
        "linkshere": "آشاغیداکی صفحه‌لر '''[[:$1]]'''-ه باغلانیب:",
index 3d387b2..98347aa 100644 (file)
        "querypage-disabled": "Был махсус бит һөҙөмтәлелекте арттырыу өсөн ябылған.",
        "apihelp": "API белешмәһе",
        "apihelp-no-such-module": "«$1» модуле табылмаған.",
+       "apisandbox": "API һынау урыны",
+       "apisandbox-api-disabled": "Был сайтта API һүндерелгән.",
+       "apisandbox-intro": "''MediaWiki API''' өйрәнеү бите.  API ҡулланыу тураһында белешмә алыу өсөн [//www.mediawiki.org/wiki/API:Main_page API документацияһы]на мөрәжәғәт итегеҙ. Мәҫәләң, [//www.mediawiki.org/wiki/API#A_simple_example Башбит эстәлеген нисек алырға]. Башҡа миҫалдарҙы күреү өсөн ғәмәлде ҡулланығыҙ.",
+       "apisandbox-submit": "Һоратыу яһарға",
+       "apisandbox-reset": "Таҙарт",
+       "apisandbox-examples": "Миҫал",
+       "apisandbox-results": "Һөҙөмтә",
+       "apisandbox-request-url-label": "Һоратыуҙың URL-адресы:",
+       "apisandbox-request-time": "Мөрәжәғәт ваҡыты:$1",
        "booksources": "Китап сығанаҡтары",
        "booksources-search-legend": "Китап сығанаҡтарын эҙлә",
        "booksources-search": "Эҙләү",
index b2e7570..02f757f 100644 (file)
        "right-managechangetags": "ствараць і выдаляць [[Special:Tags|меткі]] з базы зьвестак",
        "right-applychangetags": "дадаваць [[Special:Tags|меткі]] пры рэдагаваньні",
        "right-changetags": "дадаваць і выдаляць адвольныя [[Special:Tags|меткі]] да асобных вэрсіяў і запісаў у журнале падзеяў",
+       "grant-generic": "Набор правоў «$1»",
        "grant-createaccount": "Стварыць рахункі",
        "grant-createeditmovepage": "Ствараць, рэдагаваць і пераносіць старонкі",
        "grant-delete": "Выдаляць старонкі, вэрсіі і запісы журналу",
        "querypage-disabled": "Гэта спэцыяльная старонка адключаная для падвышэньня прадукцыйнасьці",
        "apihelp": "Даведка API",
        "apihelp-no-such-module": "Модуль «$1» ня знойдзены.",
+       "apisandbox": "Пясочніца API",
+       "apisandbox-api-disabled": "API забаронены на гэтым сайце.",
+       "apisandbox-intro": "Выкарыстоўвайце гэтую старонку для экспэрымэнтаў з '''API вэб-сэрвісу MediaWiki'''.\nЗьвяртайцеся да [//www.mediawiki.org/wiki/API:Main_page дакумэнтацыі API] для дадатковай інфармацыі па выкарыстаньні API. Напрыклад, [//www.mediawiki.org/wiki/API#A_simple_example як атрымаць зьмест галоўнай старонкі]. Абярыце дзеяньне, каб пабачыць болей узораў.\n\nЗьвярніце ўвагу, што нягледзячы на тое, што гэта пясочніца, вашыя дзеяньні могуць унесьці зьмены ў вікі.",
+       "apisandbox-submit": "Зрабіць запыт",
+       "apisandbox-reset": "Ачысьціць",
+       "apisandbox-examples": "Прыклад",
+       "apisandbox-results": "Вынік",
+       "apisandbox-request-url-label": "URL-адрас запыту:",
+       "apisandbox-request-time": "Час апрацоўкі запыту: $1",
        "booksources": "Крыніцы кніг",
        "booksources-search-legend": "Пошук кніг",
        "booksources-isbn": "ISBN:",
index b763eb2..5f779fc 100644 (file)
        "suppress": "Премахване от публичния архив",
        "querypage-disabled": "Тази специална страница е изключена, защото затруднява производителността на уикито.",
        "apihelp-no-such-module": "Модул \"$1\" не беше намерен.",
+       "apisandbox-reset": "Изчистване",
+       "apisandbox-examples": "Пример",
+       "apisandbox-results": "Резултат",
        "booksources": "Източници на книги",
        "booksources-search-legend": "Търсене на информация за книга",
        "booksources-search": "Търсене",
index de7d55b..775eaef 100644 (file)
        "foreign-structured-upload-form-label-infoform-date": "তারিখ",
        "foreign-structured-upload-form-label-not-own-work-local-local": "এছাড়াও আপনি [[Special:Upload|ডিফল্ট আপলোডের পাতা]] চেষ্টা করতে পারেন।",
        "foreign-structured-upload-form-2-label-ccbysa": "[https://creativecommons.org/licenses/by-sa/4.0/deed.bn ক্রিয়েটিভ কমন্স অ্যাট্রিবিউশন-শেয়ার অ্যালাইক ৪.০] লাইসেন্সের আওতায় এটি ইন্টারনেটে <strong>চিরতরে প্রকাশ করা ঠিক হবে</strong>",
+       "foreign-structured-upload-form-3-label-question-website": "আপনি কি একটি ওয়েবসাইট থেকে এই ছবি ডাউনলোড করেছেন, বা একটি চিত্র অনুসন্ধান থেকে এটি পেয়েছেন?",
        "foreign-structured-upload-form-3-label-yes": "হ্যাঁ",
        "foreign-structured-upload-form-3-label-no": "না",
        "backend-fail-stream": "\"$1\" ফাইলের স্ট্রিম দেখানো যাচ্ছে না।",
        "querypage-disabled": "কারিগরি কারণে এই বিশেষ পাতাটি আপাতত বন্ধ রয়েছে।",
        "apihelp": "এপিআই সাহায্য",
        "apihelp-no-such-module": "মডিউল \"$1\" পাওয়া যায়নি।",
+       "apisandbox": "এপিআই খেলাঘর",
+       "apisandbox-api-disabled": "এপিআই এই সাইটে নিষ্ক্রিয় করা আছে।",
+       "apisandbox-submit": "অনুরোধ রাখুন",
+       "apisandbox-reset": "পরিস্কার",
+       "apisandbox-examples": "উদাহরণ",
+       "apisandbox-results": "ফলাফল",
+       "apisandbox-request-time": "অনুরোধের সময়: $1",
        "booksources": "বইয়ের উৎস",
        "booksources-search-legend": "বইয়ের উৎসের জন্য অনুসন্ধান করা হোক",
        "booksources-isbn": "আইএসবিএন:",
        "block-log-flags-hiddenname": "ব্যবহারকারীনাম লুকায়িত",
        "range_block_disabled": "প্রশাসকের পক্ষে আইপি ঠিকানার শ্রেণী বাধাদানের ক্ষমতা নিষ্ক্রিয় আছে।",
        "ipb_expiry_invalid": "মেয়াদোত্তীর্ণকাল অবৈধ।",
+       "ipb_expiry_old": "মেয়াদোত্তীর্ণের সময় অতীত হয়েছে।",
        "ipb_expiry_temp": "লুকানো ব্যবহারকারীনাম বাধা চিরস্থায়ী হতে হবে।",
        "ipb_hide_invalid": "এই অ্যাকাউন্ট বাধা দেয়া সম্ভব নয়; এটি {{PLURAL:$1|একের অধিক|$1টি}} সম্পাদনা করেছে।",
        "ipb_already_blocked": "\"$1\" ইতিমধ্যে ব্লক",
        "lockedbyandtime": "({{GENDER:$1|$1}} $2 এর $3 সময়ে)",
        "move-page": "$1 স্থানান্তর",
        "move-page-legend": "পাতা স্থানান্তর",
-       "movepagetext": "নিচের ফর্মটি ব্যবহার করে একটি পাতার শিরোনাম পরিবর্তন করা যাবে, এবং সেই সাথে নতুন শিরোনামে এর সমগ্র ইতিহাস স্থানান্তর করা যাবে।\nপুরনো শিরোনামটি নতুন শিরোনামটির প্রতি একটি পুনর্নির্দেশনা ধারণ করবে।\nযেসমস্ত পুনর্নির্দেশনা পুরনো শিরোনামটির দিকে নির্দেশ করছিল, সেগুলি স্বয়ংক্রিয়ভাবে হালনাগাদ করতে পারবেন।\nযদি তা না চান, তবে [[Special:DoubleRedirects|দ্বি-পুনর্নির্দেশনা]] বা [[Special:BrokenRedirects|অচল পুনর্নির্দেশনাগুলি]] পরীক্ষা করে দেখতে ভুলবেন না।\nসংযোগগুলি যাতে তাদের লক্ষ্যে পৌঁছায়, তা নিশ্চিত করার দায়িত্ব আপনার।\n\nলক্ষ্য করুন যে যদি নতুন শিরোনামে ইতিমধ্যেই একটি পাতা থেকে থাকে, তবে উৎস পাতাটি সেই শিরোনামে স্থানান্তর করা হবে '''না''', যদি না নতুন শিরোনামের পাতাটি খালি থাকে বা একটি পুননির্দেশনা হয় এবং এর কোন অতীত সম্পাদনা ইতিহাস না থাকে।\nঅর্থাৎ আপনি ভুল করে নাম পরিবর্তন করলে সহজেই পুরনো নামে ফেরত যেতে পারবেন, কিন্তু ইতিমধ্যে বিদ্যমান কোন পাতার উপরে লিখতে পারবেন না।\n\n'''সতর্কীকরণ!'''\nকোন জনপ্রিয় পাতার ক্ষেত্রে এই পরিবর্তনটি খুবই আকস্মিক হতে পারে; অগ্রসর হবার আগে এই কাজটির ফলাফল কী হতে পারে, সে ব্যাপারে অনুগ্রহ করে নিশ্চিত হোন।",
+       "movepagetext": "নিচের ফর্মটি ব্যবহার করে একটি পাতার শিরোনাম পরিবর্তন করা যাবে, এবং সেই সাথে নতুন শিরোনামে এর সমগ্র ইতিহাস স্থানান্তর করা যাবে।\nপুরনো শিরোনামটি নতুন শিরোনামটির প্রতি একটি পুনর্নির্দেশনা ধারণ করবে।\nযেসমস্ত পুনর্নির্দেশনা পুরনো শিরোনামটির দিকে নির্দেশ করছিল, সেগুলি স্বয়ংক্রিয়ভাবে হালনাগাদ করতে পারবেন।\nযদি তা না চান, তবে [[Special:DoubleRedirects|দ্বি-পুনর্নির্দেশনা]] বা [[Special:BrokenRedirects|অচল পুনর্নির্দেশনাগুলি]] পরীক্ষা করে দেখতে ভুলবেন না।\nসংযোগগুলি যাতে তাদের লক্ষ্যে পৌঁছায়, তা নিশ্চিত করার দায়িত্ব আপনার।\n\nলক্ষ্য করুন যে যদি নতুন শিরোনামে ইতিমধ্যেই একটি পাতা থেকে থাকে, তবে উৎস পাতাটি সেই শিরোনামে স্থানান্তর করা হবে <strong>না</strong>, যদি না নতুন শিরোনামের পাতাটি খালি থাকে বা একটি পুননির্দেশনা হয় এবং এর কোন অতীত সম্পাদনা ইতিহাস না থাকে।\nঅর্থাৎ আপনি ভুল করে নাম পরিবর্তন করলে সহজেই পুরনো নামে ফেরত যেতে পারবেন, কিন্তু ইতিমধ্যে বিদ্যমান কোন পাতার উপরে লিখতে পারবেন না।\n\n<strong>টীকা:</strong>\nকোন জনপ্রিয় পাতার ক্ষেত্রে এই পরিবর্তনটি খুবই আকস্মিক হতে পারে; অগ্রসর হবার আগে এই কাজটির ফলাফল কী হতে পারে, সে ব্যাপারে অনুগ্রহ করে নিশ্চিত হোন।",
        "movepagetext-noredirectfixer": "নিচের ফর্মটি ব্যবহার করে একটি পাতার শিরোনাম পরিবর্তন করা যাবে, এবং সেই সাথে নতুন শিরোনামে এর সমগ্র ইতিহাস স্থানান্তর করা যাবে।\nপুরনো শিরোনামটি নতুন শিরোনামটির প্রতি একটি পুনর্নির্দেশনা ধারণ করবে।\n[[Special:DoubleRedirects|দ্বি-পুনর্নির্দেশনা]] বা [[Special:BrokenRedirects|অচল পুনর্নির্দেশনাগুলি]] পরীক্ষা করে দেখতে ভুলবেন না।\nসংযোগগুলি যাতে তাদের লক্ষ্যে পৌঁছায়, তা নিশ্চিত করার দায়িত্ব আপনার।\n\nলক্ষ্য করুন যে যদি নতুন শিরোনামে ইতিমধ্যেই একটি পাতা থেকে থাকে, তবে উৎস পাতাটি সেই শিরোনামে স্থানান্তর করা হবে '''না''', যদি না নতুন শিরোনামের পাতাটি খালি থাকে বা একটি পুননির্দেশনা হয় এবং এর কোন অতীত সম্পাদনা ইতিহাস না থাকে। \nঅর্থাৎ আপনি ভুল করে নাম পরিবর্তন করলে সহজেই পুরনো নামে ফেরত যেতে পারবেন, কিন্তু ইতিমধ্যে বিদ্যমান কোন পাতার উপরে লিখতে পারবেন না।\n\n'''সতর্কীকরণ!'''\nকোন জনপ্রিয় পাতার ক্ষেত্রে এই পরিবর্তনটি খুবই আকস্মিক হতে পারে;\nঅগ্রসর হবার আগে এই কাজটির ফলাফল কী হতে পারে, সে ব্যাপারে অনুগ্রহ করে নিশ্চিত হোন।",
        "movepagetalktext": "পাতাটির সাথে সাথে সংশ্লিষ্ট আলোচনা পাতাটিও স্বয়ংক্রিয়ভাবে সরানো হবে '''যদি না:'''\n*খালি নয় এমন একটি আলাপ পাতা নতুন শিরোনামটির অধীনে ইতিমধ্যেই বিদ্যমান থাকে, অথবা\n*আপনি নিচের বাক্সটি থেকে টিক সরিয়ে নিতে পারেন।\n\nএসব ক্ষেত্রে আপনি চাইলে নিজের হাতে পাতাটিকে সরাতে বা একত্রীকরণ করতে পারেন।",
        "moveuserpage-warning": "'''সতর্কতা:''' আপনি একটি ব্যবহারকারী পাতা স্থানান্তর করছেন। অনুগ্রহ করে লক্ষ্য করুন যে এর মাধ্যমে কেবলমাত্র পাতাটি স্থানান্তর হবে, কিন্তু পাতার নাম পরিবর্তন হবে ''না''।",
        "movenosubpage": "এই পাতাটির কোনো উপপাতা নেই।",
        "movereason": "কারণ:",
        "revertmove": "পূর্বাবস্থায় ফেরত নেওয়া হোক",
-       "delete_and_move_text": "==মুছে ফেলা আবশ্যক==\n\n\"[[:$1]]\" শিরোনামের গন্তব্য পাতাটি ইতিমধ্যেই বিদ্যমান। আপনি কি স্থানান্তর সফল করার জন্য পাতাটি মুছে দিতে চান?",
+       "delete_and_move_text": "\"[[:$1]]\" শিরোনামের গন্তব্য পাতাটি ইতিমধ্যেই বিদ্যমান। আপনি কি স্থানান্তর সফল করার জন্য পাতাটি মুছে দিতে চান?",
        "delete_and_move_confirm": "হ্যাঁ, পাতাটি মুছে ফেলা হোক",
        "delete_and_move_reason": "\"[[$1]]\" থেকে স্থানান্তরের স্বার্থে মুছে ফেলা হয়েছে",
        "selfmove": "উৎস ও গন্তব্য পাতা একই শিরোনামের; কোন পাতা একই শিরোনামের আরেক পাতায় সরানো যাবে না।",
        "move-leave-redirect": "পুনর্নির্দেশ রেখে দিন",
        "protectedpagemovewarning": "'''সতর্কীকরণ:''' এই পাতাটি বন্ধ করা হয়েছে; কেবলমাত্র প্রশাসক মর্যাদার ব্যবহারকারীরাই এটি স্থানান্তর করতে পারবেন।\nআপনার সুবিধার্থে পাতাটির সাম্প্রতিক সংরক্ষণ লগের বিবরণ নিচে দেওয়া হলো।",
        "semiprotectedpagemovewarning": "'''নোট:''' এই পাতাটির ব্যবহার নিয়ন্ত্রণ করা হয়েছে তাই নিবন্ধনকৃত ব্যবহারকারী এটি স্থানান্তর করতে পারবেন।\nআপনার সুবিধার্থে পাতাটির সাম্প্রতিক সংরক্ষণ লগের বিবরণ নিচে দেওয়া হলো:",
-       "move-over-sharedrepo": "== এই নামের ফাইল রয়েছে ==\n[[:$1]] নামের ফাইলটি শেয়ার্ড রিপোজিটরীতে রয়েছে। একই নামের একটি ফাইল এখানে স্থানান্তর করা হলে পূর্বের ফাইলটি প্রতিস্থাপিত হবে।",
+       "move-over-sharedrepo": "[[:$1]] নামের ফাইলটি শেয়ার্ড সংগ্রহস্থলে রয়েছে। একই নামের একটি ফাইল এখানে স্থানান্তর করা হলে পূর্বের ফাইলটি প্রতিস্থাপিত হবে।",
        "file-exists-sharedrepo": "নির্ধিত নামের ফাইলটি পূর্বেই শেয়ার্ড রিপোজিরটীতে রয়েছে। \nঅনুগ্রহ করে অন্য কোনো নাম নির্বাচন করুন।",
        "export": "পাতা রপ্তানি",
        "exporttext": "আপনি কোন একটি নির্দিষ্ট পাতার বা অনেকগুলি পাতার একটি সেটের বিষয়বস্তু এবং সম্পাদনা ইতিহাস XML-এ আবৃত করে রপ্তানি করতে পারেন। এটি মিডিয়াউইকি সফটওয়্যার ব্যবহারকারী অন্য একটি উইকিতে [[Special:Import|আমদানি পাতার]] মাধ্যমে আমদানি করা সম্ভব।\n\nপাতা রপ্তানি করতে চাইলে নিচের টেক্সট বাক্সে শিরোনামগুলি প্রবেশ করান, প্রতি লাইনে একটি শিরোনাম দিয়ে, এবং নির্বাচন করুন আপনি বর্তমান সংস্করণসহ সবগুলি পুরনো সংস্করণ পাতার ইতিহাসের লাইনসহ রপ্তানি করতে চান, নাকি কেবল সর্বশেষ সম্পাদনাটির তথ্যসহ বর্তমান সংস্করণটি রপ্তানি করতে চান।\n\nদ্বিতীয় ক্ষেত্রটিতে আপনি একটি সংযোগও ব্যবহার করতে পারেন, যেমন \"[[{{MediaWiki:Mainpage}}]]\" পাতাটির জন্য [[{{#Special:Export}}/{{MediaWiki:Mainpage}}]]।",
        "tooltip-t-recentchangeslinked": "এই পাতা থেকে সংযোগ আছে, এমন পাতাগুলিতে সাম্প্রতিক পরিবর্তন",
        "tooltip-feed-rss": "এই পাতার জন্য আরএসএস ফিড",
        "tooltip-feed-atom": "এই পাতার জন্য অ্যাটম ফিড",
-       "tooltip-t-contributions": "{{GENDER:|এই ব্যবহারকারীর}} অবদানগুলির একটি তালিকা",
-       "tooltip-t-emailuser": "{{GENDER:|এই ব্যবহারকারীকে}} একটি ই-মেইল পাঠান",
+       "tooltip-t-contributions": "{{GENDER:$1|এই ব্যবহারকারীর}} অবদানগুলির একটি তালিকা",
+       "tooltip-t-emailuser": "{{GENDER:$1|এই ব্যবহারকারীকে}} একটি ইমেইল পাঠান",
        "tooltip-t-info": "এই পাতা সম্পর্কে আরো তথ্য",
        "tooltip-t-upload": "ফাইল আপলোড করুন",
        "tooltip-t-specialpages": "সব বিশেষ পাতার তালিকা",
        "newimages-legend": "ছাকনী",
        "newimages-label": "ফাইলের নাম (অথবা এর কোন অংশ):",
        "newimages-showbots": "বটের আপলোড গুলো দেখাও।",
+       "newimages-hidepatrolled": "টহলকৃত আপলোড আড়াল করো",
        "noimages": "দেখার মত কিছু নেই।",
        "ilsubmit": "অনুসন্ধান",
        "bydate": "তারিখ অনুযায়ী",
        "version-libraries-license": "লাইসেন্স",
        "version-libraries-description": "বিবরণ",
        "version-libraries-authors": "লেখক",
-       "redirect": "পাতা, à¦«à¦¾à¦\87ল, à¦¬à§\8dযবহারà¦\95রà§\80, à¦\85থবা à¦¸à¦\82শà§\8bধন আইডি দ্বারা পুনঃনির্দেশ করা হয়েছে",
+       "redirect": "পাতা, à¦«à¦¾à¦\87ল, à¦¬à§\8dযবহারà¦\95রà§\80, à¦¸à¦\82শà§\8bধন à¦¬à¦¾ à¦²à¦\97 আইডি দ্বারা পুনঃনির্দেশ করা হয়েছে",
        "redirect-legend": "একটি ফাইল অথবা পাতায় পুনঃনির্দেশ করা হয়েছে",
        "redirect-summary": "এই বিশেষ পাতাটি একটি ফাইলে (ফাইলের নাম), একটি পাতায় (সংস্করণ আইডি বা পাতা আইডি), অথবা একটি ব্যবহারকরী পাতায় (সংখ্যায় লেখা ব্যবহারকারী আইডি) পুনঃনির্দেশিত হয়েছে। ব্যবহার:  [[{{#Special:Redirect}}/file/উদাহরণ.jpg]], [[{{#Special:Redirect}}/page/64308]], [[{{#Special:Redirect}}/revision/328429]], অথবা [[{{#Special:Redirect}}/user/101]]।",
        "redirect-submit": "যাও",
        "redirect-page": "পাতার আইডি",
        "redirect-revision": "পাতা সংস্করণ",
        "redirect-file": "ফাইলের নাম",
+       "redirect-logid": "লগ আইডি",
        "redirect-not-exists": "মান পাওয়া যায়নি",
        "fileduplicatesearch": "সদৃশ ফাইলের জন্য অনুসন্ধান",
        "fileduplicatesearch-summary": "হ্যাশ ভ্যালুর ওর ভিত্তি করে একই ছবিগুলো খুঁজুন।",
index af4cbfc..3601084 100644 (file)
        "querypage-disabled": "Aquesta pàgina especial està desactivada per a no perjudicar el rendiment.",
        "apihelp": "Ajuda de l'API",
        "apihelp-no-such-module": "No s'ha trobat el mòdul \"$1\".",
+       "apisandbox": "Pàgina de proves de l'API",
+       "apisandbox-api-disabled": "L'API està desactivada en aquest lloc.",
+       "apisandbox-intro": "Utilitzeu aquesta pàgina per experimentar amb l'<nowiki />'''API de web service de MediaWiki'''.\nVisiteu [//www.mediawiki.org/wiki/API:Main_page la documentació de l'API] per a més informació sobre l'ús de l'API. Exemple: [//www.mediawiki.org/wiki/API#A_simple_example recuperar el contingut d'una Pàgina Principal]. Seleccioneu una acció per veure més exemples.\n\nTingueu en compte que, encara que això és una pàgina de proves, les accions que feu en aquesta pàgina poden modificar la wiki.",
+       "apisandbox-submit": "Fes sol·licitud",
+       "apisandbox-reset": "Neteja",
+       "apisandbox-examples": "Exemple",
+       "apisandbox-results": "Resultat",
+       "apisandbox-request-url-label": "Sol·licita URL:",
+       "apisandbox-request-time": "Temps de sol·licitud: $1",
        "booksources": "Obres de referència",
        "booksources-search-legend": "Cerca fonts de llibres",
        "booksources-isbn": "ISBN:",
index 02c6486..9428d8f 100644 (file)
        "suppress": "Хьулдар",
        "apihelp": "API гӀо",
        "apihelp-no-such-module": "Модуль «$1» цакарий.",
+       "apisandbox": "Ловзаран майда API",
+       "apisandbox-intro": "Лела йе хӀара агӀо '''MediaWiki API''' зуьйш.\nAPI кхин муха лела йо хьажа [//www.mediawiki.org/wiki/API:Main_page кхузахь]. Масала, [//www.mediawiki.org/wiki/API#A_simple_example Коьрта агӀона чулацам схьаэца]. Кхин масалаш ган харжа дийриг.",
+       "apisandbox-submit": "Дехар далар",
+       "apisandbox-reset": "ЦӀанъян",
+       "apisandbox-examples": "Масала",
+       "apisandbox-results": "Хилам",
+       "apisandbox-request-url-label": "Дехаран URL-адрес:",
+       "apisandbox-request-time": "Дехар дина хан: $1",
        "booksources": "Жайнан хьосташ",
        "booksources-search-legend": "Жайнех лаьцна хаам лахар",
        "booksources-search": "Лахар",
index e07376f..e1b08b2 100644 (file)
        "create": "دروستکردن",
        "create-local": "وەسفی ناوچەیی زۆر بکە",
        "editthispage": "دەستکاری ئەم پەڕەیە بکە‌",
-       "create-this-page": "ئەم پەڕە دروست بکە",
+       "create-this-page": "ئەم پەڕەیە دروست بکە",
        "delete": "سڕینەوە",
        "deletethispage": "سڕینه‌وه‌ی ئه‌م په‌ڕه‌یه‌",
        "undeletethispage": "ئەم پەڕەیە بھێنەوە",
        "viewsourcetext": "دەتوانی سەرچاوەی ئەم پەڕە ببینی و کۆپیی بکەی:",
        "viewyourtext": "دەتوانی ژێدەری '''دەستکارییەکەت''' لەم پەڕەیەدا ببینی و کۆپی بکەی:",
        "protectedinterface": "ئەم پەڕەیە دەقی ڕواڵەتی نەرمامێری ئەم ویکییە نیشان دەدات و بۆ بەرگری لە خراپکاری پارێزراوە.\nبۆ زیادکردن یان گۆڕینی وەرگێڕانەکان بۆ ھەموو ویکییەکان، تکایە لە [//translatewiki.net/ translatewiki.net]، پرۆژەی ناوچەیی کردنی میدیاویکی کەڵک وەربگرە.",
-       "editinginterface": "<strong>ھۆشیار بە:</strong> خەریکی دەستکاریی پەڕەیەک دەکەیت کە بۆ دابینکردنی دەقی ڕووکاری نەرمامێر بەکاردێت.\nگۆڕانکارییەکانی لەم پەڕەیەدا کاریگەر دەبێت لە سەر ڕواڵەتی پەڕەکانی بەکارھێنەرانی تر لەم ویکییەدا.",
+       "editinginterface": "<strong>ھۆشیار بە:</strong> خەریکی دەستکاریی پەڕەیەک دەکەیت کە بۆ دابین کردنی دەقی ڕووکاری نەرمامێر بەکاردێت.\nگۆڕانکارییەکان لەم پەڕەیەدا لە سەر ڕواڵەتی پەڕەکان بۆ بەکارھێنەرانی تر لەم ویکییەدا کاریگەر دەبێت.",
        "cascadeprotected": "ئەم لاپەڕە پارێزراوە لە دەستکاریی، چونکا خراوەتە سەر ڕیزی ئەم {{PLURAL:$1|لاپەڕانه‌، کە}} که‌ به‌ هه‌ڵکردنی بژارده‌ی داڕژان هه‌ڵکراوه‌:\n$2",
        "namespaceprotected": "تۆ ناتوانی لاپەڕەکانی ناو نەیمسپەیسی '''$1''' بگۆڕی.",
        "customcssprotected": "دەسەڵاتی دەستکارییکردنی ئەم پەڕەی CSS ـەت نییە چوونکە ڕێکخستنەکانی کەسێکی تر لەخۆ دەگرێت.",
        "semiprotectedpagewarning": "'''ئاگاداری:''' ئەم پەڕە داخراوە بۆ ئەوی تەنھا بەکارھێنەرە تۆمارکراوەکان بتوانن دەستکاریی بکەن.\nدوایین لۆگ بۆ ژێدەر لە خوارەوەدا ھاتووە:",
        "cascadeprotectedwarning": "'''ئاگاداری:''' ئەم لاپەڕە داخراوە بۆیە تەنها ئەو کەسانەی مافی بەڕێوبەرایەتی‌یان هەیە ئەتوانن دەستکاری بکەن، چۆنکا ئەمە {{PLURAL:$1|لاپه‌ڕه‌|لاپه‌ڕانه‌}} لە زنجیرەی پارێزراوەکانی لە خۆ گرتووە‌:",
        "titleprotectedwarning": "'''ئاگاداری: ئەم پەڕە داخراوە، بۆئەوەی بۆ درووست‌کردنی [[Special:ListGroupRights|مافە تایبەتەکانت]] پێویستن.'''\nبۆ چاوانە دوایین لۆگ لە خوارەوەدا ھاتووە:",
-       "templatesused": "ئەو {{PLURAL:$1|داڕێژە کە لەم پەڕەیەدا بە کارھێنراوە|داڕێژانە کە لەم پەڕەیەدا بە کارھێنراون}}:",
-       "templatesusedpreview": "ئەو {{PLURAL:$1|داڕێژە کە لەم پێشبینینەدا بە کارھێنراوە|داڕێژانە کە لەم پێشبینینەدا بە کارھێنراون}}:",
+       "templatesused": "ئەو {{PLURAL:$1|داڕێژەیە کە لەم پەڕەیەدا بە کارھێنراوە|داڕێژانە کە لەم پەڕەیەدا بە کارھێنراون}}:",
+       "templatesusedpreview": "ئەو {{PLURAL:$1|داڕێژەیە کە لەم پێشبینینەدا بە کارھێنراوە|داڕێژانە کە لەم پێشبینینەدا بە کارھێنراون}}:",
        "templatesusedsection": "ئەو {{PLURAL:$1|داڕێژە|داڕێژانە}} کە لەم بەشەدا بە کارھێنراون:",
        "template-protected": "(پارێزراو)",
        "template-semiprotected": "(نیوەپارێزراو)",
        "newuserlogpagetext": "ئەمە لۆگێکی دروستکردنی بەکارھێنەرە.",
        "rightslog": "لۆگی مافەکانی بەکارھێنەر",
        "rightslogtext": "ئەمە لۆگی دەستکاری مافەکانی بەکار‌هێنەرە.",
-       "action-read": "خوێندنەوەی ئەم پەڕە",
+       "action-read": "خوێندنەوەی ئەم پەڕەیە",
        "action-edit": "دەستکاریی ئەم پەڕەیە",
        "action-createpage": "دروستکردنی پەڕەکان",
        "action-createtalk": "دروستکردنی پەڕەکانی وتووێژ",
        "action-createaccount": "دروست کردنی ئەم ھەژماری بەکارھێنەرییە",
-       "action-history": "مێژووی ئەم پەڕەیە ببینە",
+       "action-history": "بینینی مێژووی ئەم پەڕەیە",
        "action-minoredit": "نیشان‌کردنی ئەم دەستکاریە وەک بچووک",
        "action-move": "گواستنەوەی ئەم پەڕەیە",
        "action-move-subpages": "گواستنەوەی ئەم پەڕەیە و ژێرپەڕەکانی",
        "protect-expiring-local": "بەسەردەچێ لە $1",
        "protect-expiry-indefinite": "بێسنوور",
        "protect-cascade": "پەڕەکانی نێو ئەم پەڕە بپارێزە (پاراستنی تاڤگەیی)",
-       "protect-cantedit": "ناتوانی ئاستی پاراستنی ئەم پەڕە بگۆڕی، چونکوو تۆ ئیجازەی ئەم کارەت نیە.",
+       "protect-cantedit": "ناتوانیت ئاستەکانی پاراستنی ئەم پەڕەیە بگۆڕیت، چونکە بۆت نییە دەستکاریی بکەیت.",
        "protect-othertime": "کاتی تر:",
        "protect-othertime-op": "کاتی تر",
        "protect-existing-expiry": "ئەم کاتی بەسەرچوونی ماوە کە هەیە: $3، $2",
        "tooltip-ca-undelete": "هێنانەوەی دەستکاریەکانی پیش سڕینەوە وا لەسەر ئەم لاپەڕە ڕووی‌داوە",
        "tooltip-ca-move": "ئەم پەڕەیە بگوازەوە",
        "tooltip-ca-watch": "ئەم پەڕە بخە سەر لیستی چاودێریت",
-       "tooltip-ca-unwatch": "ئەم پەڕە لە لیستی چاودێریت لابە",
+       "tooltip-ca-unwatch": "ئەم پەڕەیە لە لیستی چاودێریت لاببە",
        "tooltip-search": "لە {{SITENAME}} بگەڕێ",
        "tooltip-search-go": "بڕۆ بۆ پەڕەیەک کە بە تەواوی ئەم ناوەی ھەیە ئەگەر بببێت",
        "tooltip-search-fulltext": "لە پەڕەکاندا بگەڕێ بۆ ئەم دەقە",
        "tooltip-save": "گۆڕانکارییەکانی خۆت پاشکەوت بکە",
        "tooltip-preview": "پێش بینینی گۆڕانکارییەکان، تکایە پێش پاشکەوت کردن ئەمە بەکار بھێنە",
        "tooltip-diff": "نیشان دانی گۆڕانکارییەکانت لە دەقەکەدا",
-       "tooltip-compareselectedversions": "جیاوازییەکانی دوو وەشانە دیاریکراوەی ئەم پەڕە ببینە.",
+       "tooltip-compareselectedversions": "جیاوازییەکانی دوو وەشانە دیاریکراوەی ئەم پەڕەیە ببینە.",
        "tooltip-watch": "ئەم پەڕە بخە سەر لیستی چاودێریت",
        "tooltip-watchlistedit-normal-submit": "ناونیشانەکان لاببە",
        "tooltip-watchlistedit-raw-submit": "نوێکردنەوەی لیستی چاودێری",
index a249e14..2931250 100644 (file)
        "querypage-disabled": "Tato speciální stránka je z výkonnostních důvodů vypnuta.",
        "apihelp": "Nápověda k API",
        "apihelp-no-such-module": "Modul „$1“ nebyl nalezen.",
+       "apisandbox": "API pískoviště",
+       "apisandbox-api-disabled": "API je na tomto webu vypnuto.",
+       "apisandbox-intro": "Pomocí této stránky můžete experimentovat s '''webovými službami MediaWiki API'''.\nPodrobností využití API najdete v [//www.mediawiki.org/wiki/API:Main_page jeho dokumentaci]. Příklad: [//www.mediawiki.org/wiki/API#A_simple_example získání obsahu Hlavní stránky]. Další příklady uvidíte vybráním parametru action.\nUvědomte si, že přestože jste na pískovišti, mohou akce provedené na této stránce wiki změnit.",
+       "apisandbox-submit": "Odeslat požadavek",
+       "apisandbox-reset": "Vyčistit",
+       "apisandbox-examples": "Příklad",
+       "apisandbox-results": "Výsledek",
+       "apisandbox-request-url-label": "URL požadavku:",
+       "apisandbox-request-time": "Trvání požadavku: $1",
        "booksources": "Zdroje knih",
        "booksources-search-legend": "Vyhledat knižní zdroje",
        "booksources-search": "Hledat",
        "group-bot.js": "/* Zde uvedený JavaScript bude použit pouze pro boty */",
        "group-sysop.js": "/* Zde uvedený JavaScript bude použit pouze pro správce */",
        "group-bureaucrat.js": "/* Zde uvedený JavaScript bude použit pouze pro byrokraty */",
-       "anonymous": "anonymní {{PLURAL:$1|uživatel|uživatelé|uživatelé}} {{GRAMMAR:2sg|{{SITENAME}}}}",
+       "anonymous": "{{PLURAL:$1|anonymního uživatele|anonymních uživatelů}} {{GRAMMAR:2sg|{{SITENAME}}}}",
        "siteuser": "uživatel {{grammar:2sg|{{SITENAME}}}} $1",
        "anonuser": "anonymní uživatel {{grammar:2sg|{{SITENAME}}}} $1",
        "lastmodifiedatby": "Tuto stránku naposledy {{GENDER:$4|změnil|změnila|změnil}} $3 v $2, $1.",
-       "othercontribs": "Do textu {{PLURAL:$2|přispěl|přispěli}} $1.",
+       "othercontribs": "Založeno na práci $1.",
        "others": "další",
-       "siteusers": "{{PLURAL:$2|uživatel|uživatelé|uživatelé}} {{grammar:2sg|{{SITENAME}}}} $1",
+       "siteusers": "{{PLURAL:$2|{{GENDER:$1|uživatele|uživatelky}}|uživatelů}} {{grammar:2sg|{{SITENAME}}}} $1",
        "anonusers": "anonymní {{PLURAL:$2|uživatel|uživatelé}} {{grammar:2sg|{{SITENAME}}}} $1",
        "creditspage": "Zásluhy za stránku",
        "nocredits": "K této stránce neexistuje informace o zásluhách.",
index 2bec075..3960575 100644 (file)
@@ -75,6 +75,8 @@
        "october-date": "октѡврїꙗ $1 числа",
        "november-date": "ноємврїꙗ $1 числа",
        "december-date": "дєкємврїꙗ $1 числа",
+       "period-am": "до полоудьни",
+       "period-pm": "по полоудьни",
        "pagecategories": "{{PLURAL:$1|Катигорїꙗ|Катигорїи|Катигорїѩ|Катигорїѩ}}",
        "category_header": "катигорїѩ ⁖ $1 ⁖ страницѧ",
        "subcategories": "подъкатигорїѩ",
        "create-this-page": "сѥѩ страницѧ сътворѥниѥ",
        "delete": "поничьжєниѥ",
        "deletethispage": "сѥѩ страницѧ поничьжєниѥ",
+       "undeletethispage": "сѥѩ страницѧ въстаниѥ иꙁ поничьжєниꙗ",
+       "undelete_short": "въстаниѥ {{PLURAL:$1|ѥдьнꙑ мѣнꙑ|$1 мѣноу|$1 мѣнъ}} иꙁ поничьжєниꙗ",
+       "viewdeleted_short": "{{PLURAL:$1|ѥдьнꙑ поничьжєнꙑ мѣнꙑ|$1 поничьжєноу мѣноу|$1 поничьжєнъ мѣнъ}} поꙁьрѣниѥ",
        "protect": "ꙁабранѥниѥ",
        "protect_change": "иꙁмѣнѥниѥ",
        "protectthispage": "сѥѩ страницѧ ꙁабранєниѥ",
        "unprotect": "ꙁабранѥниꙗ обраꙁа иꙁмѣнѥниѥ",
+       "unprotectthispage": "ꙁабранѥниꙗ сѥѩ страницѧ обраꙁа иꙁмѣнѥниѥ",
        "newpage": "нова страница",
        "talkpage": "сѥѩ страницѧ бєсѣда",
        "talkpagelinktext": "бєсѣда",
        "specialpage": "нарочьна страница",
        "personaltools": "моꙗ орѫдиꙗ",
+       "articlepage": "члѣна поꙁьрѣниѥ",
        "talk": "бєсѣда",
        "views": "поꙁьрѣниꙗ",
        "toolbox": "орѫдиꙗ",
+       "userpage": "польꙃєватєлꙗ страницѧ поꙁьрѣниѥ",
+       "imagepage": "дѣла страницѧ поꙁьрѣниѥ",
+       "templatepage": "обраꙁьца страницѧ поꙁьрѣниѥ",
+       "viewhelppage": "помощи страницѧ поꙁьрѣниѥ",
+       "categorypage": "катигорїѩ страницѧ поꙁьрѣниѥ",
+       "viewtalkpage": "бєсѣдꙑ поꙁьрѣниѥ",
        "otherlanguages": "дроугꙑ ѩꙁꙑкꙑ",
        "redirectedfrom": "(прѣнаправлѥниѥ отъ ⁖ $1 ⁖)",
        "redirectpagesub": "прѣнаправлѥниѥ",
        "redirectto": "прѣнаправлѥниѥ къ :",
        "lastmodifiedat": "страницѧ послѣдьнꙗ мѣна сътворѥна $2 · $1 бѣ ⁙",
+       "protectedpage": "сꙗ страница ꙁабранѥна ѥстъ",
        "jumpto": "прѣиди къ :",
        "jumptonavigation": "плаваниѥ",
        "jumptosearch": "исканиѥ",
        "toc": "каталогъ",
        "showtoc": "виждь",
        "hidetoc": "съкрꙑи",
+       "confirmable-yes": "да",
+       "confirmable-no": "нѣтъ",
        "viewdeleted": "$1 видєти хощєши ;",
+       "restorelink": "{{PLURAL:$1|ѥдьна поничьжєна мѣна|$1 поничьжєноу мѣноу|$1 поничьжєнъ мѣнъ}}",
+       "feedlinks": "потокъ :",
        "red-link-title": "$1 (сѥѩ страницѧ нѣстъ)",
        "nstab-main": "члѣнъ",
        "nstab-user": "польꙃєватєл҄ь",
        "mainpage-nstab": "главьна страница",
        "nosuchspecialpage": "сѥѩ нарочнꙑ страницѧ нѣстъ",
        "error": "блаꙁна",
+       "databaseerror-error": "блаꙁна : $1",
        "internalerror": "вънѫтрѣнꙗ блаꙁна",
        "badtitle": "ꙁъло имѧ",
        "viewsource": "страницѧ источьнъ обраꙁъ",
        "viewsource-title": "вижьдь страницѧ ⁖ $1 ⁖ источьнъ обраꙁъ",
+       "exception-nologin": "тꙑ нє въшьлъ ѥси",
        "welcomeuser": "Добрѣ прити · $1!",
        "welcomecreation-msg": "твоѥ польꙃєватєльско мѣсто сътворєно ѥстъ ⁙\nнꙑнѣ иꙁмѣнити [[Special:Preferences|{{GRAMMAR:genitive|{{SITENAME}}}} строи]] можєши",
        "yourname": "твоѥ имѧ",
        "userlogin-yourname": "польꙃєватєлꙗ имѧ",
        "userlogin-yourname-ph": "твоѥ польꙃєватєлꙗ имѧ напьши",
+       "createacct-another-username-ph": "польꙃєватєлꙗ имѧ напьши",
        "yourpassword": "таино слово напиши",
        "userlogin-yourpassword": "таино слово",
        "userlogin-yourpassword-ph": "твоѥ таино слово напьши",
        "userlogout": "ис̾ходъ",
        "notloggedin": "тꙑ нє въшьлъ ѥси",
        "userlogin-noaccount": "мѣсто ти нѣстъ ли ?",
+       "userlogin-joinproject": "въ {{grammar:locative|{{SITENAME}}}} чѧсть прими",
        "nologin": "мѣсто ти нѣстъ ли ? $1",
        "nologinlink": "съꙁижди си мѣсто",
        "createaccount": "съꙁижди си мѣсто",
        "userlogin-helplink2": "помощь въниждєниꙗ дѣлꙗ",
        "createaccountreason": "какъ съмꙑслъ :",
        "createacct-reason": "какъ съмꙑслъ",
+       "createacct-reason-ph": "чєсо дѣлꙗ ино польꙃєватєльско мѣсто сътворити хощєши ;",
        "createacct-submit": "съꙁижди си мѣсто",
+       "createacct-another-submit": "съꙁижди мѣсто",
        "createacct-benefit-heading": "{{SITENAME}} съꙁьдаѥтъ сѧ чьловѣкꙑ · ижє ꙗко тꙑ сѫтъ",
        "createacct-benefit-body1": "{{PLURAL:$1|мѣна|мѣнꙑ|мѣнъ}}",
        "createacct-benefit-body2": "{{PLURAL:$1|страница|страници|страницѧ}}",
        "loginerror": "въхода блаꙁна",
        "createacct-error": "мѣста сътворѥниꙗ блаꙁна",
        "loginsuccess": "'''нꙑнѣ тꙑ {{GENDER|въшьлъ|въшьла}} въ {{grammar:locative|{{SITENAME}}}} подь имьньмъ ⁖ $1 ⁖.'''",
-       "mailmypassword": "поÑ\81Ñ\8aли Ð½Ð¾Ð²Ð¾ Ñ\82аино Ñ\81лово",
+       "mailmypassword": "нова Ñ\82аина Ñ\81лова Ð¾Ñ\83Ñ\81Ñ\82авлѥниѥ",
        "accountcreated": "мѣсто сътворєно ѥстъ",
        "accountcreatedtext": "польꙃєватєльско мѣсто [[{{ns:User}}:$1|$1]] ([[{{ns:User talk}}:$1|бєсѣда]]) сътворєно бѣ",
        "loginlanguagelabel": "ѩꙁꙑкъ : $1",
        "retypenew": "опакꙑ ново таиноѥ слово напиши :",
        "resetpass-submit-loggedin": "таина словєсє иꙁмѣнѥниѥ",
        "resetpass-submit-cancel": "отъмѣтаниѥ",
+       "passwordreset": "нова таина слова оуставлѥниѥ",
        "passwordreset-username": "польꙃєватєлꙗ имѧ :",
        "changeemail-none": "(нѣстъ)",
        "link_sample": "съвѧꙁи имѧ",
        "creating": "сътворѥниѥ ⁖ $1 ⁖",
        "editingsection": "исправлѥниѥ ⁖ $1 ⁖ (чѧсть)",
        "editingcomment": "исправлѥниѥ ⁖ $1 ⁖ (нова чѧсть)",
+       "explainconflict": "нѣкъто сѭ страницѫ иꙁмѣнилъ въ врѣмѧ ѥгда тꙑ ѥѩжє исправлꙗти почѧашє ⁙\nврьхоу нꙑнѣщьн҄ь страницѧ обраꙁъ авлѥнъ ѥстъ ⁙\nниꙁоу жє твоꙗ мѣна авлѥна ѥстъ ⁙\nсъѥдинити твоѭ мѣноу съ новомь обраꙁомь страницѧ длъжєнъ ѥси ⁙\nащє жє ⁖ {{int:savearticle}} ⁖ жьмєши · <strong>тъкъмо</strong> напьсаниѥ ижє врьхоу ѥстъ съхранѥно бѫдєтъ",
        "yourtext": "твоѥ напьсаниѥ",
        "templatesused": "сѥѩ страницѧ {{PLURAL:$1|сь обраꙁьць польꙃоуѥтъ сѧ ѥстъ|с҄и обраꙁьца польꙃоуѭтъ сѧ ѥстє|с҄и обраꙁьци польꙃоуѭтъ сѧ сѫтъ}} :",
        "template-protected": "(ꙁабранєно ѥстъ)",
        "template-semiprotected": "(чѧстьно ꙁабранѥно)",
        "hiddencategories": "сꙗ страница въ {{PLURAL:$1|1 съкрꙑтѣи катигорїи|$1 съкрꙑтѣхъ катигорїѩ}} сѧ авлꙗѥтъ :",
        "moveddeleted-notice": "сꙗ страница поничьжєна ѥстъ ⁙\nпоничьжєниꙗ и прѣимєнованиꙗ їстории сѥѩ страницѧ нижѣ видѣти можєши",
+       "postedit-confirmation-created": "страница сътворѥна ѥстъ",
        "postedit-confirmation-saved": "твоꙗ мѣна съхранѥна ѥстъ",
        "viewpagelogs": "сѥѩ страницѧ їсторїѩ",
        "cur": "нꙑ҃н",
        "deletedhist": "поничьжєна їсторїꙗ",
        "revdelete-otherreason": "инъ или допльнитєл҄ьнъ съмꙑслъ :",
        "revdelete-reasonotherlist": "инъ съмꙑслъ",
+       "revdelete-edit-reasonlist": "поничьжєниꙗ съмꙑслъ исправлѥниѥ",
        "mergehistory-reason": "какъ съмꙑслъ :",
        "editundo": "отъмѣтаниѥ",
        "searchresults": "исканиꙗ слѣдьствиѥ",
index 6674c27..bd0ec04 100644 (file)
        "passwordreset-emailtext-ip": "Nogen (sandsynligvis dig, fra IP-adressen $1) har anmodet om at få nulstillet din adgangskode til {{SITENAME}} ($4). {{PLURAL:$3|Den følgende brugerkonto er associeret|De følgende brugerkonti er associerede}} med denne e-mailadresse:\n\n$2\n\n{{PLURAL:$3|Denne midlertidige adgangskode|Disse midlertidige adgangskoder}} vil udløbe om {{PLURAL:$5|en dag|$5 dage}}.\nDu bør logge på og vælge en ny adgangskode nu. Hvis en anden end dig har lavet denne anmodning, eller hvis du er kommet i tanke om din oprindelig adgangskode og ikke længere ønsker at ændre den, kan du ignorere denne meddelelse og fortsætte med at bruge din gamle adgangskode.",
        "passwordreset-emailtext-user": "Brugeren $1 på {{SITENAME}} har anmodet om at få nulstillet din adgangskode til {{SITENAME}} ($4). {{PLURAL:$3|Den følgende brugerkonto er associeret|De følgende brugerkonti er associerede}} med denne e-mailadresse:\n\n$2\n\n{{PLURAL:$3|Denne midlertidige adgangskode|Disse midlertidige adgangskoder}} vil udløbe om {{PLURAL:$5|en dag|$5 dage}}.\nDu bør logge på og vælge en ny adgangskode nu. Hvis en anden end dig har lavet denne anmodning, eller hvis du er kommet i tanke om din oprindelig adgangskode og ikke længere ønsker at ændre den, kan du ignorere denne meddelelse og fortsætte med at bruge din gamle adgangskode.",
        "passwordreset-emailelement": "Brugernavn: \n$1\n\nMidlertidig adgangskode: \n$2",
-       "passwordreset-emailsentemail": "Hvis dettte er en registreret e-mail-adresse til din konto, så vil en e-mail om nulstilling af adgangskoden blive sendt.",
+       "passwordreset-emailsentemail": "Hvis denne e-mailadresse er knyttet til din konto, så vil en e-mail om nulstilling af adgangskoden blive sendt.",
+       "passwordreset-emailsentusername": "Hvis der er en e-mailadresse forbundet med dette brugernavn, så vil en e-mail om nulstilling af adgangskoden blive sendt.",
        "passwordreset-emailsent-capture": "En e-mail om nulstilling af adgangskode, som vist nedenfor, er blevet sendt.",
        "passwordreset-emailerror-capture": "En mail om nulstilling af adgangskode, som vist nedenfor, blev genereret, men det lykkedes ikke at sende den til {{GENDER:$2|bruger}}: $1",
        "changeemail": "Ændr eller fjern e-mailadresse",
        "querypage-disabled": "Denne specialside er deaktiveret af hensyn til ydeevnen.",
        "apihelp": "API-hjælp",
        "apihelp-no-such-module": "Modul \"$1\" ikke fundet.",
+       "apisandbox": "API-sandkassen",
+       "apisandbox-api-disabled": "API er deaktiveret på dette websted.",
+       "apisandbox-intro": "Brug denne side til at eksperimentere med '''MediaWiki web service API'''.\nVi henviser til [//www.mediawiki.org/wiki/API:Main_page dokumentationen af API] for yderligere oplysninger om brug af API.  Eksempel: [//www.mediawiki.org/wiki/API#A_simple_example få indholdet af en forside]. Vælg en handling at se flere eksempler.\n\nBemærk, at selv om dette er en sandkasse, vil handlinger du udfører på denne side redigere wikien.",
+       "apisandbox-submit": "Lav forespørgsel",
+       "apisandbox-reset": "Ryd",
+       "apisandbox-examples": "Eksempel",
+       "apisandbox-results": "Resultat",
+       "apisandbox-request-url-label": "Forespurgt URL:",
+       "apisandbox-request-time": "Forespørgselstid: $1",
        "booksources": "Bogkilder",
        "booksources-search-legend": "Søgning efter bøger",
        "booksources-search": "Søg",
index df90cc9..39262b2 100644 (file)
        "querypage-disabled": "Diese Spezialseite wurde aus Gründen der Leistungserhaltung deaktiviert.",
        "apihelp": "API-Hilfe",
        "apihelp-no-such-module": "Modul „$1“ nicht gefunden.",
+       "apisandbox": "API-Spielwiese",
+       "apisandbox-api-disabled": "Die API wurde auf diesem Wiki deaktiviert.",
+       "apisandbox-intro": "Diese Seite kannst du für Versuche mit der '''MediaWiki-API''' verwenden.\nDie [//www.mediawiki.org/wiki/API:Main_page/de Dokumentation zur API] enthält weitere Hinweise zu ihrer Nutzung. Beispiel: [//www.mediawiki.org/wiki/API:Main_page/de#Ein_einfaches_Beispiel Den Inhalt der Hauptseite abrufen]. Für weitere Beispiele eine der verfügbaren Aktionen auswählen.\n\nObwohl dies eine Spielwiese ist, bedenke, dass Aktionen, die du auf dieser Seite durchführst, das Wiki verändern.",
+       "apisandbox-submit": "Anfrage ausführen",
+       "apisandbox-reset": "Leeren",
+       "apisandbox-examples": "Beispiel",
+       "apisandbox-results": "Ergebnis",
+       "apisandbox-request-url-label": "Anforderungs-URL:",
+       "apisandbox-request-time": "Dauer der Anfrage: $1",
        "booksources": "ISBN-Suche",
        "booksources-search-legend": "Suche nach Bezugsquellen für Bücher",
        "booksources-search": "Suchen",
        "pageinfo-robot-index": "Erlaubt",
        "pageinfo-robot-noindex": "Nicht erlaubt",
        "pageinfo-watchers": "Anzahl der Beobachter dieser Seite",
-       "pageinfo-visiting-watchers": "Anzahl der Seitenbeobachter, die die letzten Bearbeitungen besucht haben",
+       "pageinfo-visiting-watchers": "Anzahl der Beobachter dieser Seite, die die letzten Bearbeitungen besucht haben",
        "pageinfo-few-watchers": "Weniger als {{PLURAL:$1|ein|$1}} Beobachter",
        "pageinfo-few-visiting-watchers": "Es könnte einen beobachtenden Benutzer geben oder nicht, der die letzten Bearbeitungen besucht hat",
        "pageinfo-redirects-name": "Anzahl der Weiterleitungen zu dieser Seite",
index 8c09ed9..b3e71d0 100644 (file)
        "querypage-disabled": "Na pelaya xısusi,sebeb de performansi ra qefılneyê.",
        "apihelp": "Peştiya APIyi",
        "apihelp-no-such-module": "Modulê \"$1\" çıniyo.",
+       "apisandbox": "API qumdor",
+       "apisandbox-submit": "Bıwazê",
+       "apisandbox-reset": "Bestere",
+       "apisandbox-examples": "Misal",
+       "apisandbox-results": "Netice",
+       "apisandbox-request-url-label": "URL waştış:",
+       "apisandbox-request-time": "Demê waştışi: $1",
        "booksources": "Çımeyê kıtaban",
        "booksources-search-legend": "Seba çımeyanê kıtaban cı geyre",
        "booksources-isbn": "ISBN:",
index c89f5d6..2b82787 100644 (file)
        "querypage-disabled": "Αυτή η ειδική σελίδα είναι απενεργοποιημένη για λόγους απόδοσης.",
        "apihelp": "Βοήθεια API",
        "apihelp-no-such-module": "Το Module \"$1\" δεν βρέθηκε.",
+       "apisandbox": "Αμμοδοχείο API",
+       "apisandbox-api-disabled": "Η Διεπαφή Προγραμματισμού Εφαρμογών (API) είναι απενεργοποιημένη σε αυτήν την τοποθεσία.",
+       "apisandbox-intro": "Χρησιμοποιήστε αυτήν τη σελίδα για να πειραματιστείτε με το '''API της υπηρεσίας ιστού του MediaWiki'''.\nΑνατρέξτε στην [//www.mediawiki.org/wiki/API:Main_page τεκμηρίωση του API] για περισσότερες πληροφορίες πάνω στη χρήση του API. Παράδειγμα: [//www.mediawiki.org/wiki/API#A_simple_example λήψη του περιεχομένου της Αρχικής Σελίδας]. Επιλέξτε μια ενέργεια για να δείτε περισσότερα παραδείγματα.\n\nΝα σημειωθεί ότι, παρόλο που αυτό εδώ είναι αμμοδοχείο, οι ενέργειες που εκτελείτε σε αυτήν τη σελίδα μπορούν να τροποποιήσουν το wiki.",
+       "apisandbox-submit": "Υποβολή του αιτήματος",
+       "apisandbox-reset": "Εκκαθάριση",
+       "apisandbox-examples": "Παράδειγμα",
+       "apisandbox-results": "Αποτέλεσμα",
+       "apisandbox-request-url-label": "Αίτηση URL:",
+       "apisandbox-request-time": "Χρόνος αιτήματος: $1",
        "booksources": "Πηγές βιβλίων",
        "booksources-search-legend": "Αναζήτηση για πηγές βιβλίων",
        "booksources-isbn": "ISBN:",
index cb9f7c4..2e1df41 100644 (file)
        "virus-scanfailed": "scan failed (code $1)",
        "virus-unknownscanner": "unknown antivirus:",
        "logouttext": "<strong>You are now logged out.</strong>\n\nNote that some pages may continue to be displayed as if you were still logged in, until you clear your browser cache.",
+       "cannotlogoutnow-title": "Cannot log out now",
+       "cannotlogoutnow-text": "Logging out is not possible when using $1.",
        "welcomeuser": "Welcome, $1!",
        "welcomecreation-msg": "Your account has been created.\nYou can change your {{SITENAME}} [[Special:Preferences|preferences]] if you wish.",
        "yourname": "Username:",
        "remembermypassword": "Remember my login on this browser (for a maximum of $1 {{PLURAL:$1|day|days}})",
        "userlogin-remembermypassword": "Keep me logged in",
        "userlogin-signwithsecure": "Use secure connection",
+       "cannotloginnow-title": "Cannot log in now",
+       "cannotloginnow-text": "Logging in is not possible when using $1.",
        "yourdomainname": "Your domain:",
        "password-change-forbidden": "You cannot change passwords on this wiki.",
        "externaldberror": "There was either an authentication database error or you are not allowed to update your external account.",
        "resetpass_submit": "Set password and log in",
        "changepassword-success": "Your password has been changed successfully!",
        "changepassword-throttled": "You have made too many recent login attempts.\nPlease wait $1 before trying again.",
+       "botpasswords": "Bot passwords",
+       "botpasswords-summary": "<em>Bot passwords</em> allow access to a user account via the API without using the account's main login credentials. The user rights available when logged in with a bot password may be restricted.\n\nIf you don't know why you might want to do this, you should probably not do it. No one should ever ask you to generate one of these and give it to them.",
+       "botpasswords-disabled": "Bot passwords are disabled.",
+       "botpasswords-no-central-id": "To use bot passwords, you must be logged in to a centralized account.",
+       "botpasswords-existing": "Existing bot passwords",
+       "botpasswords-createnew": "Create a new bot password",
+       "botpasswords-editexisting": "Edit an existing bot password",
+       "botpasswords-label-appid": "Bot name:",
+       "botpasswords-label-create": "Create",
+       "botpasswords-label-update": "Update",
+       "botpasswords-label-cancel": "Cancel",
+       "botpasswords-label-delete": "Delete",
+       "botpasswords-label-resetpassword": "Reset the password",
+       "botpasswords-label-grants": "Applicable grants:",
+       "botpasswords-help-grants": "Each grant gives access to listed user rights that a user account already has. See the [[Special:ListGrants|table of grants]] for more information.",
+       "botpasswords-label-restrictions": "Usage restrictions:",
+       "botpasswords-label-grants-column": "Granted",
+       "botpasswords-bad-appid": "The bot name \"$1\" is not valid.",
+       "botpasswords-insert-failed": "Failed to add bot name \"$1\". Was it already added?",
+       "botpasswords-update-failed": "Failed to update bot name \"$1\". Was it deleted?",
+       "botpasswords-created-title": "Bot password created",
+       "botpasswords-created-body": "The bot password \"$1\" was created successfully.",
+       "botpasswords-updated-title": "Bot password updated",
+       "botpasswords-updated-body": "The bot password \"$1\" was updated successfully.",
+       "botpasswords-deleted-title": "Bot password deleted",
+       "botpasswords-deleted-body": "The bot password \"$1\" was deleted.",
+       "botpasswords-newpassword": "The new password to log in with <strong>$1</strong> is <strong>$2</strong>. <em>Please record this for future reference.</em>",
+       "botpasswords-no-provider": "BotPasswordsSessionProvider is not available.",
+       "botpasswords-restriction-failed": "Bot password restrictions prevent this login.",
+       "botpasswords-invalid-name": "The username specified does not contain the bot password separator (\"$1\").",
+       "botpasswords-not-exist": "User \"$1\" does not have a bot password named \"$2\".",
        "resetpass_forbidden": "Passwords cannot be changed",
        "resetpass-no-info": "You must be logged in to access this page directly.",
        "resetpass-submit-loggedin": "Change password",
        "right-createpage": "Create pages (which are not discussion pages)",
        "right-createtalk": "Create discussion pages",
        "right-createaccount": "Create new user accounts",
+       "right-autocreateaccount": "Automatically log in with an external user account",
        "right-minoredit": "Mark edits as minor",
        "right-move": "Move pages",
        "right-move-subpages": "Move pages with their subpages",
        "action-createpage": "create pages",
        "action-createtalk": "create discussion pages",
        "action-createaccount": "create this user account",
+       "action-autocreateaccount": "automatically create this external user account",
        "action-history": "view the history of this page",
        "action-minoredit": "mark this edit as minor",
        "action-move": "move this page",
        "apihelp-summary": "",
        "apihelp-no-such-module": "Module \"$1\" not found.",
        "apihelp-link": "[[Special:ApiHelp/$1|$2]]",
+       "apisandbox": "API sandbox",
+       "apisandbox-summary": "",
+       "apisandbox-jsonly": "JavaScript is required to use the API sandbox.",
+       "apisandbox-api-disabled": "The API is disabled on this site.",
+       "apisandbox-intro": "Use this page to experiment with the <strong>MediaWiki web service API</strong>.\nRefer to [[mw:API:Main page|the API documentation]] for further details of API usage. Example: [//www.mediawiki.org/wiki/API#A_simple_example get the content of a Main Page]. Select an action to see more examples.\n\nNote that, although this is a sandbox, actions you carry out on this page may modify the wiki.",
+       "apisandbox-fullscreen": "Expand panel",
+       "apisandbox-fullscreen-tooltip": "Expand the sandbox panel to fill the browser window.",
+       "apisandbox-unfullscreen": "Show page",
+       "apisandbox-unfullscreen-tooltip": "Reduce the sandbox panel, so MediaWiki navigation links are available.",
+       "apisandbox-submit": "Make request",
+       "apisandbox-reset": "Clear",
+       "apisandbox-retry": "Retry",
+       "apisandbox-loading": "Loading information for API module \"$1\"...",
+       "apisandbox-load-error": "An error occurred while loading information for API module \"$1\": $2",
+       "apisandbox-no-parameters": "This API module has no parameters.",
+       "apisandbox-helpurls": "Help links",
+       "apisandbox-examples": "Examples",
+       "apisandbox-dynamic-parameters": "Additional parameters",
+       "apisandbox-dynamic-parameters-add-label": "Add parameter:",
+       "apisandbox-dynamic-parameters-add-placeholder": "Parameter name",
+       "apisandbox-dynamic-error-exists": "A parameter named \"$1\" already exists.",
+       "apisandbox-deprecated-parameters": "Deprecated parameters",
+       "apisandbox-fetch-token": "Auto-fill the token",
+       "apisandbox-submit-invalid-fields-title": "Some fields are invalid",
+       "apisandbox-submit-invalid-fields-message": "Please correct the marked fields and try again.",
+       "apisandbox-results": "Results",
+       "apisandbox-sending-request": "Sending API request...",
+       "apisandbox-loading-results": "Receiving API results...",
+       "apisandbox-results-error": "An error occurred while loading the API query response: $1.",
+       "apisandbox-request-url-label": "Request URL:",
+       "apisandbox-request-time": "Request time: {{PLURAL:$1|$1 ms}}",
+       "apisandbox-results-fixtoken": "Correct token and resubmit",
+       "apisandbox-results-fixtoken-fail": "Failed to fetch \"$1\" token.",
+       "apisandbox-alert-page": "Fields on this page are not valid.",
+       "apisandbox-alert-field": "The value of this field is not valid.",
        "booksources": "Book sources",
        "booksources-summary": "",
        "booksources-search-legend": "Search for book sources",
        "mw-widgets-titleinput-description-new-page": "page does not exist yet",
        "mw-widgets-titleinput-description-redirect": "redirect to $1",
        "api-error-blacklisted": "Please choose a different, descriptive title.",
+       "sessionmanager-tie": "Cannot combine multiple request authentication types: $1.",
+       "sessionprovider-generic": "$1 sessions",
+       "sessionprovider-mediawiki-session-cookiesessionprovider": "cookie-based sessions",
+       "sessionprovider-nocookies": "Cookies may be disabled. Ensure you have cookies enabled and start again.",
        "randomrootpage": "Random root page"
 }
index 5dc6ea6..f214c12 100644 (file)
        "querypage-disabled": "Tiu ĉi speciala paĝo estas malfunkciigita pro rendimentaj kialoj.",
        "apihelp": "Helpo pri API",
        "apihelp-no-such-module": "Modulo \"$1\" ne estis trovita.",
+       "apisandbox": "API testejo",
+       "apisandbox-api-disabled": "API estas malŝalta en ĉi tiu retejo.",
+       "apisandbox-intro": "Uzu tiun ĉi paĝon por eksperimenti kun '''MediaWiki API'''.\nVidu [//www.mediawiki.org/wiki/API:Main_page la API-dokumentadon] por pli da detaloj pri la uzo de API. Ekz-e: [//www.mediawiki.org/wiki/API#A_simple_example atingi la enhavon de la Ĉefpaĝo]. Elektu agon por vidi pliajn ekzemplojn.\n\nNotu ke, kvankam ĉi tiu estas provejo, agoj kiun vi faros en ĉi tiu paĝo povas modifi la vikion.",
+       "apisandbox-submit": "Fari mendon",
+       "apisandbox-reset": "Nuligi",
+       "apisandbox-examples": "Ekzemplo",
+       "apisandbox-results": "Rezulto",
+       "apisandbox-request-url-label": "Mendi URL-on.",
+       "apisandbox-request-time": "Tempo de peto: $1",
        "booksources": "Libroservoj",
        "booksources-search-legend": "Serĉi librofontojn",
        "booksources-search": "Serĉi",
index 9e560c6..fe4a81e 100644 (file)
        "querypage-disabled": "Esta página especial está deshabilitada por motivos de rendimiento.",
        "apihelp": "Ayuda de la API",
        "apihelp-no-such-module": "No se encontró el módulo \"$1\".",
+       "apisandbox": "Zona de pruebas API",
+       "apisandbox-api-disabled": "La API está desactivada en este sitio.",
+       "apisandbox-intro": "Usa esta página para experimentar con la '''API de servicio web de MediaWiki'''.\nPara más detalles sobre el uso de la API, visita [//www.mediawiki.org/wiki/API:Main_page su documentación]. Ejemplo: [//www.mediawiki.org/wiki/API#A_simple_example obtener el contenido de una Página principal]. Selecciona una acción para ver más ejemplos.\n\nObserva que, aunque sea una página de pruebas, las acciones que realices en esta página pueden modificar el wiki.",
+       "apisandbox-submit": "Realizar solicitud",
+       "apisandbox-reset": "Limpiar",
+       "apisandbox-examples": "Ejemplo",
+       "apisandbox-results": "Resultado",
+       "apisandbox-request-url-label": "URL solicitante:",
+       "apisandbox-request-time": "Tiempo de solicitud: $1",
        "booksources": "Fuentes de libros",
        "booksources-search-legend": "Buscar fuentes de libros",
        "booksources-search": "Buscar",
index 720c350..c1a0fcc 100644 (file)
        "querypage-disabled": "See erilehekülg on keelatud, et jõudlust hoida.",
        "apihelp": "API abi",
        "apihelp-no-such-module": "Moodulit \"$1\" ei leitud.",
+       "apisandbox": "API liivakast",
+       "apisandbox-api-disabled": "API on selles võrgukohas keelatud.",
+       "apisandbox-intro": "Kasuta seda lehekülge '''MediaWiki API''' katsetamiseks.\nÜksikasjad API kasutamise kohta leiad [//www.mediawiki.org/wiki/API:Main_page API dokumentatsioonist]. Näide: [//www.mediawiki.org/wiki/API#A_simple_example esilehe sisu hankimine]. Vali toiming, et näha veel näiteid.\n\nPane tähele, et kuigi siin on liivakast, võivad siin leheküljel tehtud toimingud vikit muuta.",
+       "apisandbox-submit": "Tee päring",
+       "apisandbox-reset": "Puhasta",
+       "apisandbox-examples": "Näide",
+       "apisandbox-results": "Tulemus",
+       "apisandbox-request-url-label": "Päringu URL:",
+       "apisandbox-request-time": "Päringuaeg: $1",
        "booksources": "Raamatuotsimine",
        "booksources-search-legend": "Raamatuotsimine",
        "booksources-search": "Otsi",
index fe6484a..c1622c7 100644 (file)
        "uploaded-script-svg": "عنصر قابل برنامه‌ریزی «$1» در پرونده بارگذاری اس‌وی‌جی یافت شد.",
        "uploaded-hostile-svg": "سی‌اس‌اس نا امن در عنصر سبک پروندهٔ بارگذاری شدهٔ اس‌وی‌جی یافت شد.",
        "uploaded-event-handler-on-svg": "قرار دادن ویژگی‌های مدیریت رویداد <code>$1=\"$2\"</code> در پرونده‌های اس‌وی‌جی مجاز نیست.",
+       "uploaded-href-attribute-svg": "ویژگی‌های href در پرونده‌های SVG فقط برای اهدافhttp:// &lrm; وhttps:// &lrm; مجاز هستند، <code>&lt;$1 $2=\"$3\"&gt;</code> یافت شد.",
        "uploaded-href-unsafe-target-svg": "در پرونده SVG بارگذاری‌شده برای هدف نادرست <code>&lt;$1 $2=\"$3\"&gt;</code> برچسب href یافت شد.",
        "uploaded-animate-svg": "برچسب  \"animate\" یافت شده ممکن است herf را تغییر دهد. از مشخصه \"from\" <code>&lt;$1 $2=\"$3\"&gt;</code> در پرونده SVG بارگذاری‌شده استفاده کنید.",
        "uploaded-setting-event-handler-svg": "تنظیمات مشخصه گرداننده رویداد بسته شده‌است. کد <code>&lt;$1 $2=\"$3\"&gt;</code>  در پرونده بارگذاری‌شده یافت شد.",
        "querypage-disabled": "این صفحه ویژه به دلایل عملکردی غیرفعال شده‌است.",
        "apihelp": "راهنمای API",
        "apihelp-no-such-module": "پودمان \" $1 \" یافت نشد.",
+       "apisandbox": "گودال ماسه‌بازی رابط برنامه‌نویسی",
+       "apisandbox-api-disabled": "رابط برنامه‌نویسی در این تارنما غیرفعال شده‌است.",
+       "apisandbox-intro": "از این صفحه برای آزمایش '''خدمات وب API مدیاویکی''' استفاده کنید.\nبرای جزئیات بیشتر دربارهٔ نحوهٔ استفاده از API به [//www.mediawiki.org/wiki/API:Main_page مستندات API] رجوع کنید. مثال: [//www.mediawiki.org/wiki/API#A_simple_example دریافت محتوای صفحهٔ اصلی]. برای دیدن مثال‌های بیشتر عملکردی را انتخاب کنید.",
+       "apisandbox-submit": "ایجاد درخواست",
+       "apisandbox-reset": "پاک‌کردن",
+       "apisandbox-examples": "مثال",
+       "apisandbox-results": "نتیجه",
+       "apisandbox-request-url-label": "درخواست آدرس:",
+       "apisandbox-request-time": "زمان درخواست: $1",
        "booksources": "منابع کتاب",
        "booksources-search-legend": "جستجوی منابع کتاب",
        "booksources-isbn": "شابک:",
        "version-poweredby-others": "دیگران",
        "version-poweredby-translators": "مترجمان translatewiki.net",
        "version-credits-summary": "افراد زیر را به خاطر ویرایش‌هایش در [[Special:Version|مدیاویکی]] معرفی می‌نمائیم.",
-       "version-license-info": "مدیاویکی یک نرم‌افزار آزاد است. می‌توانید آن را با شرایط نگارش ۲، یا (با نظر خودتان) هر نگارش جدیدتری از پروانه جامع همگانی گنو که توسط بنیاد نرم‌افزار آزاد منتشر شده‌است، بازنشر کنید.\n\nمدیاویکی با این امید که مفید واقع شود منتشر شده‌است، ولی هیچ‌گونه ضمانتی، حتا ضمانت ضمنی تجاری یا مناسب بودن برای یک مصرف خاص را ارائه نمی‌کند. برای اطلاعات بیش‌تر، پروانه جامع همگانی گنو را مشاهده کنید.\n\nشما باید [{{SERVER}}{{SCRIPTPATH}}/COPYING یک نسخه از پروانه جامع همگانی گنو] را به همراه این برنامه دریافت کرده باشید. در غیر این صورت با Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA یا آن را [//www.gnu.org/licenses/old-licenses/gpl-2.0.html مکاتبه کرده یا آن را به صورت برخط بخوانید].",
+       "version-license-info": "مدیاویکی یک نرم‌افزار آزاد است. می‌توانید آن را با شرایط نگارش ۲، یا (با نظر خودتان) هر نگارش جدیدتری از پروانه جامع همگانی گنو که توسط بنیاد نرم‌افزار آزاد منتشر شده‌است، بازنشر کنید.\n\nمدیاویکی با این امید که مفید واقع شود منتشر شده‌است، ولی هیچ‌گونه ضمانتی، حتی ضمانت ضمنی تجاری یا مناسب بودن برای یک مصرف خاص را ارائه نمی‌کند. برای اطلاعات بیش‌تر، پروانه جامع همگانی گنو را مشاهده کنید.\n\nشما باید [{{SERVER}}{{SCRIPTPATH}}/COPYING یک نسخه از پروانه جامع همگانی گنو] را به همراه این برنامه دریافت کرده باشید. در غیر این صورت با Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA یا آن را [//www.gnu.org/licenses/old-licenses/gpl-2.0.html مکاتبه کرده یا آن را به صورت برخط بخوانید].",
        "version-software": "نسخهٔ نصب‌شده",
        "version-software-product": "محصول",
        "version-software-version": "نسخه",
index 5ed597f..10e5b96 100644 (file)
        "querypage-disabled": "Tämä toimintosivu on poistettu käytöstä suorituskykyyn liittyvien syiden vuoksi.",
        "apihelp": "API-apu",
        "apihelp-no-such-module": "Moduulia ”$1” ei löydy.",
+       "apisandbox": "API-hiekkalaatikko",
+       "apisandbox-api-disabled": "API on poistettu käytöstä tällä sivustolla.",
+       "apisandbox-intro": "Tämä on '''MediaWiki API:n''' hiekkalaatikko.\n[//www.mediawiki.org/wiki/API:Main_page API-dokumentaatio] kertoo lisää API:en käytöstä.",
+       "apisandbox-submit": "Tee pyyntö",
+       "apisandbox-reset": "Tyhjennä",
+       "apisandbox-examples": "Esimerkki",
+       "apisandbox-results": "Tulos",
+       "apisandbox-request-url-label": "Pyynnön URL",
+       "apisandbox-request-time": "Pyyntöaika: $1",
        "booksources": "Kirjalähteet",
        "booksources-search-legend": "Etsi kirjalähteitä",
        "booksources-isbn": "ISBN",
index 4bbee5b..3ac64b1 100644 (file)
                        "SRXcraft",
                        "StevenJ81",
                        "The RedBurn",
-                       "Fredlefred"
+                       "Fredlefred",
+                       "Lbayle"
                ]
        },
        "tog-underline": "Soulignement des liens :",
        "querypage-disabled": "Cette page spéciale est désactivée pour des raisons de performances.",
        "apihelp": "Aide de l’API",
        "apihelp-no-such-module": "Le module « $1 » est introuvable.",
+       "apisandbox": "Bac à sable API",
+       "apisandbox-jsonly": "Le bac à sable de l'API nécessite JavaScript",
+       "apisandbox-api-disabled": "API est désactivé sur ce site.",
+       "apisandbox-intro": "Utilisez cette page pour expérimenter l’'''API webservice de MediaWiki'''.\nReportez-vous à [//www.mediawiki.org/wiki/API:Main_page la documentation de l’API] pour plus de détails sur l’utilisation de l’API. Exemple: [//www.mediawiki.org/wiki/API#A_simple_example obtenir le contenu d'une page principale]. Choisissez une option pour voir d'autres exemples.",
+       "apisandbox-unfullscreen": "Afficher la page",
+       "apisandbox-submit": "Faire la demande",
+       "apisandbox-reset": "Effacer",
+       "apisandbox-retry": "Réessayer",
+       "apisandbox-loading": "Chargement des informations du module \"$1\" de l'API...",
+       "apisandbox-load-error": "Une erreur s'est produite lors du chargement des informations du module \"$1\" de l'API: $2",
+       "apisandbox-examples": "Exemples",
+       "apisandbox-dynamic-parameters": "Paramètres supplémentaires",
+       "apisandbox-dynamic-parameters-add-label": "Ajout du paramètre:",
+       "apisandbox-dynamic-parameters-add-placeholder": "Nom du paramètre",
+       "apisandbox-dynamic-error-exists": "Un paramètre nommé \"$1\" existe déjà.",
+       "apisandbox-deprecated-parameters": "Paramètres obsolètes",
+       "apisandbox-submit-invalid-fields-title": "Certains champs ne sont pas valides",
+       "apisandbox-submit-invalid-fields-message": "Veuillez corriger les champs marqués et essayez de nouveau.",
+       "apisandbox-results": "Résultat",
+       "apisandbox-sending-request": "Envoi de la requête à l'API...",
+       "apisandbox-loading-results": "Réception des résultats de l'API...",
+       "apisandbox-request-url-label": "Requête URL :",
+       "apisandbox-request-time": "Durée de la demande: $1",
+       "apisandbox-results-fixtoken": "Corrigez le jeton et renvoyez",
+       "apisandbox-results-fixtoken-fail": "Impossible de récupérer le jeton \"$1\"",
+       "apisandbox-alert-page": "Les champs de cette page ne sont pas valides.",
+       "apisandbox-alert-field": "La valeur de ce champ n'est pas valide.",
        "booksources": "Ouvrages de référence",
        "booksources-search-legend": "Rechercher parmi des ouvrages de référence",
        "booksources-isbn": "ISBN :",
index e7e7af8..32ca842 100644 (file)
        "querypage-disabled": "Esta páxina especial está desactivada por razóns de rendemento.",
        "apihelp": "Axuda coa API",
        "apihelp-no-such-module": "Non se atopou o módulo \"$1\".",
+       "apisandbox": "Zona de probas API",
+       "apisandbox-api-disabled": "API está desactivado neste sitio.",
+       "apisandbox-intro": "Use esta páxina para experimentar co '''servizo web da API de MediaWiki'''.\nConsulte a [//www.mediawiki.org/wiki/API:Main_page documentación da API] para obter máis información sobre o uso da API. Exemplo: [//www.mediawiki.org/wiki/API#A_simple_example obter o contido dunha páxina de inicio]. Seleccione unha acción para ollar máis exemplos.\n\nTeña en conta que, aínda que esta é unha páxina de probas, as accións que realice nesta páxina poden modificar o wiki.",
+       "apisandbox-submit": "Facer a solicitude",
+       "apisandbox-reset": "Limpar",
+       "apisandbox-examples": "Exemplo",
+       "apisandbox-results": "Resultado",
+       "apisandbox-request-url-label": "URL da solicitude:",
+       "apisandbox-request-time": "Duración da solicitude: $1",
        "booksources": "Fontes bibliográficas",
        "booksources-search-legend": "Procurar fontes bibliográficas",
        "booksources-search": "Procurar",
index 2081f83..936af3f 100644 (file)
        "querypage-disabled": "Die Spezialsyte isch deaktiviert wore us Leischtigserhaltigs-Grind.",
        "apihelp": "API-Hilff",
        "apihelp-no-such-module": "Ds Modul «$1» lat sech nid la finde.",
+       "apisandbox": "API-Sandchaschte",
+       "apisandbox-api-disabled": "D API isch uf däm Wiki deaktiviert wore.",
+       "apisandbox-intro": "Die Syte chasch bruche fir Versuech mit dr '''MediaWiki-API'''.\nIn dr [//www.mediawiki.org/wiki/API:Main_page/de Dokumäntation zue dr API] het s no meh Hiiwys zue ihre Nutzig. Byschpel: [//www.mediawiki.org/wiki/API:Main_page/de#Beispiel Dr Inhalt vu dr Hauptsyte abruefe]. Fir meh Byschpel eini vu dr verfiegbare Aktionen uuswehle.",
+       "apisandbox-submit": "Aafrog uusfiere",
+       "apisandbox-reset": "Lääre",
+       "apisandbox-examples": "Byyschpil",
+       "apisandbox-results": "Ergebnis",
+       "apisandbox-request-url-label": "Aaforderigs-URL:",
+       "apisandbox-request-time": "Aafrogzyt: $1",
        "booksources": "ISBN-Suech",
        "booksources-search-legend": "Suech no Bezugsquälle fir Biecher",
        "booksources-search": "Sueche",
index 9c70672..6b75f8f 100644 (file)
        "querypage-disabled": "કાર્યક્ષમતાના કારણે આ ખાસ પાનું નિષ્ક્રિ કરાયું છે.",
        "apihelp": "API મદદ",
        "apihelp-no-such-module": "સાધન જૂથ \"$1\" ન મળ્યું.",
+       "apisandbox-submit": "વિનંતી કરો",
+       "apisandbox-reset": "સાફ કરો",
+       "apisandbox-examples": "ઉદાહરણ",
+       "apisandbox-results": "પરિણામ",
        "booksources": "પુસ્તક સ્રોત",
        "booksources-search-legend": "પુસ્તક સ્રોત શોધો",
        "booksources-isbn": "આઇએસબીએન:",
index c10fdb3..2ac1fe2 100644 (file)
        "right-applychangetags": "החלת [[Special:Tags|תגיות]] יחד עם שינויים",
        "right-changetags": "הוספת והסרה של [[Special:Tags|תגיות]] כלשהן לגרסאות מסוימות ולרשומות יומן",
        "grant-generic": "חבילת ההרשאות \"$1\"",
-       "grant-group-page-interaction": "פע×\99×\9c×\95ת ×\94×\99×\93×\95×\93×\99ת ×\91דפים",
-       "grant-group-file-interaction": "פע×\99×\9c×\95ת ×\94×\99×\93×\95×\93×\99ת ×\91×\9e×\93×\99×\94",
-       "grant-group-watchlist-interaction": "פע×\99×\9c×\95ת ×\94×\99×\93×\95×\93×\99ת ×\91רשימת המעקב שלך",
+       "grant-group-page-interaction": "×\90×\99× ×\98ר×\90קצ×\99×\94 ×¢×\9d דפים",
+       "grant-group-file-interaction": "×\90×\99× ×\98ר×\90קצ×\99×\94 ×¢×\9d ×§×\91צ×\99×\9d",
+       "grant-group-watchlist-interaction": "×\90×\99× ×\98ר×\90קצ×\99×\94 ×¢×\9d רשימת המעקב שלך",
        "grant-group-email": "שליחת דוא\"ל",
        "grant-group-high-volume": "ביצוי פעולות מרובות",
        "grant-group-customization": "התאמה אישית והעדפות",
        "grant-blockusers": "חסימת משתמשים ושחרורם",
        "grant-createaccount": "יצירת חשבונות",
        "grant-createeditmovepage": "יצירה, עריכה והעברה של דפים",
-       "grant-delete": "×\9e×\97×\99קת ×\93פ×\99×\9d, ×\92רס×\90×\95ת ×\95×¢×\99×\95×\9c×\99 יומן",
+       "grant-delete": "×\9e×\97×\99קת ×\93פ×\99×\9d, ×\92רס×\90×\95ת ×\95רש×\95×\9e×\95ת יומן",
        "grant-editinterface": "עריכת מרחב השם מדיה ויקי ו־CSS/JavaScript של משתמשים",
        "grant-editmycssjs": "עריכת CSS/JavaScript שלך",
        "grant-editmyoptions": "עריכת העדפות המשתמש שלך",
        "grant-uploadfile": "העלאת קבצים חדשים",
        "grant-basic": "הרשאות בסיסיות",
        "grant-viewdeleted": "צפייה בקבצים ודפים שנמחקו",
-       "grant-viewmywatchlist": "צפייה ברשימת מעקב שלך",
+       "grant-viewmywatchlist": "צפ×\99×\99×\94 ×\91רש×\99×\9eת ×\94×\9eעק×\91 ×©×\9c×\9a",
        "newuserlogpage": "יומן רישום משתמשים",
        "newuserlogpagetext": "זהו יומן המכיל הרשמות של משתמשים.",
        "rightslog": "יומן תפקידים",
        "upload-too-many-redirects": "הכתובת מכילה הפניות רבות מדי",
        "upload-http-error": "התרחשה שגיאת HTTP‏: $1",
        "upload-copy-upload-invalid-domain": "העלאת קבצים משרת זה אינה אפשרית.",
-       "upload-foreign-cant-upload": "×\91×\95×\95×\99ק×\99 ×\94×\96×\94 ×\9c×\90 ×\9e×\95×\92×\93ר ×\90×\99×\9a ×\9c×\94×¢×\9c×\95ת ×§×\91צ×\99×\9d ×\9c×\9e×\90×\92ר ×§×\91צ×\99×\9d ×\96ר.",
+       "upload-foreign-cant-upload": "×\91×\90תר ×\94×\95×\95×\99ק×\99 ×\94×\96×\94 ×\9c×\90 ×\9e×\95פע×\9cת ×\94×\90פשר×\95ת ×\9c×\94×¢×\9c×\90ת ×§×\91צ×\99×\9d ×\9c×\9e×\90×\92ר ×\94ק×\91צ×\99×\9d ×\94×\96ר ×\94×\9e×\91×\95קש.",
        "upload-dialog-title": "העלאת קובץ",
        "upload-dialog-button-cancel": "ביטול",
        "upload-dialog-button-done": "בוצע",
        "querypage-disabled": "דף מיוחד זה מבוטל עקב בעיות ביצועים.",
        "apihelp": "עזרה עבור ה־API",
        "apihelp-no-such-module": "המודול \"$1\" לא נמצא.",
+       "apisandbox": "ארגז חול של API",
+       "apisandbox-api-disabled": "API אינו פעיל באתר הזה.",
+       "apisandbox-intro": "השתמשו בדף הזה כדי להתנסות עם '''API של שירות הרשת של מדיה־ויקי'''.\nר' את [//www.mediawiki.org/wiki/API:Main_page תיעוד של ה־API] למידע נוסף של שימוש ב־API (באנגלית). למשל: [//www.mediawiki.org/wiki/API#A_simple_example איך לקבל את התוכן של הדף הראשי]. בחרו בפעולה (action) לדוגמאות נוספות.\n\nשימו לב שאף שמדובר ב\"ארגז חול\", פעולות שנעשות כאן יכולות לשנות את התוכן של הוויקי.",
+       "apisandbox-submit": "ביצוע שאילתה",
+       "apisandbox-reset": "ניקוי",
+       "apisandbox-examples": "דוגמה",
+       "apisandbox-results": "תוצאה",
+       "apisandbox-request-url-label": "כתובת ה־URL של הבקשה:",
+       "apisandbox-request-time": "זמן בקשה: $1",
        "booksources": "משאבי ספרות חיצוניים",
        "booksources-search-legend": "חיפוש משאבי ספרות חיצוניים",
        "booksources-isbn": "מסת\"ב (ISBN):",
        "block-log-flags-hiddenname": "שם המשתמש הוסתר",
        "range_block_disabled": "האפשרות לחסום טווח כתובות אינה פעילה.",
        "ipb_expiry_invalid": "זמן פקיעת החסימה אינו תקין.",
-       "ipb_expiry_old": "×\96×\9e×\9f ×ª×¤×\95×\92×\94 ×\91עבר.",
+       "ipb_expiry_old": "×\96×\9e×\9f ×\94תפ×\95×\92×\94 ×\9b×\91ר עבר.",
        "ipb_expiry_temp": "חסימות הכוללות הסתרת שם משתמש חייבות להיות לזמן בלתי מוגבל.",
        "ipb_hide_invalid": "לא ניתן להעלים את החשבון הזה; {{PLURAL:$1|בוצעה ממנו יותר מעריכה אחת|בוצעו ממנו יותר מ‏‏֫־$1 עריכות}}.",
        "ipb_already_blocked": "המשתמש \"$1\" כבר נחסם.",
        "version-libraries-license": "רישיון",
        "version-libraries-description": "תיאור",
        "version-libraries-authors": "יוצרים",
-       "redirect": "×\94פנ×\99×\94 ×\9cפ×\99 ×§×\95×\91×¥, ×\9eשת×\9eש, ×\93×£, ×\92רס×\94 ×\90×\95 ×\9e×\96×\94×\94 ×\99×\95×\9e×\9dן",
+       "redirect": "×\94פנ×\99×\94 ×\9cפ×\99 ×©×\9d ×§×\95×\91×¥, ×\9eספר ×\9eשת×\9eש, ×\9eספר ×\93×£, ×\9eספר ×\92רס×\94 ×\90×\95 ×\9e×\96×\94×\94 ×\99×\95×\9eן",
        "redirect-legend": "הפניה לקובץ או לדף",
-       "redirect-summary": "×\93×£ ×\9e×\99×\95×\97×\93 ×\96×\94 ×\9eפנ×\94 ×\9cק×\95×\91×¥ (×\91×\94×\99נת×\9f ×©×\9d ×\94ק×\95×\91×¥), ×\9c×\93×£ (×\91×\94×\99נת×\9f ×\9eספר ×\92רס×\94 ×\90×\95 ×\9eספר ×\93×£), ×\90×\95 ×\9c×\93×£ ×\9eשת×\9eש (×\91×\94×\99נת×\9f ×\9eספר ×\9eשת×\9eש), ×\90×\95 ×¢×\99×\95×\9c יומן (בהינתן מזהה יומן). דוגמאות לשימוש: [[{{#Special:Redirect}}/file/Example.jpg]], [[{{#Special:Redirect}}/page/64308]], [[{{#Special:Redirect}}/revision/328429]], או [[{{#Special:Redirect}}/user/101]], או [[{{#Special:Redirect}}/logid/186]].",
+       "redirect-summary": "×\93×£ ×\9e×\99×\95×\97×\93 ×\96×\94 ×\9eפנ×\94 ×\9cק×\95×\91×¥ (×\91×\94×\99נת×\9f ×©×\9d ×\94ק×\95×\91×¥), ×\9c×\93×£ (×\91×\94×\99נת×\9f ×\9eספר ×\92רס×\94 ×\90×\95 ×\9eספר ×\93×£), ×\9c×\93×£ ×\9eשת×\9eש (×\91×\94×\99נת×\9f ×\9eספר ×\9eשת×\9eש), ×\90×\95 ×\9cרש×\95×\9eת יומן (בהינתן מזהה יומן). דוגמאות לשימוש: [[{{#Special:Redirect}}/file/Example.jpg]], [[{{#Special:Redirect}}/page/64308]], [[{{#Special:Redirect}}/revision/328429]], או [[{{#Special:Redirect}}/user/101]], או [[{{#Special:Redirect}}/logid/186]].",
        "redirect-submit": "מעבר",
        "redirect-lookup": "סוג:",
        "redirect-value": "ערך:",
index ea03c06..8dc26c8 100644 (file)
        "querypage-disabled": "प्रदर्शन कारणों से यह विशेष पृष्ठ अक्षम किया गया है।",
        "apihelp": "ए पी आई सहाएता",
        "apihelp-no-such-module": "मॉड्यूल \"$1\" नहीं मिला",
+       "apisandbox": "ए॰पी॰आइ प्रयोगस्थल",
+       "apisandbox-api-disabled": "इस स्थल पर ए०पी०आई० सक्षम नहीं हैं।",
+       "apisandbox-intro": "याद रखिए कि, हालांकि यह प्रयोगपृष्ठ है, इस पृष्ठ पर किए गए आपके काम इस विकि में बदलाव ला सकते हैं।",
+       "apisandbox-submit": "अनुरोध करें",
+       "apisandbox-reset": "स्पष्ट",
+       "apisandbox-examples": "Example",
+       "apisandbox-results": "परिणाम",
+       "apisandbox-request-url-label": "अनुरोध URL:",
+       "apisandbox-request-time": "अनुरोध समय: $1",
        "booksources": "पुस्तकों के स्रोत",
        "booksources-search-legend": "पुस्तकों के स्रोत खोजें",
        "booksources-isbn": "आइ॰एस॰बी॰एन:",
index 649e467..c8dc1b9 100644 (file)
        "suppress": "Nadzor",
        "querypage-disabled": "Ova posebna stranica onemogućena je jer bi usporila funkcioniranje projekta.",
        "apihelp": "Pomoć za API",
+       "apisandbox-submit": "Napraviti zahtjev",
+       "apisandbox-reset": "Očisti",
+       "apisandbox-examples": "Primjer",
+       "apisandbox-results": "Rezultat",
        "booksources": "Pretraživanje po ISBN-u",
        "booksources-search-legend": "Traženje izvora za knjigu",
        "booksources-search": "Traži",
index c66b8c3..dd765b3 100644 (file)
        "querypage-disabled": "Ez a speciális lap a megfelelő teljesítmény fenntartása érdekében le van tiltva.",
        "apihelp": "API segítség",
        "apihelp-no-such-module": "A(z) „$1\" modul nem található.",
+       "apisandbox": "API homokozó",
+       "apisandbox-api-disabled": "API le van tiltva ezen az oldalon.",
+       "apisandbox-intro": "Ezen az oldalon kísérletezhetsz a '''MediaWiki web service API'''-val.\nA használattal kapcsolatos további részletek az [//www.mediawiki.org/wiki/API:Main_page API-dokumentációnál] találhatók. Példa: [//www.mediawiki.org/wiki/API#A_simple_example olvasd el a főoldal tartalomjegyzékét]. További példákért válassz egy tevékenységet!\n\nFigyelj rá, hogy bár ez csak egy „homokozó”, ettől még az általad végzett műveletek módosíthatják a wikit!",
+       "apisandbox-submit": "Kérés végrehajtása",
+       "apisandbox-reset": "Törlés",
+       "apisandbox-examples": "Példa",
+       "apisandbox-results": "Eredmény",
+       "apisandbox-request-url-label": "Kérő URL:",
+       "apisandbox-request-time": "Kérés ideje: $1",
        "booksources": "Könyvforrások",
        "booksources-search-legend": "Könyvforrások keresése",
        "booksources-search": "Keresés",
index 5819bba..3c734c9 100644 (file)
        "preview": "Նախադիտում",
        "showpreview": "Նախադիտել",
        "showdiff": "Կատարված փոփոխությունները",
-       "anoneditwarning": "<strong>Ուշադրություն,</strong> Դուք չեք մտել համակարգ։ Ցանկացած խմբագրման դեպքում Ձեր IP հասցեն կդառնա բոլորին տեսանելի։ Եթե դուք <strong>[$1 մուտք գործեք]</strong> կամ <strong>[$2 ստեղծեք մասնակցային հաշիվ]</strong>, Ձեր կատարած խմբագրումները կկավեն Ձեր մասնակցային անվան հետ և Դուք կունենաք այլ առավելություններ։",
+       "anoneditwarning": "<strong>Ուշադրություն,</strong> Դուք չեք մտել համակարգ։ Ցանկացած խմբագրման դեպքում Ձեր IP հասցեն կդառնա բոլորին տեսանելի։ Եթե Դուք <strong>[$1 մուտք գործեք]</strong> կամ <strong>[$2 ստեղծեք մասնակցային հաշիվ]</strong>, Ձեր կատարած խմբագրումները կկապվեն Ձեր մասնակցային անվան հետ, ինչպես նաև կունենաք այլ առավելություններ։",
        "anonpreviewwarning": "<em>Դուք չեք մտել համակարգ։\nՀիշելով Ձեր կատարած խմբագրումը, այն կպահանվի Ձեր IP հասցեի հետ միասին այս էջի խմբագրումների պատմության մեջ։</em>",
        "missingsummary": "'''Հիշեցում.''' Դուք չեք տվել խմբագրման ամփոփում։ «Հիշել» կոճակի կրկնակի մատնահարման դեպքում փոփոխությունները կհիշվեն առանց ամփոփման։",
        "missingcommenttext": "Խնդրում ենք մեկնաբանություն ավելացնել ստորև։",
        "watchthisupload": "Հսկել այս նիշքը",
        "filewasdeleted": "Այս անվանմամբ նիշք նախկինում բեռնվել է և հետագայում ջնջվել։ Այն կրկին բեռնելուց առաջ խնդրում ենք ստուգել $1։",
        "filename-bad-prefix": "Բեռնվող նիշքի անվանումը սկսվում է '''<tt>«$1»</tt>''' արտահայտությամբ, որը ոչ-նկարագրական է և սովորաբար տրվում է թվային լուսանկարչական ապարատների կողմից։ Խնդրում ենք ընտրել ավելի նկարագրական անվանում ձեր նիշքի համար։",
-       "upload-success-subj": "Բեռնումը կատարված է",
-       "upload-failure-subj": "Ներբեռնման սխալ",
-       "upload-warning-subj": "Ներբեռնման զգուշացում",
        "upload-proto-error": "Սխալ պրոտոկոլ",
        "upload-proto-error-text": "Հեռավոր բեռնումը պահանջում է URL-հասցե, որը սկսվում է <code>http://</code> կամ <code>ftp://</code> նախածանցով։",
        "upload-file-error": "Ներքին սխալ",
        "wlheader-showupdated": "Էջերը, որոնք փոփոխվել են ձեր դրանց վերջին այցից հետո բերված են '''թավատառ'''։",
        "wlnote": "Ստորև բերված {{PLURAL:$1|է վերջին փոփոխությունը|են վերջին '''$1''' փոփոխությունները}} վերջին <strong>$2</strong> ժամվա ընթացքում։",
        "wlshowlast": "Ցուցադրել վերջին $1 ժամերը $2 օրերը",
-       "watchlistall2": "բոլոր",
        "watchlist-submit": "Ցույց տալ",
        "watchlist-options": "Հսկացանկի նախընտրություններ",
        "watching": "Հսկվում է...",
index 94b59e1..54471a6 100644 (file)
        "querypage-disabled": "Iste pagina special es disactivate pro evitar de supercargar le systema.",
        "apihelp": "Adjuta con le API",
        "apihelp-no-such-module": "Modulo \"$1\" non trovate.",
+       "apisandbox": "Cassa de sablo pro API",
+       "apisandbox-api-disabled": "Le API ha essite disactivate in iste sito.",
+       "apisandbox-intro": "Usa iste pagina pro experimentar con le '''API de servicio web de MediaWiki'''.\nConsulta [//www.mediawiki.org/wiki/API:Main_page le documentation del API] pro ulterior detalios concernente le uso del API. Per exemplo: [//www.mediawiki.org/wiki/API#A_simple_example obtener le contento de un Pagina principal]. Selige un action pro vider altere exemplos.",
+       "apisandbox-submit": "Facer requesta",
+       "apisandbox-reset": "Rader",
+       "apisandbox-examples": "Exemplo",
+       "apisandbox-results": "Resultato",
+       "apisandbox-request-url-label": "URL de requesta:",
+       "apisandbox-request-time": "Duration del requesta: $1",
        "booksources": "Fontes de libros",
        "booksources-search-legend": "Cercar fontes de libros",
        "booksources-search": "Cercar",
index 6c13a83..a4f8828 100644 (file)
        "querypage-disabled": "Daytoy nga espesial a panid ket nabaldado gapu kadagiti rason ti kasayaat ti panagpataray.",
        "apihelp": "Tulong ti API",
        "apihelp-no-such-module": "Saan a nabirukan ti modulo ti \"$1\".",
+       "apisandbox": "Pagsubokan ti API",
+       "apisandbox-api-disabled": "Ti API ket nabaldado iti daytoy a sitio.",
+       "apisandbox-intro": "Usaren daytoy a panid iti panagsubok ti '''MediaWiki a serbisio ti web ti API'''.\nKitaen ti [//www.mediawiki.org/wiki/API:Main_page the API dokumentasion] para iti ad-adu pay a salaysay ti panagusar ti API. Kas pagarigan: [//www.mediawiki.org/wiki/API#A_simple_example alaen ti linaon ti Umuna a Panid].  Agpili ti maaramid tapno makakita ti adu pay a kas pagarigan.\n\nLaglagipen nga uray daytoy ket pagipadasan, dagiti tignay nga aramidem iti daytoy a panid ket mabalin a mangbaliw iti wiki.",
+       "apisandbox-submit": "Agaramid ti kiddaw",
+       "apisandbox-reset": "Dalusan",
+       "apisandbox-examples": "Kas pagarigan",
+       "apisandbox-results": "Nagbanagan",
+       "apisandbox-request-url-label": "Agkiddaw ti URL:",
+       "apisandbox-request-time": "Oras ti kiddaw: $1",
        "booksources": "Dagiti taudan ti libro",
        "booksources-search-legend": "Agbiruk para kadagiti taudan ti libro",
        "booksources-search": "Biruken",
index 1e7c6d4..866c4b8 100644 (file)
@@ -30,6 +30,7 @@
        "tog-hideminor": "Fela minniháttar breytingar í nýlegum breytingum",
        "tog-hidepatrolled": "Fela yfirfarnar breytingar í nýlegum breytingum",
        "tog-newpageshidepatrolled": "Fela yfirfarnar breytingar í listanum yfir nýjar síður",
+       "tog-hidecategorization": "Fela flokkun á síðum",
        "tog-extendwatchlist": "Sýna allar breytingar á vaktlistanum, ekki einungis þær nýjustu",
        "tog-usenewrc": "Flokka breytingar eftir síðu í nýlegum breytingum og vaktlista",
        "tog-numberheadings": "Númera fyrirsagnir sjálfkrafa",
@@ -59,6 +60,7 @@
        "tog-watchlisthideliu": "Ekki sýna breytingar innskráðra notenda á vaktlistanum",
        "tog-watchlisthideanons": "Ekki sýna breytingar óþekktra notenda á vaktlistanum",
        "tog-watchlisthidepatrolled": "Fela yfirfarnar breytingar í vaktlistanum",
+       "tog-watchlisthidecategorization": "Fela flokkun á síðum",
        "tog-ccmeonemails": "Senda mér afrit af tölvupóstum sem ég sendi öðrum notendum",
        "tog-diffonly": "Ekki sýna síðuefni undir mismunum",
        "tog-showhiddencats": "Sýna falda flokka",
        "october-date": "$1. október",
        "november-date": "$1. nóvember",
        "december-date": "$1. desember",
+       "period-am": "FH",
+       "period-pm": "EH",
        "pagecategories": "{{PLURAL:$1|Flokkur|Flokkar}}",
        "category_header": "Síður í flokknum „$1“",
        "subcategories": "Undirflokkar",
        "nospecialpagetext": "Þú hefur beðið um kerfissíðu sem ekki er til. Listi yfir gildar kerfissíður er að finna á [[Special:SpecialPages|kerfissíður]].",
        "error": "Villa",
        "databaseerror": "Gagnagrunnsvilla",
+       "databaseerror-query": "Fyrirspurn: $1",
        "databaseerror-error": "Villa: $1",
        "laggedslavemode": "Viðvörun: Síðan inniheldur ekki nýjustu uppfærslur.",
        "readonly": "Gagnagrunnur læstur",
        "filerenameerror": "Gat ekki endurnefnt skrána „$1“ í „$2“.",
        "filedeleteerror": "Gat ekki eytt skránni „$1“.",
        "directorycreateerror": "Gat ekki búið til efnisskrána \"$1\".",
+       "directoryreadonlyerror": "Mappan \"$1\" er skrifvarin.",
+       "directorynotreadableerror": "Mappan \"$1\" er ekki lesanleg.",
        "filenotfound": "Gat ekki fundið skrána „$1“.",
        "unexpected": "Óvænt gildi: „$1“=„$2“.",
        "formerror": "Villa: gat ekki sent eyðublað",
        "passwordreset-emailerror-capture": "Tölvupóstur til að endursetja lykilorðið var búinn til, sem er sýndur hér fyrir neðan, en ekki tókst að senda hana til {{GENDER:$2|notandans}}: $1",
        "changeemail": "Breyta eða fjarlægja netfang",
        "changeemail-header": "Fylltu út þetta eyðublað til að breyta netfanginu þínu. Ef þú vilt fjarlægja tengingu allra netfanga frá aðganginum þínum skildu þá netfangs reitinn eftir tóman.",
+       "changeemail-passwordrequired": "Þú verður að setja inn lykilorðið þitt til að staðfesta þessa breytingu.",
        "changeemail-no-info": "Þú verður að vera skráð(ur) inn til að hafa aðgang að þessari síðu.",
        "changeemail-oldemail": "Núverandi netfang:",
        "changeemail-newemail": "Nýtt netfang:",
        "edit-conflict": "Breytingaárekstur.",
        "edit-no-change": "Breyting þín var hunsuð, því engin breyting var á textanum.",
        "postedit-confirmation-created": "Síðan hefur verið búin til.",
+       "postedit-confirmation-restored": "Síðan hefur verið endurheimt.",
        "postedit-confirmation-saved": "Breytingin þín hefur verið vistuð.",
        "edit-already-exists": "Gat ekki skapað nýja síðu.\nHún er nú þegar til.",
        "defaultmessagetext": "Sjálfgefinn texti skilaboða",
        "notextmatches": "Engar samsvaranir á texta í síðum",
        "prevn": "síðustu {{PLURAL:$1|$1}}",
        "nextn": "næstu {{PLURAL:$1|$1}}",
+       "prev-page": "fyrri síða",
+       "next-page": "næsta síða",
        "prevn-title": "Fyrri $1 {{PLURAL:$1|niðurstaða|niðurstöður}}",
        "nextn-title": "{{PLURAL:$1|Næsta|Næstu}} $1 {{PLURAL:$1|niðurstaða|niðurstöður}}",
        "shown-title": "Sýna $1 {{PLURAL:$1|niðurstöðu|niðurstöður}} á hverri síðu",
        "search-result-category-size": "{{PLURAL:$1|1 meðlimur|$1 meðlimir}} ({{PLURAL:$2|1 undirflokks|$2 undirflokka}}, {{PLURAL:$3|1 skrá|$3 skrár}})",
        "search-redirect": "(tilvísun $1)",
        "search-section": "(hluti $1)",
+       "search-category": "(flokkur $1)",
        "search-file-match": "(passar við innihald skráa)",
        "search-suggest": "Varstu að leita að: $1",
        "search-interwiki-caption": "Systurverkefni",
        "columns": "Dálkar",
        "searchresultshead": "Leit",
        "stub-threshold": "Þröskuldur fyrir stílsnið stubbatengla ($1):",
+       "stub-threshold-sample-link": "dæmi",
        "stub-threshold-disabled": "Óvirkt",
        "recentchangesdays": "Fjöldi daga sem nýlegar breytingar ná yfir:",
        "recentchangesdays-max": "(hámark $1 {{PLURAL:$1|dag|daga}})",
        "right-override-export-depth": "Flytja út síður með greinum þar sem allt að 5 greinar tengja þær saman.",
        "right-sendemail": "Senda tölvupóst til annara notenda",
        "right-passwordreset": "Skoða tölvupósta um endurstillingu lykilorðs",
+       "grant-group-email": "Senda tölvupóst",
+       "grant-createaccount": "Stofna aðganga",
+       "grant-createeditmovepage": "Búa til, breyta og færa síður",
+       "grant-delete": "Eyða síðum, yfirferðum og annálsfærslum",
        "newuserlogpage": "Skrá yfir nýja notendur",
        "newuserlogpagetext": "Þetta er skrá yfir nýskráða notendur.",
        "rightslog": "Réttindaskrá notenda",
        "rcshowhidemine": "$1 mínar breytingar",
        "rcshowhidemine-show": "Sýna",
        "rcshowhidemine-hide": "Fela",
+       "rcshowhidecategorization-show": "Birta",
+       "rcshowhidecategorization-hide": "Fela",
        "rclinks": "Sýna síðustu $1 breytingar síðustu $2 daga<br />$3",
        "diff": "breyting",
        "hist": "breytingaskrá",
        "upload-too-many-redirects": "Vefslóðin inniheldur of margar tilvísanir.",
        "upload-http-error": "HTTP villa kom upp: $1",
        "upload-copy-upload-invalid-domain": "Lokað er fyrir afritun skráa frá öðrum vefþjón á þessu vefsvæði.",
+       "upload-dialog-title": "Hlaða inn skrá",
+       "upload-dialog-button-cancel": "Hætta við",
+       "upload-dialog-button-done": "Lokið",
+       "upload-dialog-button-save": "Vista",
+       "upload-dialog-button-upload": "Hlaða inn",
+       "upload-form-label-select-file": "Velja skrá",
+       "upload-form-label-infoform-title": "Nánar",
+       "upload-form-label-infoform-name": "Heiti",
+       "upload-form-label-infoform-name-tooltip": "Einstakur og lýsandi titill, sem mun verða skráarnafn. Þú mátt nota einfalt mál með bilum. Ekki hafa með neina skráarendingu.",
+       "upload-form-label-infoform-description": "Lýsing",
+       "upload-form-label-infoform-description-tooltip": "Lýstu stuttlega öllu því sem er markvert um verkið.\nFyrir ljósmyndir, nefndu aðalatriði myndarinnar, tilefni eða staðsetningu.",
+       "upload-form-label-usage-title": "Notkun",
+       "upload-form-label-usage-filename": "Skráarheiti",
+       "foreign-structured-upload-form-label-own-work": "Það er mitt eigið verk",
+       "foreign-structured-upload-form-label-infoform-categories": "Flokkar",
+       "foreign-structured-upload-form-label-infoform-date": "Dagsetning",
+       "foreign-structured-upload-form-3-label-yes": "Já",
+       "foreign-structured-upload-form-3-label-no": "Nei",
        "backend-fail-stream": "Gat ekki streymt skránni „$1“.",
        "backend-fail-backup": "Öryggisafritun skrárinnar $1 mistókst.",
        "backend-fail-notexists": "Skráin $1 er ekki til.",
        "randomincategory-nopages": "Það eru engar síður í flokkinum [[:Category:$1|$1]].",
        "randomincategory-category": "Flokkur:",
        "randomincategory-legend": "Handhófsvalin síða í flokki",
+       "randomincategory-submit": "Fara",
        "randomredirect": "Handahófsvalin tilvísun",
        "randomredirect-nopages": "Það eru engar tilvísanir í nafnrýminu „$1“.",
        "statistics": "Tölfræði",
        "mostrevisions": "Síður eftir fjölda breytinga",
        "prefixindex": "Allar síður með forskeyti",
        "prefixindex-namespace": "Allar síður með forskeyti ($1 nafnrými)",
+       "prefixindex-submit": "Birta",
        "prefixindex-strip": "Fjarlægja forskeyti í listanum",
        "shortpages": "Stuttar síður",
        "longpages": "Langar síður",
        "protectedpages-performer": "Vernduð af",
        "protectedpages-params": "Verndunar stikar",
        "protectedpages-reason": "Ástæða",
+       "protectedpages-submit": "Birta síður",
        "protectedpages-unknown-timestamp": "Óþekktur",
        "protectedpages-unknown-performer": "Óþekktur notandi",
        "protectedtitles": "Verndaðir titlar",
        "protectedtitlesempty": "Engir titlar eru verndaðir með þessum stikum.",
+       "protectedtitles-submit": "Sýna titla",
        "listusers": "Notendalisti",
        "listusers-editsonly": "Sýna eingöngu notendur með breytingar",
        "listusers-creationsort": "Raða eftir stofndegi",
        "usereditcount": "$1 {{PLURAL:$1|breyting|breytingar}}",
        "usercreated": "{{GENDER:$3|Stofnað|}} $1 $2",
        "newpages": "Nýjustu greinar",
+       "newpages-submit": "Birta",
        "newpages-username": "Notandanafn:",
        "ancientpages": "Síst uppfærðar síður",
        "move": "Færa",
        "specialloguserlabel": "Gerandi:",
        "speciallogtitlelabel": "Beinist að (titill eða {{ns:user}}:notendanafn fyrir notanda):",
        "log": "Aðgerðaskrár",
+       "logeventslist-submit": "Birta",
        "all-logs-page": "Allar aðgerðir",
        "alllogstext": "Safn allra aðgerðaskráa {{SITENAME}}.\nÞú getur takmarkað listann með því að velja tegund aðgerðaskráar, notandanafn, eða síðu.",
        "logempty": "Engin slík aðgerð fannst.",
        "log-title-wildcard": "Leita að titlum sem byrja á þessum texta",
        "showhideselectedlogentries": "Sýna/fela valdar aðgerða færslur",
+       "checkbox-select": "Velja: $1",
+       "checkbox-all": "Allt",
+       "checkbox-none": "Ekkert",
+       "checkbox-invert": "Snúa við",
        "allpages": "Allar síður",
        "nextpage": "Næsta síða ($1)",
        "prevpage": "Fyrri síða ($1)",
        "cachedspecial-viewing-cached-ts": "Þetta er útgáfa þessarar síðu úr skyndiminni og sem endurspeglar ekki endilega núverandi ástand.",
        "cachedspecial-refresh-now": "Skoða síðustu",
        "categories": "Flokkar",
+       "categories-submit": "Birta",
        "categoriespagetext": "Eftirfarandi {{PLURAL:$1|flokkur inniheldur|flokkar innihalda}} síður eða skrár.\n[[Special:UnusedCategories|Ónotaðir flokkar]] birtast ekki hér.\nSjá einnig [[Special:WantedCategories|eftirsótta flokka]].",
        "categoriesfrom": "Sýna flokka frá:",
        "special-categories-sort-count": "raða eftir fjölda",
        "activeusers-hidebots": "Fela vélmenni",
        "activeusers-hidesysops": "Fela möppudýr",
        "activeusers-noresult": "Enginn notandi fannst.",
+       "activeusers-submit": "Sýna virka notendur",
        "listgrouprights": "Notandahópréttindi",
        "listgrouprights-summary": "Hér er listi yfir notendahópa á þessum wiki, með þeirra réttindum. \nÞað gæti verið til síða með [[{{MediaWiki:Listgrouprights-helppage}}|frekari upplýsingar]] um einstök réttindi.",
        "listgrouprights-key": "Skýringar:\n* <span class=\"listgrouprights-granted\">Veitt réttindi</span>\n* <span class=\"listgrouprights-revoked\">Afturkölluð réttindi</span>",
        "listgrouprights-removegroup-self": "Fjarlægja sjálfan sig úr {{PLURAL:$2|hópinum|hópunum}}: $1",
        "listgrouprights-addgroup-self-all": "Bæta sjálfum sér í alla hópa",
        "listgrouprights-removegroup-self-all": "Fjarlægja sjálfan sig úr öllum hópum",
+       "listgrouprights-namespaceprotection-header": "Takmarkanir nafnrýmis",
+       "listgrouprights-namespaceprotection-namespace": "Nafnrými",
+       "listgrants-rights": "Réttindi",
+       "trackingcategories-nodesc": "Enginn lýsing tiltæk.",
+       "trackingcategories-disabled": "Flokkurinn er óvirkur",
        "mailnologin": "Ekkert netfang til að senda á",
        "mailnologintext": "Þú verður að vera [[Special:UserLogin|innskráð(ur)]] auk þess að hafa gilt netfang í [[Special:Preferences|stillingunum]] þínum til að senda tölvupóst til annara notenda.",
        "emailuser": "Senda þessum notanda tölvupóst",
        "wlheader-showupdated": "Síðum sem hefur verið breytt síðan þú skoðaðir þær síðast eru '''feitletraðar'''.",
        "wlnote": "Hér fyrir neðan {{PLURAL:$1|er síðasta <strong>$1</strong> breyting|eru síðustu <strong>$1</strong> breytingar}} {{PLURAL:$2|síðasta <strong>$2</strong> klukkutímann|síðustu <strong>$2</strong> klukkutímana}}, frá $3, $4.",
        "wlshowlast": "Sýna síðustu $1 klukkutíma, $2 daga",
+       "watchlist-hide": "Fela",
+       "watchlist-submit": "Birta",
+       "wlshowhideliu": "skráðir notendur",
+       "wlshowhideanons": "óskráðir notendur",
+       "wlshowhidepatr": "vaktaðar breytingar",
+       "wlshowhidemine": "mínar breytingar",
        "watchlist-options": "Vaktlistastillingar",
        "watching": "Vakta...",
        "unwatching": "Afvakta...",
        "rollback-success": "Tók til baka breytingar eftir $1; núverandi $2.",
        "sessionfailure-title": "Mistök í setu",
        "sessionfailure": "Líklega er vandamál með innskráningar setuna þína;\nhætt hefur verið við þessa aðgerð sem vörn gegn mögulegu samskiptaráni setunar.\nFarðu aftur á fyrri síðu, endurhladdu hana og reyndu aftur.",
+       "changecontentmodel-title-label": "Titill síðu",
+       "changecontentmodel-reason-label": "Ástæða:",
        "protectlogpage": "Verndunarskrá",
        "protectlogtext": "Fyrir neðan er listi yfir síðuverndanir og -afverndanir.\nSjáðu [[Special:ProtectedPages|Verndunarskrá]] fyrir núverandi lista yfir verndaðar síður.",
        "protectedarticle": "verndaði „[[$1]]“",
        "whatlinkshere-hidelinks": "$1 tengla",
        "whatlinkshere-hideimages": "$1 skrátenglar",
        "whatlinkshere-filters": "Síur",
+       "whatlinkshere-submit": "Fara",
        "autoblockid": "Sjálfvirkt bann $1",
        "block": "Banna notanda",
        "unblock": "Afbanna notanda",
index 61ea7ee..38fb292 100644 (file)
        "querypage-disabled": "Questa pagina speciale è disattivata per motivi di prestazioni.",
        "apihelp": "Aiuto API",
        "apihelp-no-such-module": "Modulo \"$1\" non trovato.",
+       "apisandbox": "Pagina di prova API",
+       "apisandbox-api-disabled": "Le funzionalità API sono disabilitate su questo sito.",
+       "apisandbox-intro": "Utilizza questa pagina per sperimentare con le '''API web service MediaWiki'''.\nPer ulteriori dettagli di utilizzo delle API, fai riferimento alla [//www.mediawiki.org/wiki/API:Main_page documentazione API]. Esempio: [//www.mediawiki.org/wiki/API#A_simple_example ottenere il contenuto della pagina principale]. Seleziona un'azione per vedere altri esempi.\n\nNota che, anche se questa è una pagina per le prove, le azioni che esegui qui potrebbero modificare il wiki.",
+       "apisandbox-submit": "Inoltra richiesta",
+       "apisandbox-reset": "Pulisci",
+       "apisandbox-examples": "Esempio",
+       "apisandbox-results": "Risultato",
+       "apisandbox-request-url-label": "URL di richiesta:",
+       "apisandbox-request-time": "Tempo richiesto: $1",
        "booksources": "Fonti librarie",
        "booksources-search-legend": "Ricerca di fonti librarie",
        "booksources-isbn": "Codice ISBN:",
index 76de1be..63ca031 100644 (file)
        "querypage-disabled": "パフォーマンスに悪影響を与えるおそれがあるため、この特別ページは無効になっています。",
        "apihelp": "API のヘルプ",
        "apihelp-no-such-module": "モジュール「$1」が見つかりません。",
+       "apisandbox": "APIサンドボックス",
+       "apisandbox-api-disabled": "このウェブサイトでは、API は無効になっています。",
+       "apisandbox-intro": "このページでは、'''MediaWiki ウェブサービス API''' を試用できます。\nAPI の使用方法の詳細は [//www.mediawiki.org/wiki/API:Main_page API のドキュメント]をご覧ください。例: [//www.mediawiki.org/wiki/API#A_simple_example メインページの内容を取得]。アクションを選択すると他の例を閲覧できます。\n\nこれはサンドボックスですが、このページで実行した操作によってウィキが変更される場合があることにご注意ください。",
+       "apisandbox-submit": "リクエストする",
+       "apisandbox-reset": "消去",
+       "apisandbox-examples": "例",
+       "apisandbox-results": "結果",
+       "apisandbox-request-url-label": "リクエスト URL:",
+       "apisandbox-request-time": "リクエスト時間: $1",
        "booksources": "書籍情報源",
        "booksources-search-legend": "書籍情報源を検索",
        "booksources-isbn": "ISBN:",
index 1dee9e2..344ee22 100644 (file)
        "querypage-disabled": "ეს სპეცგვერდი გამორთულია წარმადობის გასაზრდელად.",
        "apihelp": "API დახმარება",
        "apihelp-no-such-module": "მოდული „$1“ ვერ მოიძებნა.",
+       "apisandbox": "API-ს სავარჯიშო",
+       "apisandbox-api-disabled": "API ამ საიტზე გამორთულია.",
+       "apisandbox-submit": "მოთხოვნის გაკეთება",
+       "apisandbox-reset": "წაშლა",
+       "apisandbox-examples": "მაგალითი",
+       "apisandbox-results": "შედეგი",
+       "apisandbox-request-url-label": "მოთხოვნის URL:",
+       "apisandbox-request-time": "თხოვნის დრო: $1",
        "booksources": "წიგნის წყაროები",
        "booksources-search-legend": "წიგნის წყაროს ძებნა",
        "booksources-isbn": "ISBN:",
index 15d9801..95e3d0e 100644 (file)
@@ -56,7 +56,8 @@
                        "Macofe",
                        "Yearning",
                        "고솜",
-                       "Sternradio"
+                       "Sternradio",
+                       "Joolee0104"
                ]
        },
        "tog-underline": "링크에 밑줄:",
        "october-date": "10월 $1일",
        "november-date": "11월 $1일",
        "december-date": "12월 $1일",
+       "period-am": "오전",
+       "period-pm": "오후",
        "pagecategories": "{{PLURAL:$1|분류}}",
        "category_header": "\"$1\" 분류에 속하는 문서",
        "subcategories": "하위 분류",
        "title-invalid-interwiki": "요청한 페이지 제목에 제목에는 사용될 수 없는 위키간 링크가 있습니다.",
        "title-invalid-talk-namespace": "요청한 페이지 제목이 존재하지 않는 토론 문서를 가리킵니다.",
        "title-invalid-characters": "요청된 문서 제목이 잘못된 문자를 포함하고 있습니다: \"$1\".",
+       "title-invalid-too-long": "페이지 제목이 너무 깁니다. 페이지 제목 길이는 최대 $1 까지 설정할 수 있습니다.",
+       "title-invalid-leading-colon": "페이지 제목에 잘못된 문자가 포함되어 있습니다.",
        "perfcached": "다음 자료는 캐시된 것이며 최신이 아닐 수 있습니다. 캐시에 최대 {{PLURAL:$1|결과 한 개|결과 $1개}}가 있습니다.",
        "perfcachedts": "다음 자료는 캐시된 것으로, $1에 마지막으로 업데이트되었습니다. 캐시에 최대 {{PLURAL:$4|결과 한 개|결과 $4개}}가 있습니다.",
        "querypage-no-updates": "이 문서의 갱신이 현재 중지되어 있습니다.\n자료가 잠시 갱신되지 않을 것입니다.",
        "querypage-disabled": "이 특수 문서는 성능상의 이유로 비활성화되었습니다.",
        "apihelp": "API 도움말",
        "apihelp-no-such-module": "\"$1\" 모듈을 찾을 수 없습니다.",
+       "apisandbox": "API 실험실",
+       "apisandbox-api-disabled": "이 사이트에서는 API가 꺼져 있습니다.",
+       "apisandbox-intro": "'''미디어위키 웹 서비스 API'''를 시험해보려면 이 페이지를 이용해보세요. API 용법에 대해서는 [//www.mediawiki.org/wiki/API:Main_page API 문서]을 참고하십시오. 예: [//www.mediawiki.org/wiki/API#A_simple_example 대문의 내용 요청하기]. 더 많은 예를 보려면 액션을 선택하세요.\n\n여기가 연습장이라도 이 페이지에서 실행하는 동작때문에 위키를 변경할 수도 있다는 점에 유의하십시오.",
+       "apisandbox-submit": "요청하기",
+       "apisandbox-reset": "지우기",
+       "apisandbox-examples": "예시",
+       "apisandbox-results": "결과",
+       "apisandbox-request-url-label": "요청 URL:",
+       "apisandbox-request-time": "요청 처리 시간: $1",
        "booksources": "책 찾기",
        "booksources-search-legend": "책 원본 검색",
        "booksources-isbn": "ISBN:",
index c1d1cff..06f05d3 100644 (file)
        "querypage-disabled": "Heh di {{int:specialpage}} es ußjeschalldt, domet dä ẞööver jät winnijer ze brassele hät.",
        "apihelp": "Hölp för de <i lang=\"en\" xml:lang=\"en\" title=\"Application Programmers Interface\">API</i>",
        "apihelp-no-such-module": "Et Moduhl „$1“ wood nit jefonge.",
+       "apisandbox": "De <i lang=\"en\">API</i> ußprobeere",
+       "apisandbox-api-disabled": "Dat <i lang=\"en\">API</i> es en heh dämm Wiki afjeschalldt.",
+       "apisandbox-intro": "Op heh dä Sigg kanns De met dä '''MediaWiki web service <i lang=\"en\">API</i>''' eröm schpelle.\nBeloor Der de Einzelheite, wi di jebruch weed, op dä iere [//www.mediawiki.org/wiki/API:Main_page Sigg met de Verklieronge].\nE Beiscpell: [//www.mediawiki.org/wiki/API#A_simple_example De Houpsigg holle].\nSöhk ene {{int:Apisb-label-action}} uß, öm mieh Beishpell aanjezeisch ze krijje.\nOch wann dat heh nor zom Ußprobeere es, kann dat, wat De heh mähß, et Wiki verändere.",
+       "apisandbox-submit": "Lohß jonn!",
+       "apisandbox-reset": "Läddesch maache",
+       "apisandbox-examples": "Bäijshpell",
+       "apisandbox-results": "Erus jekumme es",
+       "apisandbox-request-url-label": "Dä <i lang=\"en\">URL</i> vun dä Aanfrooch:",
+       "apisandbox-request-time": "De Zigg vum Afroof: $1",
        "booksources": "Böcher",
        "booksources-search-legend": "Söök noh Bezochsquelle för Bööcher",
        "booksources-isbn": "ISBN:",
index b274dfe..aabcdc1 100644 (file)
        "shown-title": "Monstrare $1 {{PLURAL:$1|eventum|eventus}} per paginam",
        "viewprevnext": "Videre ($1 {{int:pipe-separator}} $2) ($3).",
        "searchmenu-exists": "'''Iam est pagina \"[[:$1]]\"'''",
-       "searchmenu-new": "<strong>Si vis, paginam \"[[:$1]]\" crea!<strong> {{PLURAL:$2|0=|Conferatur etiam pagina sequens, ubi quaesitum quodam modo continetur.|Conferantur etiam paginae $2 sequentes, in quibus quaesitum quodam modo continetur.}}",
+       "searchmenu-new": "<strong>Si vis, paginam \"[[:$1]]\" crea!</strong> {{PLURAL:$2|0=|Conferatur etiam pagina sequens, ubi quaesitum quodam modo continetur.|Conferantur etiam paginae $2 sequentes, in quibus quaesitum quodam modo continetur.}}",
        "searchprofile-articles": "Paginae contentorum",
        "searchprofile-images": "Multimedia",
        "searchprofile-everything": "Omnia",
index e02b40a..dd6bf36 100644 (file)
        "querypage-disabled": "Dës Spezialsäit ass aus Performance-Grënn ausgeschalt.",
        "apihelp": "API-Hëllef",
        "apihelp-no-such-module": "Modul \"$1\" net fonnt.",
+       "apisandbox": "API-Sandkëscht",
+       "apisandbox-api-disabled": "API ass op dësem Site ausgeschalt.",
+       "apisandbox-submit": "Ufro maachen",
+       "apisandbox-reset": "Eidel maachen",
+       "apisandbox-examples": "Beispill",
+       "apisandbox-results": "Resultat",
+       "apisandbox-request-url-label": "URL fir Ufroen:",
+       "apisandbox-request-time": "Zäitpunkt vun der Ufro: $1",
        "booksources": "Bicherreferenzen",
        "booksources-search-legend": "No Bicherreferenze sichen",
        "booksources-search": "Sichen",
index 1cd0680..71ce37f 100644 (file)
        "querypage-disabled": "Šiame specialiajame puslapyje yra išjungta dėl neefektyvumo.",
        "apihelp": "API pagalba",
        "apihelp-no-such-module": "Nerasta modulio $1.",
+       "apisandbox": "API smėlio dėžės",
+       "apisandbox-api-disabled": "API yra išjungtas šioje svetainėje.",
+       "apisandbox-intro": "Naudokite šį puslapį norėdami eksperimentuoti su '''MediaWiki API \"„.\n\tIeškokite [//www.mediawiki.org/wiki/API:Main_page API dokumentacijoje] Išsamesnės informacijos apie API naudojimo.",
+       "apisandbox-submit": "Pateikti prašymą",
+       "apisandbox-reset": "Išvalyti",
+       "apisandbox-examples": "Pavyzdys",
+       "apisandbox-results": "Rezultatai",
+       "apisandbox-request-url-label": "Prašyti URL:",
+       "apisandbox-request-time": "Užklausos laikas: $1",
        "booksources": "Knygų šaltiniai",
        "booksources-search-legend": "Knygų šaltinių paieška",
        "booksources-search": "Ieškoti",
index 789b92f..2258c67 100644 (file)
        "querypage-disabled": "Оваа службена страница е оневозможена за да не попречува на делотворноста.",
        "apihelp": "Помош со извршникот",
        "apihelp-no-such-module": "Модулот „$1“ не е пронајден.",
+       "apisandbox": "Извршнички песочник",
+       "apisandbox-api-disabled": "Извршникот е оневозможен на ова мрежно место.",
+       "apisandbox-intro": "Страницава служи за вршење проби со '''Извршник на МедијаВики'''.\n\nПовеќе за употребата на овој извршник ќе најдете во [//www.mediawiki.org/wiki/API:Main_page неговата документација].  Пример: [//www.mediawiki.org/wiki/API#A_simple_example преземање на содржината на главната страница].  Одберете дејство за да видите повеќе примери.\n\nИмајте предвид дека она шо го правите на страницава може да се одрази врз викито, иако ова е песочник.",
+       "apisandbox-submit": "Постави барање",
+       "apisandbox-reset": "Исчисти",
+       "apisandbox-examples": "Пример",
+       "apisandbox-results": "Извод",
+       "apisandbox-request-url-label": "URL на барањето:",
+       "apisandbox-request-time": "Време за барањето: $1",
        "booksources": "Печатени извори",
        "booksources-search-legend": "Пребарување на извори за книга",
        "booksources-isbn": "ISBN:",
index 55c3246..0ab6e2a 100644 (file)
        "querypage-disabled": "പ്രവർത്തനമികവിനെ ബാധിക്കുന്ന കാരണങ്ങളാൽ ഈ പ്രത്യേക താൾ പ്രവർത്തന രഹിതമാക്കിയിരിക്കുന്നു.",
        "apihelp": "എ.പി.ഐ. സഹായം",
        "apihelp-no-such-module": "ഘടകം \"$1\" കണ്ടെത്താനായില്ല.",
+       "apisandbox": "എ.പി.ഐ. എഴുത്തുകളരി",
+       "apisandbox-api-disabled": "ഈ സൈറ്റിൽ എ.പി.ഐ. പ്രവർത്തനരഹിതമാക്കിയിരിക്കുന്നു.",
+       "apisandbox-intro": "'''മീഡിയവിക്കി വെബ്‌ സെർവീസ് എ.പി.ഐ.'''യിൽ പരീക്ഷണങ്ങൾ നടത്താൻ ഈ താൾ ഉപയോഗിക്കുക.\nഎ.പി.ഐ.യുടെ ഉപയോഗത്തെക്കുറിച്ചുള്ള കൂടുതൽ വിവരങ്ങൾക്കായി [//www.mediawiki.org/wiki/API:Main_page the എ.പി.ഐ. സഹായം] പരിശോധിക്കുക. ഉദാഹരണം: [//www.mediawiki.org/wiki/API#A_simple_example പ്രധാന താളിന്റെ ഉള്ളടക്കം എടുക്കുക]. കൂടുതൽ ഉദാഹരണങ്ങൾക്കായി പ്രവൃത്തി തിരഞ്ഞെടുക്കുക.\n\nഇതൊരു പരീക്ഷണകളരിയാണെങ്കിലും ഇവിടെ ചെയ്യുന്നവ വിക്കിയിൽ മാറ്റങ്ങൾ വരുത്തിയേക്കാമെന്ന് ഓർക്കുക.",
+       "apisandbox-submit": "അഭ്യർത്ഥിക്കുക",
+       "apisandbox-reset": "ശൂന്യമാക്കുക",
+       "apisandbox-examples": "ഉദാഹരണം",
+       "apisandbox-results": "ഫലം",
+       "apisandbox-request-url-label": "അഭ്യർത്ഥനാ യൂ.ആർ.എൽ.:",
+       "apisandbox-request-time": "നടപ്പിലാക്കാൻ എടുത്ത സമയം: $1",
        "booksources": "പുസ്തക സ്രോതസ്സുകൾ",
        "booksources-search-legend": "പുസ്തകസ്രോതസ്സുകൾക്കായി തിരയുക",
        "booksources-isbn": "ഐ.എസ്.ബി.എൻ.:",
index 0a57052..cad0551 100644 (file)
        "querypage-disabled": "Laman khas ini dilumpuhkan atas sebab-sebab prestasi.",
        "apihelp": "Bantuan API",
        "apihelp-no-such-module": "Modul \"$1\" tidak dijumpai.",
+       "apisandbox": "Kotak pasir API",
+       "apisandbox-api-disabled": "API dimatikan di tapak web ini.",
+       "apisandbox-intro": "Gunakan laman ini untuk bereksperimen dengan '''API perkhidmatan sesawang MediaWiki'''.\nRujuk [//www.mediawiki.org/wiki/API:Main_page dokumentasi API] untuk keterangan lanjut tentang penggunaan API.\nContoh: [//www.mediawiki.org/wiki/API#A_simple_example dapatkan kandungan Laman Utama].  Pilih satu tindakan untuk melihat banyak lagi contoh.",
+       "apisandbox-submit": "Buat permintaan",
+       "apisandbox-reset": "Padamkan",
+       "apisandbox-examples": "Contoh",
+       "apisandbox-results": "Hasil",
+       "apisandbox-request-url-label": "URL permohonan:",
+       "apisandbox-request-time": "Waktu pemohonan: $1",
        "booksources": "Sumber buku",
        "booksources-search-legend": "Cari sumber buku",
        "booksources-search": "Cari",
index ba28a76..abf56eb 100644 (file)
        "helppage-top-gethelp": "အကူအညီ",
        "mainpage": "ဗဟိုစာမျက်နှာ",
        "mainpage-description": "ဗ​ဟို​စာ​မျက်​နှာ​",
-       "policy-url": "Project:မူ​ဝါ​ဒ",
+       "policy-url": "Project:မူဝါဒ",
        "portal": "ပေါင်းကူးနေရာ",
        "portal-url": "Project:ပေါင်းကူးနေရာ",
        "privacy": "ကိုယ်ပိုင်ရေးရာ မူဝါဒ",
        "retypenew": "စကားဝှက် အသစ်ကို ထပ်ရိုက်ပါ -",
        "resetpass_submit": "စကားဝှက်ကို သတ်မှတ်ပြီးနောက် Log in ဝင်ရန်",
        "changepassword-success": "သင့်စကားဝှက်ကို အောင်မြင်စွာ ပြောင်းလဲပြီးပါပြီ။",
-       "botpasswords-label-appid": "ဘော့အမည်-",
-       "botpasswords-label-create": "ဖန်တီး",
-       "botpasswords-label-cancel": "မလုပ်တော့ပါ",
-       "botpasswords-label-delete": "ဖျက်",
-       "botpasswords-label-resetpassword": "စကားဝှက်ကို ပြန်ချိန်ရန်",
        "resetpass_forbidden": "စကားဝှက် ပြောင်းမရနိုင်ပါ",
        "resetpass-no-info": "ဤစာမျက်နှာကို တိုက်ရိုက်အသုံးပြုနိုင်ရန်အတွက် Log in ဝင်ထားရပါမည်။",
        "resetpass-submit-loggedin": "စကားဝှက်ပြောင်းရန်",
        "loginreqpagetext": "အခြားစာမျက်နှာများကို ကြည့်ရန် $1ရမည်။",
        "accmailtitle": "စကားဝှက်ကို ပို့ပြီးပြီ",
        "newarticle": "(အသစ်)",
-       "newarticletext": "သင်သည် မရှိသေးသော စာမျက်နှာလင့် ကို ရောက်လာခြင်းဖြစ်သည်။\nစာမျက်နှာအသစ်စတင်ရန် အောက်မှ သေတ္တာထဲတွင် စတင်ရိုက်ထည့်ပါ (နောက်ထပ် သတင်းအချက်အလက်များအတွက်[$1 အကူအညီ စာမျက်နှာ]ကို ကြည့်ပါ)။\nမတော်တဆရောက်လာခြင်း ဖြစ်ပါက ဘရောက်ဆာ၏ နောက်ပြန်ပြန်သွားသော'''back''' ခလုတ်ကို နှိပ်ပါ။",
+       "newarticletext": "သင်သည် မရှိသေးသော စာမျက်နှာလင့် ကို ရောက်လာခြင်းဖြစ်သည်။\nစာမျက်နှာအသစ်စတင်ရန် အောက်မှ သေတ္တာထဲတွင် စတင်ရိုက်ထည့်ပါ (နောက်ထပ် သတင်းအချက်အလက်များအတွက်[$1 အကူအညီ စာမျက်နှာ]ကို ကြည့်ပါ)။\nမတော်တဆရောက်လာခြင်း ဖြစ်ပါက ဘရောက်ဆာ၏ နောက်ပြန်ပြန်သွားသော <strong>back</strong> ခလုတ်ကို နှိပ်ပါ။",
        "noarticletext": "ဤစာမျက်နှာတွင် ယခုလက်ရှိတွင် မည်သည့်စာသားမှ မရှိပါ။\nသင်သည် အခြားစာမျက်နှာများတွင် [[Special:Search/{{PAGENAME}}|ဤစာမျက်နှာ၏ ခေါင်းစဉ်ကို ရှာနိုင်သည်]]၊ <span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} ဆက်စပ်ရာ Logs များကို ရှာနိုင်သည်]၊ သို့မဟုတ် [{{fullurl:{{FULLPAGENAME}}|action=edit}} ဤစာမျက်နှာကို တည်းဖြတ်နိုင်သည်]</span>။",
        "noarticletext-nopermission": "ဤစာမျက်နှာတွင် ယခုလက်ရှိတွင် မည်သည့်စာသားမှ မရှိပါ။\nသင်သည် အခြားစာမျက်နှာများတွင် [[Special:Search/{{PAGENAME}}|ဤစာမျက်နှာ၏ ခေါင်းစဉ်ကို ရှာနိုင်သည်]]၊ သို့မဟုတ် <span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} ဆက်စပ်ရာ Logs များကို ရှာနိုင်သည်]</span>။ သို့သော် ဤစာမျက်နှာကို ဖန်တီးရန် သင့်တွင် အခွင့်အရေး မရှိပါ။",
        "note": "'''မှတ်ချက် -'''",
        "ipb_already_blocked": "\"$1\" ကို အစကတည်းက ပိတ်ထားသည်",
        "move-page": "$1 ကို ရွှေ့ရန်",
        "move-page-legend": "စာ​မျက်​နှာ​ကို ရွှေ့ပြောင်းရန်",
-       "movepagetext": "á\80¡á\80±á\80¬á\80\80á\80ºá\80\95á\80«á\80\95á\80¯á\80¶á\80\85á\80¶á\80\80á\80­á\80¯ á\80¡á\80\9eá\80¯á\80¶á\80¸á\80\95á\80¼á\80¯á\80\81á\80¼á\80\84á\80ºá\80¸á\80\9eá\80\8aá\80º á\80\85á\80¬á\80\99á\80»á\80\80á\80ºá\80\94á\80¾á\80¬á\80\80á\80­á\80¯ á\80¡á\80\99á\80\8aá\80ºá\80\95á\80¼á\80±á\80¬á\80\84á\80ºá\80¸á\80\9cá\80²á\80\95á\80±á\80¸á\80\99á\80\8aá\80º á\80\96á\80¼á\80\85á\80ºá\80\95á\80¼á\80®á\80¸ á\80¡á\80\99á\80\8aá\80ºá\80\9eá\80\85á\80ºá\80\9eá\80­á\80¯á\80· á\80\9aá\80\84á\80ºá\80¸á\81\8f á\80\99á\80¾á\80\90á\80ºá\80\90á\80\99á\80ºá\80¸á\80\94á\80¾á\80\84á\80·á\80ºá\80\90á\80\80á\80½ á\80\9bá\80½á\80¾á\80±á\80·á\80\95á\80±á\80¸á\80\99á\80\8aá\80º á\80\96á\80¼á\80\85á\80ºá\80\9eá\80\8aá\80ºá\81\8b\ná\80¡á\80\99á\80\8aá\80ºá\80\9fá\80±á\80¬á\80\84á\80ºá\80¸á\80\9eá\80\8aá\80º á\80¡á\80\99á\80\8aá\80ºá\80\9eá\80\85á\80ºá\80\9eá\80­á\80¯á\80· á\80\95á\80¼á\80\94á\80ºá\80\8aá\80½á\80¾á\80\94á\80ºá\80¸á\80\85á\80¬á\80\99á\80»á\80\80á\80ºá\80\94á\80¾á\80¬ á\80\96á\80¼á\80\85á\80ºá\80\9cá\80¬á\80\99á\80\8aá\80ºá\81\8b\ná\80\9eá\80\84á\80ºá\80\9eá\80\8aá\80º á\80\99á\80°á\80\9cá\80\81á\80±á\80«á\80\84á\80ºá\80¸á\80\85á\80\89á\80ºá\80\9eá\80­á\80¯á\80· á\80\95á\80¼á\80\94á\80ºá\80\8aá\80½á\80¾á\80\94á\80ºá\80¸á\80\99á\80»á\80¬á\80¸á\80\80á\80­á\80¯ á\80¡á\80\9cá\80­á\80¯á\80¡á\80\9cá\80»á\80±á\80¬á\80\80á\80º á\80¡á\80\95á\80ºá\80\92á\80­á\80\90á\80º update á\80\9cá\80¯á\80\95á\80ºá\80\94á\80­á\80¯á\80\84á\80ºá\80\9eá\80\8aá\80ºá\81\8b\ná\80¡á\80\80á\80\9aá\80ºá\81\8d á\80\99á\80\95á\80¼á\80¯á\80\9cá\80¯á\80\95á\80ºá\80\9cá\80­á\80¯á\80\95á\80«á\80\80 [[Special:DoubleRedirects|á\80\94á\80¾á\80\85á\80ºá\80\81á\80«á\80\91á\80\95á\80º]][[Special:BrokenRedirects|á\80\95á\80¼á\80\94á\80ºá\80\8aá\80½á\80¾á\80\94á\80ºá\80¸ á\80¡á\80\95á\80»á\80\80á\80ºá\80\99á\80»á\80¬á\80¸]] á\80\80á\80­á\80¯ á\80\99á\80¾á\80\90á\80ºá\80\9eá\80¬á\80¸á\80\9bá\80\94á\80º á\80\99á\80\99á\80±á\80·á\80\95á\80«á\80\94á\80¾á\80\84á\80·á\80ºá\81\8b\ná\80\9cá\80\84á\80·á\80ºá\80\99á\80»á\80¬á\80¸ á\80\8aá\80½á\80¾á\80\94á\80ºá\80¸á\80\9cá\80­á\80¯á\80\9eá\80\8aá\80·á\80º á\80\94á\80±á\80\9bá\80¬á\80\9eá\80­á\80¯á\80· á\80\8aá\80½á\80¾á\80\94á\80ºá\80\95á\80¼á\80\94á\80±á\80\9bá\80\94á\80º á\80\9eá\80\84á\80·á\80ºá\80\90á\80½á\80\84á\80º á\80\90á\80¬á\80\9dá\80\94á\80º á\80\9bá\80¾á\80­á\80\9eá\80\8aá\80ºá\81\8b\n\ná\80¡á\80\80á\80\9aá\80ºá\81\8d á\80\81á\80±á\80«á\80\84á\80ºá\80¸á\80\85á\80\89á\80ºá\80¡á\80\9eá\80\85á\80ºá\80\90á\80½á\80\84á\80º á\80\85á\80¬á\80\99á\80»á\80\80á\80ºá\80\94á\80¾á\80¬á\80\90á\80\85á\80ºá\80\81á\80¯ á\80\9bá\80¾á\80­á\80\94á\80¾á\80\84á\80·á\80ºá\80\95á\80¼á\80®á\80¸ á\80\96á\80¼á\80\85á\80ºá\80\95á\80«á\80\80 (á\80\9eá\80­á\80¯á\80·) á\80\9aá\80\84á\80ºá\80¸á\80\85á\80¬á\80\99á\80»á\80\80á\80ºá\80\94á\80¾á\80¬á\80\9eá\80\8aá\80º á\80¡á\80\9cá\80½á\80\90á\80ºá\80\99á\80\96á\80¼á\80\85á\80ºá\80\95á\80«á\80\80 (á\80\9eá\80­á\80¯á\80·) á\80\95á\80¼á\80\94á\80ºá\80\8aá\80½á\80¾á\80\94á\80ºá\80¸á\80\90á\80\85á\80ºá\80\81á\80¯ á\80\99á\80\9bá\80¾á\80­á\80\95á\80«á\80\80 (á\80\9eá\80­á\80¯á\80·) á\80\9aá\80\81á\80\84á\80ºá\80\80 á\80\95á\80¼á\80¯á\80\95á\80¼á\80\84á\80ºá\80\91á\80¬á\80¸á\80\9eá\80±á\80¬ á\80\99á\80¾á\80\90á\80ºá\80\90á\80\99á\80ºá\80¸ á\80\99á\80\9bá\80¾á\80­á\80\95á\80«á\80\80 á\80\85á\80¬á\80\99á\80»á\80\80á\80ºá\80\94á\80¾á\80¬á\80\9eá\80\8aá\80º <strong>á\80\9bá\80½á\80±á\80·á\80\99á\80\8aá\80ºá\80\99á\80\9fá\80¯á\80\90á\80º</strong> á\80\9eá\80\8aá\80ºá\80\80á\80­á\80¯ á\80\9eá\80\90á\80­á\80\95á\80¼á\80¯á\80\95á\80«á\81\8b \ná\80\86á\80­á\80¯á\80\9cá\80­á\80¯á\80\9eá\80\8aá\80ºá\80\99á\80¾á\80¬ á\80\9eá\80\84á\80ºá\80\9eá\80\8aá\80º á\80¡á\80\99á\80¾á\80¬á\80¸á\80\90á\80\85á\80ºá\80\81á\80¯ á\80\95á\80¼á\80¯á\80\9cá\80¯á\80\95á\80ºá\80\99á\80­á\80\95á\80«á\80\80 á\80\85á\80¬á\80\99á\80»á\80\80á\80ºá\80\94á\80¾á\80¬á\80\80á\80­á\80¯ á\80\9aá\80\81á\80\84á\80ºá\80¡á\80\99á\80\8aá\80ºá\80\80á\80­á\80¯ á\80\95á\80¼á\80\94á\80ºá\80\9cá\80\8aá\80º á\80\95á\80¼á\80±á\80¬á\80\84á\80ºá\80¸á\80\9cá\80²á\80\95á\80±á\80¸á\80\94á\80­á\80¯á\80\84á\80ºá\80\9eá\80\8aá\80ºá\81\8b á\80\9bá\80¾á\80­á\80\95á\80¼á\80®á\80\9eá\80¬á\80¸á\80\85á\80¬á\80\99á\80»á\80\80á\80ºá\80\94á\80¾á\80¬á\80\90á\80\85á\80ºá\80\81á\80¯á\80\80á\80­á\80¯ á\80\85á\80¬á\80\99á\80»á\80\80á\80ºá\80\94á\80¾á\80¬ á\80¡á\80\9eá\80\85á\80ºá\80\94á\80¾á\80\84á\80·á\80º á\80\95á\80¼á\80\94á\80ºá\80¡á\80¯á\80\95á\80º overwrite á\80\81á\80¼á\80\84á\80ºá\80¸ á\80\99á\80\95á\80¼á\80¯á\80\94á\80­á\80¯á\80\84á\80ºá\81\8b\n\n<strong>á\80\9eá\80\90á\80­á\80\95á\80±á\80¸á\80\81á\80»á\80\80á\80º!</strong>\nဤသည်မှာ လူဖတ်များသော စာမျက်နှာတစ်ခုဖြစ်ပါက မမျှော်လင့်ထားသော၊ ကြီးမားသော အပြောင်းအလဲတစ်ခု ဖြစ်ပေါ်လာနိုင်သည်။\nထို့ကြောင့် ဆက်လက် မဆောင်ရွက်မီ သင်သည် နောက်ဆက်တွဲ အကျိုးဆက်များကို နားလည်ကြောင်း ကျေးဇူးပြု၍ သေချာပါစေ။",
+       "movepagetext": "á\80¡á\80±á\80¬á\80\80á\80ºá\80\95á\80«á\80\95á\80¯á\80¶á\80\85á\80¶á\80\80á\80­á\80¯ á\80¡á\80\9eá\80¯á\80¶á\80¸á\80\95á\80¼á\80¯á\80\81á\80¼á\80\84á\80ºá\80¸á\80\9eá\80\8aá\80º á\80\85á\80¬á\80\99á\80»á\80\80á\80ºá\80\94á\80¾á\80¬á\80\80á\80­á\80¯ á\80¡á\80\99á\80\8aá\80ºá\80\95á\80¼á\80±á\80¬á\80\84á\80ºá\80¸á\80\9cá\80²á\80\95á\80±á\80¸á\80\99á\80\8aá\80º á\80\96á\80¼á\80\85á\80ºá\80\95á\80¼á\80®á\80¸ á\80¡á\80\99á\80\8aá\80ºá\80\9eá\80\85á\80ºá\80\9eá\80­á\80¯á\80· á\80\9aá\80\84á\80ºá\80¸á\81\8f á\80\99á\80¾á\80\90á\80ºá\80\90á\80\99á\80ºá\80¸á\80\94á\80¾á\80\84á\80·á\80ºá\80\90á\80\80á\80½ á\80\9bá\80½á\80¾á\80±á\80·á\80\95á\80±á\80¸á\80\99á\80\8aá\80º á\80\96á\80¼á\80\85á\80ºá\80\9eá\80\8aá\80ºá\81\8b\ná\80¡á\80\99á\80\8aá\80ºá\80\9fá\80±á\80¬á\80\84á\80ºá\80¸á\80\9eá\80\8aá\80º á\80¡á\80\99á\80\8aá\80ºá\80\9eá\80\85á\80ºá\80\9eá\80­á\80¯á\80· á\80\95á\80¼á\80\94á\80ºá\80\8aá\80½á\80¾á\80\94á\80ºá\80¸á\80\85á\80¬á\80\99á\80»á\80\80á\80ºá\80\94á\80¾á\80¬ á\80\96á\80¼á\80\85á\80ºá\80\9cá\80¬á\80\99á\80\8aá\80ºá\81\8b\ná\80\9eá\80\84á\80ºá\80\9eá\80\8aá\80º á\80\99á\80°á\80\9cá\80\81á\80±á\80«á\80\84á\80ºá\80¸á\80\85á\80\89á\80ºá\80\9eá\80­á\80¯á\80· á\80\95á\80¼á\80\94á\80ºá\80\8aá\80½á\80¾á\80\94á\80ºá\80¸á\80\99á\80»á\80¬á\80¸á\80\80á\80­á\80¯ á\80¡á\80\9cá\80­á\80¯á\80¡á\80\9cá\80»á\80±á\80¬á\80\80á\80º á\80¡á\80\95á\80ºá\80\92á\80­á\80\90á\80º update á\80\9cá\80¯á\80\95á\80ºá\80\94á\80­á\80¯á\80\84á\80ºá\80\9eá\80\8aá\80ºá\81\8b\ná\80¡á\80\80á\80\9aá\80ºá\81\8d á\80\99á\80\95á\80¼á\80¯á\80\9cá\80¯á\80\95á\80ºá\80\9cá\80­á\80¯á\80\95á\80«á\80\80 [[Special:DoubleRedirects|á\80\94á\80¾á\80\85á\80ºá\80\81á\80«á\80\91á\80\95á\80º]][[Special:BrokenRedirects|á\80\95á\80¼á\80\94á\80ºá\80\8aá\80½á\80¾á\80\94á\80ºá\80¸ á\80¡á\80\95á\80»á\80\80á\80ºá\80\99á\80»á\80¬á\80¸]] á\80\80á\80­á\80¯ á\80\99á\80¾á\80\90á\80ºá\80\9eá\80¬á\80¸á\80\9bá\80\94á\80º á\80\99á\80\99á\80±á\80·á\80\95á\80«á\80\94á\80¾á\80\84á\80·á\80ºá\81\8b\ná\80\9cá\80\84á\80·á\80ºá\80\99á\80»á\80¬á\80¸ á\80\8aá\80½á\80¾á\80\94á\80ºá\80¸á\80\9cá\80­á\80¯á\80\9eá\80\8aá\80·á\80º á\80\94á\80±á\80\9bá\80¬á\80\9eá\80­á\80¯á\80· á\80\8aá\80½á\80¾á\80\94á\80ºá\80\95á\80¼á\80\94á\80±á\80\9bá\80\94á\80º á\80\9eá\80\84á\80·á\80ºá\80\90á\80½á\80\84á\80º á\80\90á\80¬á\80\9dá\80\94á\80º á\80\9bá\80¾á\80­á\80\9eá\80\8aá\80ºá\81\8b\n\ná\80¡á\80\80á\80\9aá\80ºá\81\8d á\80\81á\80±á\80«á\80\84á\80ºá\80¸á\80\85á\80\89á\80ºá\80¡á\80\9eá\80\85á\80ºá\80\90á\80½á\80\84á\80º á\80\85á\80¬á\80\99á\80»á\80\80á\80ºá\80\94á\80¾á\80¬á\80\90á\80\85á\80ºá\80\81á\80¯ á\80\9bá\80¾á\80­á\80\94á\80¾á\80\84á\80·á\80ºá\80\95á\80¼á\80®á\80¸ á\80\96á\80¼á\80\85á\80ºá\80\95á\80«á\80\80 (á\80\9eá\80­á\80¯á\80·) á\80\9aá\80\84á\80ºá\80¸á\80\85á\80¬á\80\99á\80»á\80\80á\80ºá\80\94á\80¾á\80¬á\80\9eá\80\8aá\80º á\80¡á\80\9cá\80½á\80\90á\80ºá\80\99á\80\96á\80¼á\80\85á\80ºá\80\95á\80«á\80\80 (á\80\9eá\80­á\80¯á\80·) á\80\95á\80¼á\80\94á\80ºá\80\8aá\80½á\80¾á\80\94á\80ºá\80¸á\80\90á\80\85á\80ºá\80\81á\80¯ á\80\99á\80\9bá\80¾á\80­á\80\95á\80«á\80\80 (á\80\9eá\80­á\80¯á\80·) á\80\9aá\80\81á\80\84á\80ºá\80\80 á\80\95á\80¼á\80¯á\80\95á\80¼á\80\84á\80ºá\80\91á\80¬á\80¸á\80\9eá\80±á\80¬ á\80\99á\80¾á\80\90á\80ºá\80\90á\80\99á\80ºá\80¸ á\80\99á\80\9bá\80¾á\80­á\80\95á\80«á\80\80 á\80\85á\80¬á\80\99á\80»á\80\80á\80ºá\80\94á\80¾á\80¬á\80\9eá\80\8aá\80º <strong>á\80\9bá\80½á\80±á\80·á\80\99á\80\8aá\80ºá\80\99á\80\9fá\80¯á\80\90á\80º</strong> á\80\9eá\80\8aá\80ºá\80\80á\80­á\80¯ á\80\9eá\80\90á\80­á\80\95á\80¼á\80¯á\80\95á\80«á\81\8b \ná\80\86á\80­á\80¯á\80\9cá\80­á\80¯á\80\9eá\80\8aá\80ºá\80\99á\80¾á\80¬ á\80\9eá\80\84á\80ºá\80\9eá\80\8aá\80º á\80¡á\80\99á\80¾á\80¬á\80¸á\80\90á\80\85á\80ºá\80\81á\80¯ á\80\95á\80¼á\80¯á\80\9cá\80¯á\80\95á\80ºá\80\99á\80­á\80\95á\80«á\80\80 á\80\85á\80¬á\80\99á\80»á\80\80á\80ºá\80\94á\80¾á\80¬á\80\80á\80­á\80¯ á\80\9aá\80\81á\80\84á\80ºá\80¡á\80\99á\80\8aá\80ºá\80\80á\80­á\80¯ á\80\95á\80¼á\80\94á\80ºá\80\9cá\80\8aá\80º á\80\95á\80¼á\80±á\80¬á\80\84á\80ºá\80¸á\80\9cá\80²á\80\95á\80±á\80¸á\80\94á\80­á\80¯á\80\84á\80ºá\80\9eá\80\8aá\80ºá\81\8b á\80\9bá\80¾á\80­á\80\95á\80¼á\80®á\80\9eá\80¬á\80¸á\80\85á\80¬á\80\99á\80»á\80\80á\80ºá\80\94á\80¾á\80¬á\80\90á\80\85á\80ºá\80\81á\80¯á\80\80á\80­á\80¯ á\80\85á\80¬á\80\99á\80»á\80\80á\80ºá\80\94á\80¾á\80¬ á\80¡á\80\9eá\80\85á\80ºá\80\94á\80¾á\80\84á\80·á\80º á\80\95á\80¼á\80\94á\80ºá\80¡á\80¯á\80\95á\80º overwrite á\80\81á\80¼á\80\84á\80ºá\80¸ á\80\99á\80\95á\80¼á\80¯á\80\94á\80­á\80¯á\80\84á\80ºá\81\8b\n\n<strong>á\80\99á\80¾á\80\90á\80ºá\80\81á\80»á\80\80á\80ºá\81\8b</strong>\nဤသည်မှာ လူဖတ်များသော စာမျက်နှာတစ်ခုဖြစ်ပါက မမျှော်လင့်ထားသော၊ ကြီးမားသော အပြောင်းအလဲတစ်ခု ဖြစ်ပေါ်လာနိုင်သည်။\nထို့ကြောင့် ဆက်လက် မဆောင်ရွက်မီ သင်သည် နောက်ဆက်တွဲ အကျိုးဆက်များကို နားလည်ကြောင်း ကျေးဇူးပြု၍ သေချာပါစေ။",
        "movepagetalktext": "ဤအကွက်ကို အမှန်ခြစ်လိုက်ခြင်းဖြင့် ဗလာမဟုတ်သော ဆွေးနွေးချက်စာမျက်နှာသည် ရှိနှင့်ပြီး မဟုတ်လျှင် ဆက်နွယ်နေသော ဆွေးနွေးချက် စာမျက်နှာကို ခေါင်းစဉ်အသစ်သို့  အလိုအလျောက် ရွှေ့ပစ်မည် ဖြစ်သည်။\n\nဤကိစ္စရပ်တွင် သင် ဆန္ဒရှိလျှင် စာမျက်နှာကို မိမိကိုယ်တိုင် သွားရောက်ရွှေ့ပြောင်း ပေါင်းစပ်နိုင်သည်။",
        "newtitle": "ခေါင်းစဉ်အသစ်:",
        "move-watch": "မူရင်းစာမျက်နှာနှင့် ဦးတည်ထားသော စာမျက်နှာတို့ကို စောင့်ကြည့်ရန်",
index 5bac159..1be1e59 100644 (file)
        "uploaded-script-svg": "Truvato n'elemento pe script \"$1\" int' 'o file SVG carrecato.",
        "uploaded-hostile-svg": "Truvato nu CSS insecuro int'a l'elemente 'e stile d' 'o file SVG carrecate.",
        "uploaded-event-handler-on-svg": "Mpustà 'e parametre 'e gistore-evente <code>$1=\"$2\"</code> nun è premmesso dint' 'e file SVG.",
-       "uploaded-href-unsafe-target-svg": "S'è truvato nu href a nu target ca nun era sicuro <code>&lt;$1 $2=\"$3\"&gt;</code> dint' 'o file SVG carrecato.",
+       "uploaded-href-attribute-svg": "ll'attribbute href dint' 'e file SVG songo premmesse sulamente mmerz'a na destinaziona http:// o https:// , ca se truvasse <code>&lt;$1 $2=\"$3\"&gt;</code>.",
+       "uploaded-href-unsafe-target-svg": "S'è truvato nu href a nu target 'e date: URI ca nun era sicuro <code>&lt;$1 $2=\"$3\"&gt;</code> dint' 'o file SVG carrecato.",
        "uploaded-animate-svg": "Truvato 'o tag \"animate\" ca putesse stà a cagnà href, ausanno l'attribbuto \"from\" <code>&lt;$1 $2=\"$3\"&gt;</code> int' 'o file carrecato SVG.",
        "uploaded-setting-event-handler-svg": "Mpustà n'attributo event-handler è bluccato, truvato <code>&lt;$1 $2=\"$3\"&gt;</code> int' 'o fie carrecato SVG.",
        "uploaded-setting-href-svg": "Ausà 'o tag \"set\" pe' putè azzeccà attribbute \"href\" a l'elemento parente è bluccato.",
index 86971d1..ef3c23a 100644 (file)
        "virus-scanfailed": "skanning mislyktes (kode $1)",
        "virus-unknownscanner": "ukjent antivirusprogram:",
        "logouttext": "'''Du er nå logget ut.'''\n\nVær oppmerksom på at noen sider kan fortsette å dukke opp som om du fortsatt var innlogget, helt til du nullstiller nettleserens mellomlager (cache).",
-       "cannotlogoutnow-title": "Kan ikke logge ut nå",
-       "cannotlogoutnow-text": "Å logge ut er ikke mulig ved bruk av $1.",
        "welcomeuser": "Velkommen $1!",
        "welcomecreation-msg": "Kontoen din har blitt opprettet.\nIkke glem å endre [[Special:Preferences|innstillingene dine]] på {{SITENAME}}.",
        "yourname": "Brukernavn:",
        "remembermypassword": "Husk meg på denne datamaskinen (i maks $1 {{PLURAL:$1|dag|dager}})",
        "userlogin-remembermypassword": "Hold meg innlogget",
        "userlogin-signwithsecure": "Logg inn med sikker tjener",
-       "cannotloginnow-title": "Kan ikke logge inn nå",
-       "cannotloginnow-text": "Å logge inn er ikke mulig ved bruk av $1.",
        "yourdomainname": "Ditt domene",
        "password-change-forbidden": "Du kan ikke endre passord på denne wikien.",
        "externaldberror": "Det var en ekstern autentifiseringsfeil, eller du kan ikke oppdatere din eksterne konto.",
        "resetpass_submit": "Angi passord og logg inn",
        "changepassword-success": "Passordet ditt ble korrekt endret!",
        "changepassword-throttled": "Du har foretatt for mange nylige innloggingsforsøk.\nVær vennlig å vente $1 før du prøver igjen.",
-       "botpasswords": "Robotpassord",
-       "botpasswords-summary": "<em>Robotpassord</em> gir tilgang til en brukerkonto via API uten å bruke hovedpassordet til kontoen. Brukerrettighetene kan bli begrenset ved bruk av dette passordet.\n\nHvis du ikke vet om du vil benytte dette, er det sannsynlig at du ikke bør fylle det ut. Det skal ikke være nødvendig for andre personer å be deg om å fylle ut dette for å gi det til de.",
-       "botpasswords-disabled": "Robotpassord er deaktivert.",
-       "botpasswords-no-central-id": "For å bruke robotpassord må du være logget inn med en sentralisert konto.",
-       "botpasswords-existing": "Eksisterende robotpassord",
-       "botpasswords-createnew": "Opprett et nytt robotpassord",
-       "botpasswords-editexisting": "Redigere et eksisterende robotpassord",
-       "botpasswords-label-appid": "Robotnavn:",
-       "botpasswords-label-create": "Opprett",
-       "botpasswords-label-update": "Oppdater",
-       "botpasswords-label-cancel": "Avbryt",
-       "botpasswords-label-delete": "Slett",
-       "botpasswords-label-resetpassword": "Tilbakestill passord",
-       "botpasswords-label-grants": "Tilgjengelige tildelinger:",
-       "botpasswords-help-grants": "Hver tildeling gir tilgang til opplistede brukerrettigheter som brukerkontoen allerede har. Se [[Special:ListGrants|tildelingstabellen]] for mer informasjon.",
-       "botpasswords-label-restrictions": "Bruksbegrensninger:",
-       "botpasswords-label-grants-column": "Bevilget",
-       "botpasswords-bad-appid": "Robotnavnet \"$1\" er ikke gyldig.",
-       "botpasswords-insert-failed": "Kunne ikke legge til robotnavnet \"$1\". Har det allerede blitt lagt til?",
-       "botpasswords-update-failed": "Kunne ikke oppdatere robotnavnet \"$1\". Er det slettet?",
-       "botpasswords-created-title": "Robotpassord opprettet",
-       "botpasswords-created-body": "Robotpassordet \"$1\" ble opprettet.",
-       "botpasswords-updated-title": "Robotpassord oppdatert",
-       "botpasswords-updated-body": "Robotpassordet \"$1\" ble oppdatert.",
-       "botpasswords-deleted-title": "Robotpassord slettet",
-       "botpasswords-deleted-body": "Robotpassordet \"$1\" ble slettet.",
-       "botpasswords-newpassword": "Det nye passordet for å logge inn med <strong>$1</strong> er <strong>$2</strong>. <em>Vennligst lagre dette for fremtidig referanse.</em>",
-       "botpasswords-no-provider": "BotPasswordsSessionProvider er ikke tilgjengelig.",
-       "botpasswords-restriction-failed": "Begrensninger for robotpassord tillater ikke denne innloggingen.",
-       "botpasswords-invalid-name": "Det angitte brukernavnet inneholder ikke separasjonstegnet for robotpassord (\"$1\").",
-       "botpasswords-not-exist": "Brukeren \"$1\" har ikke noe robotpassord for \"$2\".",
        "resetpass_forbidden": "Passord kan ikke endres",
        "resetpass-no-info": "Du må være logget inn for å gå til denne siden direkte",
        "resetpass-submit-loggedin": "Endre passord",
        "passwordreset-emailtext-ip": "Noen (sannsynligvis deg fra IP-adressen $1) ba om en tilbakestilling av ditt passord for {{SITENAME}} ($4). {{PLURAL:$3|Den følgende brukerkontoen|De følgende brukerkontoene}} er\ntilknyttet denne e-postadressen:\n\n$2\n\n{{PLURAL:$3|Dette midlertidige passordet|Disse midlertidige passordene}} utløper om {{PLURAL:$5|én dag|$5 dager}}.\nDu bør logge på og velge et nytt passord nå. Dersom noen andre kom med denne\nforespørselen, eller du har kommet på ditt opprinnelige passord, og ikke lenger\nønsker å endre det, kan du ignorere denne meldingen og fortsette å bruke ditt gamle\npassord.",
        "passwordreset-emailtext-user": "Brukeren $1 på {{SITENAME}} ba om en tilbakestilling av passordet ditt for {{SITENAME}}\n($4). {{PLURAL:$3|Den følgende brukerkontoen|De følgende brukerkontoene}} er tilknyttet denne e-postadressen:\n\n$2\n\n{{PLURAL:$3|Dette midlertidige passordet|Disse midlertidige passordene}} utløper om {{én dag|$5 dager}}.\nDu bør logge på og velge et nytt passord nå. Dersom noen andre kom med denne\nforespørselen, eller du har kommet på ditt opprinnelige passord, og ikke lenger\nønsker å endre det, kan du ignorere denne meldingen og fortsette å bruke ditt gamle\npassord.",
        "passwordreset-emailelement": "Brukernavn: \n$1\n\nMidlertidig passord: \n$2",
-       "passwordreset-emailsentemail": "Hvis dette er en registrert epostadresse for din konto, vil det bli sendt ut en passordtilbakestillingsepost.",
+       "passwordreset-emailsentemail": "Hvis denne epostadressen er koblet til din konto, så vil det bli sendt en epost om tilabakestilling av passord.",
        "passwordreset-emailsentusername": "Hvis det finnes en epostadresse knyttet til dette brukernavnet, vil en epost med informasjon om tilbakestilling av passord bli sendt.",
        "passwordreset-emailsent-capture": "Passordtilbakestillingseposten vist under har blitt sendt ut.",
        "passwordreset-emailerror-capture": "En passordtilbakestillingsepost ble laget, men det lyktes ikke å sende denne til {{GENDER:$2|brukeren}}: $1",
        "right-createpage": "Opprette sider (som ikke er diskusjonssider)",
        "right-createtalk": "Opprette diskusjonssider",
        "right-createaccount": "Opprette nye kontoer",
-       "right-autocreateaccount": "Logg inn automatisk med en ekstern brukerkonto",
        "right-minoredit": "Markere endringer som mindre",
        "right-move": "Flytte sider",
        "right-move-subpages": "Flytte sider med undersider",
        "querypage-disabled": "Denne spesialsiden er deaktivert av ytelsesårsaker.",
        "apihelp": "API hjelp",
        "apihelp-no-such-module": "Modulen «$1» ikke funnet.",
+       "apisandbox": "API-sandkasse",
+       "apisandbox-api-disabled": "API er deaktivert på dette nettstedet.",
+       "apisandbox-intro": "Bruk denne siden for å eksperimentere med '''MediaWiki web service APIet'''.\nSjekk [//www.mediawiki.org/wiki/API:Main_page API-dokumentasjonen] for mer informasjon om bruk av APIet. Eksempel: [//www.mediawiki.org/wiki/API#A_simple_example hente innholdet til en hovedside]. Velg en handling for å se flere eksempler.\n\nMerk at du kan utføre handlinger her som fører til endringer på wikien.",
+       "apisandbox-submit": "Foreta en forespørsel",
+       "apisandbox-reset": "Tilbakestill",
+       "apisandbox-examples": "Eksempel",
+       "apisandbox-results": "Resultat",
+       "apisandbox-request-url-label": "Forespurt URL:",
+       "apisandbox-request-time": "Forespørselstid: $1",
        "booksources": "Bokkilder",
        "booksources-search-legend": "Søk etter bokkilder",
        "booksources-search": "Søk",
index 783417b..70ad96c 100644 (file)
        "querypage-disabled": "Deze speciale pagina is uitgeschakeld om performanceredenen.",
        "apihelp": "API-hulp",
        "apihelp-no-such-module": "Module \"$1\" niet gevonden.",
+       "apisandbox": "API-zandbak",
+       "apisandbox-api-disabled": "De API is uitgeschakeld op deze site.",
+       "apisandbox-intro": "Gebruik deze pagina om te experimenteren met de '''MediaWiki-API'''.\nZie de [//www.mediawiki.org/wiki/API:Main_page API-documentatie] voor verdere details over het gebruik van de API. Voorbeeld: [//www.mediawiki.org/wiki/API#A_simple_example hoe de inhoud van een Hoofdpagina ophalen]. Selecteer een handeling om meer voorbeelden te zien.\n\nHoewel dit een testfunctie is, kunnen sommige handelingen toch wijzigingen in de wiki maken.",
+       "apisandbox-submit": "Verzoek uitvoeren",
+       "apisandbox-reset": "Wissen",
+       "apisandbox-examples": "Voorbeeld",
+       "apisandbox-results": "Resultaat",
+       "apisandbox-request-url-label": "Verzoek-URL:",
+       "apisandbox-request-time": "Doorlooptijd verzoek: $1",
        "booksources": "Boekinformatie",
        "booksources-search-legend": "Bronnen en gegevens over een boek zoeken",
        "booksources-search": "Zoeken",
index a13b60f..dd51f9d 100644 (file)
        "pager-older-n": "{{PLURAL:$1|eldre|eldre $1}}",
        "suppress": "Historikkfjerning",
        "querypage-disabled": "Spesialsida er slegen av for skuld yting.",
+       "apisandbox": "API-sandkasse",
+       "apisandbox-api-disabled": "API er slege av på nettstaden.",
+       "apisandbox-intro": "Nytt sida til å røyna ut '''MediaWiki web service API''-en.\nSjå [//www.mediawiki.org/wiki/API:Main_page API-dokumentasjonen] for meir informasjon om bruk av API-en. Døme: [//www.mediawiki.org/wiki/API#A_simple_example hent innhaldet til ei hovudside].\nVel ei handling for å sjå fleire døme.",
+       "apisandbox-submit": "Gjer førespurnad",
+       "apisandbox-reset": "Tøm",
+       "apisandbox-examples": "Døme",
+       "apisandbox-results": "Utfall",
+       "apisandbox-request-url-label": "Førespurd URL:",
+       "apisandbox-request-time": "Førespurnadstid: $1",
        "booksources": "Bokkjelder",
        "booksources-search-legend": "Søk etter bokkjelder",
        "booksources-search": "Søk",
index d989b09..1fc9145 100644 (file)
        "querypage-disabled": "Ta strona specjalna została wyłączona ze względu na ograniczenia wydajności.",
        "apihelp": "Pomoc API",
        "apihelp-no-such-module": "Moduł \"$1\" nie znaleziony.",
+       "apisandbox": "Środowisko testowe API",
+       "apisandbox-api-disabled": "API jest wyłączone na tej stronie.",
+       "apisandbox-intro": "Użyj tej strony do eksperymentowania z '''API serwisu MediaWiki'''.\nWięcej szczegółów na temat wykorzystywania API można znaleźć w [//www.mediawiki.org/wiki/API:Main_page dokumentacji API]. Przykład: [//www.mediawiki.org/wiki/API#A_simple_example pobranie zawartości strony głównej]. Wybierz akcję, by zobaczyć więcej przykładów.\n\nZwróć uwagę, że chociaż jest to brudnopis, to działania, które można przeprowadzać na tej stronie mogą zmienić wiki.",
+       "apisandbox-submit": "Wykonaj zapytanie",
+       "apisandbox-reset": "Wyczyść",
+       "apisandbox-examples": "Przykład",
+       "apisandbox-results": "Rezultat",
+       "apisandbox-request-url-label": "URL zapytania:",
+       "apisandbox-request-time": "Czas przetwarzania zapytania: $1",
        "booksources": "Książki",
        "booksources-search-legend": "Szukaj informacji o książkach",
        "booksources-search": "Szukaj",
index 0ffcec6..a416870 100644 (file)
        "querypage-disabled": "Sta pàgina special a l'é disabilità për dle rason ëd prestassion.",
        "apihelp": "Agiut ëd l'API",
        "apihelp-no-such-module": "Ël mòdol «$1» as treuva nen.",
+       "apisandbox": "Spassi dle preuve API",
+       "apisandbox-api-disabled": "API a l'é disabilità ansima a 's sit.",
+       "apisandbox-intro": "Ch'a deuvra sta pàgina për sperimenté ël '''servissi an sl'aragnà MediaWiki API'''.\nCh'a fasa riferiment a [//www.mediawiki.org/wiki/API:Main_page la documentassion ëd l'API] për d'àutri detaj an sl'utilisassion ëd l'API. Për esempi: [//www.mediawiki.org/wiki/API#A_simple_example oten-e ël contnù ëd na pàgina d'Intrada]. Ch'a selession-a n'assion për vëdde d'àutri esempi.",
+       "apisandbox-submit": "Fé l'arcesta",
+       "apisandbox-reset": "Scancela",
+       "apisandbox-examples": "Esempi",
+       "apisandbox-results": "Arzultà",
+       "apisandbox-request-url-label": "Anliura d'arcesta:",
+       "apisandbox-request-time": "Temp necessari: $1",
        "booksources": "Andoa trové dij lìber",
        "booksources-search-legend": "Sërché antra ij lìber d'arferiment",
        "booksources-search": "Arserché",
index 14ec4bd..392eba4 100644 (file)
        "foreign-structured-upload-form-label-own-work-message-local": "Confirmo que estou a carregar este ficheiro segundo as condições de serviço e política de licenças de {{SITENAME}}.",
        "foreign-structured-upload-form-label-not-own-work-message-local": "Se não é capaz de carregar este ficheiro sob as políticas de {{SITENAME}}, por favor feche esta janela e tente outro método.",
        "foreign-structured-upload-form-label-not-own-work-local-local": "Poderá querer experimentar [[Special:Upload|a página padrão de carregamento]].",
-       "foreign-structured-upload-form-label-own-work-message-default": "Entendo que estou a carregar este ficheiro em um repositório partilhado. Confirmo que estou a fazê-lo cumprindo com os termos de serviço e com as políticas de licenciamento dali.",
+       "foreign-structured-upload-form-label-own-work-message-default": "Entendo que estou a carregar este ficheiro em um repositório partilhado. Confirmo que estou a fazê-lo cumprindo com os termos de serviço e com as políticas de licenciamento.",
        "foreign-structured-upload-form-label-not-own-work-message-default": "Se não é capaz de carregar este ficheiro sob as políticas do repositório partilhado, por favor feche esta janela e tente outro método.",
-       "foreign-structured-upload-form-label-not-own-work-local-default": "Pode querer tentar utilizar [[Special:Upload|a página de carregamento em {{SITENAME}}]], se este ficheiro puder ser carregado de acordo com suas políticas.",
-       "foreign-structured-upload-form-label-own-work-message-shared": "Confirmo que sou o proprietário dos direitos autorais deste ficheiro, e aceito liberar irrevogavelmente este ficheiro para o Wikimedia Commons nos termos da licença [https://creativecommons.org/licenses/by-sa/4.0/ Creative Commons Atribuição-CompartilhaIgual 4.0], e concordo com os [https://wikimediafoundation.org/wiki/Terms_of_Use Termos de Utilização].",
-       "foreign-structured-upload-form-label-not-own-work-message-shared": "Se não é o proprietário dos direitos autorais deste ficheiro, ou caso deseje liberá-lo sob uma licença diferente, considere utilizar o [https://commons.wikimedia.org/wiki/Special:UploadWizard Assistente de Envio de Ficheiros do Commons].",
-       "foreign-structured-upload-form-label-not-own-work-local-shared": "Pode querer tentar utilizar [[Special:Upload|a página de carregamento em {{SITENAME}}]], se o sítio aceitar o carregamento deste ficheiro de acordo com suas políticas.",
+       "foreign-structured-upload-form-label-not-own-work-local-default": "Pode querer tentar utilizar [[Special:Upload|a página de carregamento em {{SITENAME}}]], se este ficheiro puder ser carregado de acordo com suas as políticas.",
+       "foreign-structured-upload-form-label-own-work-message-shared": "Confirmo que sou o proprietário dos direitos de autor deste ficheiro, e aceito partilhar irrevogavelmente este ficheiro para o Wikimedia Commons nos termos da licença [https://creativecommons.org/licenses/by-sa/4.0/ Creative Commons Atribuição-CompartilhaIgual 4.0], e concordo com os [https://wikimediafoundation.org/wiki/Terms_of_Use Termos de Utilização].",
+       "foreign-structured-upload-form-label-not-own-work-message-shared": "Se não é o proprietário dos direitos de autor deste ficheiro, ou caso deseje partilhá-lo sob uma licença diferente, considere utilizar o [https://commons.wikimedia.org/wiki/Special:UploadWizard Assistente de Envio de Ficheiros do Commons].",
+       "foreign-structured-upload-form-label-not-own-work-local-shared": "Pode querer tentar utilizar [[Special:Upload|a página de carregamento em {{SITENAME}}]], se o sítio aceitar o carregamento deste ficheiro de acordo com as suas políticas.",
        "foreign-structured-upload-form-2-label-intro": "Obrigado por doar uma imagem para utilização em {{SITENAME}}. Deverá continuar apenas se cumprir algumas condições:",
        "foreign-structured-upload-form-2-label-ownwork": "Deve ser inteiramente <strong>sua obra própria</strong>, não apenas retirada da Internet",
        "foreign-structured-upload-form-2-label-noderiv": "Não pode conter <strong>nenhuma obra de qualquer outra pessoa</strong>, ou inspirado por elas",
        "foreign-structured-upload-form-2-label-useful": "Deve ser <strong>educativo e útil</strong> para ensinar a outros",
        "foreign-structured-upload-form-2-label-ccbysa": "Deve estar <strong>aceito para publicar para sempre</strong> na Internet nos termos da licença [https://creativecommons.org/licenses/by-sa/4.0/ Creative Commons Atribuição-CompartilhaIgual 4.0]",
-       "foreign-structured-upload-form-2-label-alternative": "Caso nenhum dos itens acima for o correcto, ainda pode ser capaz de carregar este ficheiro ao utilizar o [https://commons.wikimedia.org/wiki/Special:UploadWizard Assistente para Envio de Ficheiros do Commons], desde que esteja disponível sob uma licença livre.",
+       "foreign-structured-upload-form-2-label-alternative": "Caso nenhum dos itens acima for o correto, ainda pode ser capaz de carregar este ficheiro ao utilizar o [https://commons.wikimedia.org/wiki/Special:UploadWizard Assistente de Envio de Ficheiros do Commons], desde que esteja disponível sob uma licença livre.",
        "foreign-structured-upload-form-3-label-yes": "Sim",
        "foreign-structured-upload-form-3-label-no": "Não",
        "foreign-structured-upload-form-4-label-bad": "Não pode carregar imagens encontradas num motor de busca ou descarregadas de outros sítios.",
        "querypage-disabled": "Esta página especial está desativada para não prejudicar o desempenho.",
        "apihelp": "Ajuda API",
        "apihelp-no-such-module": "Módulo \"$1\" não encontrado.",
+       "apisandbox": "Testes da API",
+       "apisandbox-api-disabled": "A API está desativada neste site.",
+       "apisandbox-intro": "Use esta página para fazer experiências com a '''API de <i>web services</i> do MediaWiki'''.\nConsulte a [//www.mediawiki.org/wiki/API:Main_page documentação da API] para informações sobre o uso da API. Exemplo: [//www.mediawiki.org/wiki/API#A_simple_example obter o conteúdo da Página Principal]. Selecione uma operação para ver mais exemplos.\n\nNote que, embora esta seja uma área de testes, as operações que executar nesta página podem modificar a wiki.",
+       "apisandbox-submit": "Fazer o pedido",
+       "apisandbox-reset": "Limpar",
+       "apisandbox-examples": "Exemplo",
+       "apisandbox-results": "Resultado",
+       "apisandbox-request-url-label": "URL do pedido:",
+       "apisandbox-request-time": "Tempo de processamento: $1",
        "booksources": "Fontes bibliográficas",
        "booksources-search-legend": "Pesquisar referências bibliográficas",
        "booksources-search": "Pesquisar",
index 388ec67..9ec2564 100644 (file)
        "readonly_lag": "Error message displayed when the database is locked.",
        "nonwrite-api-promise-error": "Error message displayed when the 'Promise-Non-Write-API-Action' HTTP header is misused.",
        "internalerror": "{{Identical|Internal error}}",
-       "internalerror_info": "Parameters:\n* $1 - error message",
+       "internalerror_info": "Parameters:\n* $1 - error message\n{{Identical|Internal error}}",
        "internalerror-fatal-exception": "Error message displayed by MediaWiki itself when the request failed, inside an error box which also contains a code, a timestamp and a colon before this message.\nParameters:\n* $1 - proper name of the kind of error\n* $2 - alphanumeric code identifying the error in the server logs\n* $3 - URL which resulted in the error\n$2 and $3 are not used by default and only available for wiki customisations, because they are useful for communication to the wiki system administrator.",
        "filecopyerror": "Parameters:\n* $1 - source file name\n* $2 - destination file name",
        "filerenameerror": "Parameters:\n* $1 - old file name\n* $2 - new file name",
        "virus-scanfailed": "Used as error message. \"scan\" stands for \"virus scan\". Parameters:\n* $1 - exit code of virus scanner",
        "virus-unknownscanner": "Used as error message. This message is followed by the virus scanner name.",
        "logouttext": "Log out message. Parameters:\n* $1 - (Unused) an URL to [[Special:Userlogin]] containing <code>returnto</code> and <code>returntoquery</code> parameters",
+       "cannotlogoutnow-title": "Error page title shown when logging out is not possible.",
+       "cannotlogoutnow-text": "Error page text shown when logging out is not possible. Parameters:\n* $1 - Session type in use that makes it not possible to log out, from a message like {{msg-mw|sessionprovider-mediawiki-session-cookiesessionprovider}}.",
        "welcomeuser": "Text for a welcome heading that users see after registering a user account.\n\nParameters:\n* $1 - the username of the new user. See [[phab:T44215]]",
        "welcomecreation-msg": "A welcome message users see after registering a user account, following a welcomeuser heading.\n\nParameters:\n* $1 - (Unused) the username of the new user.\n\nReplaces [[MediaWiki:welcomecreation|welcomecreation]] in 1.21wmf5, see [[phab:T44215]]",
        "yourname": "Since 1.22 no longer used in core, but used by some extensions.\n{{Identical|Username}}",
        "remembermypassword": "Used as checkbox label on [[Special:ChangePassword]]. Parameters:\n* $1 - number of days\n{{Identical|Remember my login on this computer}}",
        "userlogin-remembermypassword": "The text for a check box in [[Special:UserLogin]].",
        "userlogin-signwithsecure": "Text of link to HTTPS login form.\n\nSee example: [[Special:UserLogin]]",
+       "cannotloginnow-title": "Error page title shown when logging in is not possible.",
+       "cannotloginnow-text": "Error page text shown when logging in is not possible. Parameters:\n* $1 - Session type in use that makes it not possible to log in, from a message like {{msg-mw|sessionprovider-mediawiki-session-cookiesessionprovider}}.",
        "yourdomainname": "Used as label for listbox.",
        "password-change-forbidden": "Error message shown when an external authentication source does not allow the password to be changed.",
        "externaldberror": "This message is thrown when a valid attempt to change the wiki password for a user fails because of a database error or an error from an external system.",
        "resetpass_submit": "Submit button on [[Special:ChangePassword]]",
        "changepassword-success": "Used in [[Special:ChangePassword]].",
        "changepassword-throttled": "Error message shown at [[Special:ChangePassword]] after the user has tried to login with incorrect password too many times.\n\nThe user has to wait a certain time before trying to log in again.\n\nParameters:\n* $1 - the time to wait before the next login attempt. Automatically formatted using the following duration messages:\n** {{msg-mw|Duration-millennia}}\n** {{msg-mw|Duration-centuries}}\n** {{msg-mw|Duration-decades}}\n** {{msg-mw|Duration-years}}\n** {{msg-mw|Duration-weeks}}\n** {{msg-mw|Duration-days}}\n** {{msg-mw|Duration-hours}}\n** {{msg-mw|Duration-minutes}}\n** {{msg-mw|Duration-seconds}}\n\nThis is a protection against robots trying to find the password by trying lots of them.\nThe number of attempts and waiting time are configured via [[mw:Manual:$wgPasswordAttemptThrottle|$wgPasswordAttemptThrottle]].\nThis message is used in html.\n\nSee also:\n* {{msg-mw|Changeemail-throttled}}",
+       "botpasswords": "The name of the special page [[Special:BotPasswords]].",
+       "botpasswords-summary": "Explanatory text shown at the top of [[Special:BotPasswords]].",
+       "botpasswords-disabled": "Error message displayed when bot passwords are not enabled (<code>$wgEnableBotPasswords = false</code>).",
+       "botpasswords-no-central-id": "Error message displayed when the current user does not have a central ID (e.g. they're not logged in or not attached in something like CentralAuth).",
+       "botpasswords-existing": "Form section label for the part of the form listing the user's existing bot passwords.",
+       "botpasswords-createnew": "Form section label for the part of the form related to creating a new bot password.",
+       "botpasswords-editexisting": "Form section label for the part of the form related to editing an existing bot password.",
+       "botpasswords-label-appid": "Form field label for the \"bot name\", internally known as the \"application ID\".",
+       "botpasswords-label-create": "Button label for the button to create a new bot password.\n{{Identical|Create}}",
+       "botpasswords-label-update": "Button label for the button to save changes to a bot password.\n{{Identical|Update}}",
+       "botpasswords-label-cancel": "Button label for a button to cancel the creation or edit of a bot password.\n{{Identical|Cancel}}",
+       "botpasswords-label-delete": "Button label for the button to delete a bot password.\n{{Identical|Delete}}",
+       "botpasswords-label-resetpassword": "Label for the checkbox to reset the actual password for the current bot password.",
+       "botpasswords-label-grants": "Label for the checkmatrix for selecting grants allowed when the bot password is used.",
+       "botpasswords-help-grants": "Help text for the grant selection checkmatrix.",
+       "botpasswords-label-restrictions": "Label for the textarea field in which JSON defining access restrictions (e.g. which IP address ranges are allowed) is entered.",
+       "botpasswords-label-grants-column": "Label for the checkbox column on the checkmatrix for selecting grants allowed when the bot password is used.",
+       "botpasswords-bad-appid": "Used as an error message when an invalid \"bot name\" is supplied on [[Special:BotPasswords]]. Parameters:\n* $1 - The rejected bot name.",
+       "botpasswords-insert-failed": "Error message when saving a new bot password failed. It's likely that the failure was because the user resubmitted the form after a previous successful save. Parameters:\n* $1 - Bot name",
+       "botpasswords-update-failed": "Error message when saving changes to an existing bot password failed. It's likely that the failure was because the user deleted the bot password in another browser window. Parameters:\n* $1 - Bot name",
+       "botpasswords-created-title": "Title of the success page when a new bot password is created.",
+       "botpasswords-created-body": "Success message when a new bot password is created. Parameters:\n* $1 - Bot name",
+       "botpasswords-updated-title": "Title of the success page when a bot password is updated.",
+       "botpasswords-updated-body": "Success message when a bot password is updated. Parameters:\n* $1 - Bot name",
+       "botpasswords-deleted-title": "Title of the success page when a bot password is deleted.",
+       "botpasswords-deleted-body": "Success message when a bot password is deleted. Parameters:\n* $1 - Bot name",
+       "botpasswords-newpassword": "Success message to display the new password when a bot password is created or updated. Parameters:\n* $1 - User name to be used for login.\n* $2 - Password to be used for login.",
+       "botpasswords-no-provider": "Error message when login is attempted but the BotPasswordsSessionProvider is not included in <code>$wgSessionProviders</code>.",
+       "botpasswords-restriction-failed": "Error message when login is rejected because the configured restrictions were not satisfied.",
+       "botpasswords-invalid-name": "Error message when a username lacking the separator character is passed to BotPassword. Parameters:\n* $1 - The separator character.",
+       "botpasswords-not-exist": "Error message when a username exists but does not a bot password for the given \"bot name\". Parameters:\n* $1 - username\n* $2 - bot name",
        "resetpass_forbidden": "Used as error message in changing password. Maybe the external auth plugin won't allow local password changes.",
        "resetpass-no-info": "Error message for [[Special:ChangePassword]].\n\nParameters:\n* $1 (unused) - a link to [[Special:UserLogin]] with {{msg-mw|loginreqlink}} as link description",
        "resetpass-submit-loggedin": "Button on [[Special:ResetPass]] to submit new password.\n\n{{Identical|Change password}}",
        "showpreview": "The text of the button to preview the page you are editing. See also {{msg-mw|showdiff}} and {{msg-mw|savearticle}} for the other buttons.\n\nSee also:\n* {{msg-mw|Showpreview}}\n* {{msg-mw|Accesskey-preview}}\n* {{msg-mw|Tooltip-preview}}\n{{Identical|Show preview}}",
        "showdiff": "Button below the edit page. See also {{msg-mw|Showpreview}} and {{msg-mw|Savearticle}} for the other buttons.\n\nSee also:\n* {{msg-mw|Showdiff}}\n* {{msg-mw|Accesskey-diff}}\n* {{msg-mw|Tooltip-diff}}\n{{Identical|Show change}}",
        "blankarticle": "Notice displayed once after the user tries to save an empty page.",
-       "anoneditwarning": "Shown when editing a page anonymously.\n\nParameters:\n* $1 – A link to log in, <nowiki>{{fullurl:Special:UserLogin|returnto={{FULLPAGENAMEE}}}}</nowiki>\n* $2 – A link to sign up, <nowiki>{{fullurl:Special:UserLogin/signup|returnto={{FULLPAGENAMEE}}}}</nowiki>\n\nSee also:\n* {{msg-mw|mobile-frontend-editor-anoneditwarning}}",
+       "anoneditwarning": "Shown when editing a page anonymously.\n\nParameters:\n* $1 – A link to log in, <nowiki>{{fullurl:Special:UserLogin|returnto={{FULLPAGENAMEE}}}}</nowiki>\n* $2 – A link to sign up, <nowiki>{{fullurl:Special:UserLogin/signup|returnto={{FULLPAGENAMEE}}}}</nowiki>\n\nSee also:\n* {{msg-mw|Mobile-frontend-editor-anonwarning}}",
        "anonpreviewwarning": "See also:\n* {{msg-mw|Anoneditwarning}}",
        "missingsummary": "The text \"edit summary\" is in {{msg-mw|Summary}}.\n\nSee also:\n* {{msg-mw|Missingcommentheader}}\n* {{msg-mw|Savearticle}}",
        "selfredirect": "Notice displayed once after the user tries to create a redirect to the same article.",
        "right-createpage": "{{doc-right|createpage}}\nBasic right to create pages. The right to edit discussion/talk pages is {{msg-mw|right-createtalk}}.",
        "right-createtalk": "{{doc-right|createtalk}}\nBasic right to create discussion/talk pages. The right to edit other pages is {{msg-mw|right-createpage}}.",
        "right-createaccount": "{{doc-right|createaccount}}\nThe right to [[Special:CreateAccount|create a user account]].",
+       "right-autocreateaccount": "{{doc-right|autocreateaccount}}\nThe right to automatically create an account from an external source (e.g. CentralAuth).",
        "right-minoredit": "{{doc-right|minoredit}}\nThe right to use the \"This is a minor edit\" checkbox. See {{msg-mw|minoredit}} for the message used for that checkbox.",
        "right-move": "{{doc-right|move}}\nThe right to move any page that is not protected from moving.\n{{Identical|Move page}}",
        "right-move-subpages": "{{doc-right|move-subpages}}",
        "action-createpage": "{{Doc-action|createpage}}\n{{Identical|Create page}}",
        "action-createtalk": "{{Doc-action|createtalk}}",
        "action-createaccount": "{{Doc-action|createaccount}}",
+       "action-autocreateaccount": "{{Doc-action|autocreateaccount}}",
        "action-history": "{{Doc-action|history}}",
        "action-minoredit": "{{Doc-action|minoredit}}",
        "action-move": "{{Doc-action|move}}",
        "withoutinterwiki-submit": "{{Identical|Show}}",
        "fewestrevisions": "{{doc-special|FewestRevisions}}",
        "fewestrevisions-summary": "{{doc-specialpagesummary|fewestrevisions}}",
-       "nbytes": "Message used on the history page of a wiki page. Each version of a page consist of a number of bytes. $1 is the number of bytes that the page uses. Uses plural as configured for a language based on $1.",
+       "nbytes": "Message used on the history page of a wiki page. Each version of a page consist of a number of bytes. $1 is the number of bytes that the page uses. Uses plural as configured for a language based on $1.\n{{Identical|Byte}}",
        "ncategories": "Used in the special page '[[Special:MostCategories]]' in brackets after each entry on the list signifying how many categories a page is part of. $1 is the number of categories.",
        "ninterwikis": "Used in the special page '[[Special:MostInterwikis]]' in brackets after each entry on the list signifying how many interwikis a page is part of.\n\nParameters:\n* $1 - the number of interwiki links",
        "nlinks": "This appears in brackets after each entry on the special page [[Special:MostLinked]]. $1 is the number of wiki links.",
        "apihelp-summary": "{{doc-specialpagesummary|ApiHelp}}",
        "apihelp-no-such-module": "Used as an error message if the requested API module is not found.\n\nParameters:\n* $1 - Requested module name",
        "apihelp-link": "{{notranslate}} Used to construct a link to [[Special:ApiHelp]]\n\nParameters:\n* $1 - module to link\n* $2 - link text",
+       "apisandbox": "{{doc-special|ApiSandbox}}",
+       "apisandbox-summary": "{{doc-specialpagesummary|ApiSandbox}}",
+       "apisandbox-jsonly": "Displayed as an error message if the browser does not have JavaScript enabled.",
+       "apisandbox-api-disabled": "Displayed as an error message if the API is disabled on this site.",
+       "apisandbox-intro": "Displayed (from JavaScript) as a header on [[Special:ApiSandbox]].",
+       "apisandbox-fullscreen": "JavaScript button label for enabling full-page mode.",
+       "apisandbox-fullscreen-tooltip": "Tooltip for the {{msg-mw|apisandbox-fullscreen}} button.",
+       "apisandbox-unfullscreen": "JavaScript button label for disabling full-page mode.",
+       "apisandbox-unfullscreen-tooltip": "Tooltip for the {{msg-mw|apisandbox-unfullscreen}} button.",
+       "apisandbox-submit": "JavaScript button label for submitting the request.",
+       "apisandbox-reset": "JavaScript button label for clearing the form.",
+       "apisandbox-retry": "JavaScript button label for retrying the submission.",
+       "apisandbox-loading": "JavaScript message displayed while data is loading.\n\nParameters:\n* $1 - Module being loaded",
+       "apisandbox-load-error": "Displayed as an error message from JavaScript when data failed to load.\n\nParameters:\n* $1 - Module being loaded\n* $2 - Error message from the API",
+       "apisandbox-no-parameters": "Displayed (from JavaScript) when the loaded API module has no parameters.",
+       "apisandbox-helpurls": "JavaScript button label for showing help URLs.",
+       "apisandbox-examples": "JavaScript button label for showing example queries.",
+       "apisandbox-dynamic-parameters": "JavaScript fieldset legend for the section containing the widgets to add arbitrary parameters to a module that can accept dynamic parameters.",
+       "apisandbox-dynamic-parameters-add-label": "JavaScript label for the widget to add a new arbitrary parameter.",
+       "apisandbox-dynamic-parameters-add-placeholder": "JavaScript text field placeholder for the widget to add a new arbitrary parameter.",
+       "apisandbox-dynamic-error-exists": "Displayed as an error message from JavaScript when trying to add a new arbitrary parameter with a name that already exists. Parameters:\n* $1 - Parameter name that failed.",
+       "apisandbox-deprecated-parameters": "JavaScript button label and fieldset legend for separating deprecated parameters in the UI.",
+       "apisandbox-fetch-token": "Tooltop for the button that fetches a CSRF token.",
+       "apisandbox-submit-invalid-fields-title": "Title for a JavaScript error message when fields are invalid.",
+       "apisandbox-submit-invalid-fields-message": "Content for a JavaScript error message when fields are invalid.",
+       "apisandbox-results": "JavaScript tab label for the tab displaying the API query results.",
+       "apisandbox-sending-request": "JavaScript message displayed while the request is being sent.",
+       "apisandbox-loading-results": "JavaScript message displayed while the response is being read.",
+       "apisandbox-results-error": "Displayed as an error message from JavaScript when the request failed.\n\nParameters:\n* $1 - Error message",
+       "apisandbox-request-url-label": "Label for the text field displaying the URL used to make this request.",
+       "apisandbox-request-time": "Label and value for displaying the time taken by the request.\n\nParameters:\n* $1 - Time taken in milliseconds",
+       "apisandbox-results-fixtoken": "JavaScript button label",
+       "apisandbox-results-fixtoken-fail": "Displayed as an error message from JavaScript when a CSRF token could not be fetched.\n\nParameters:\n* $1 - Token type",
+       "apisandbox-alert-page": "Tooltip for the alert icon on a module's page tab when the page contains fields with issues.",
+       "apisandbox-alert-field": "Tooltip for the alert icon on a field when the field has issues.",
        "booksources": "{{doc-special|BookSources}}\n\n'''This message shouldn't be changed unless it has serious mistakes.'''\n\nIt's used as the page name of the configuration page of [[Special:BookSources]]. Changing it breaks existing sites using the default version of this message.\n\nSee also:\n* {{msg-mw|Booksources|title}}\n* {{msg-mw|Booksources-text|text}}",
        "booksources-summary": "{{doc-specialpagesummary|booksources}}",
        "booksources-search-legend": "Box heading on [[Special:BookSources|book sources]] special page. The box is for searching for places where a particular book can be bought or viewed.",
        "mw-widgets-titleinput-description-new-page": "Description label for a new page in the title input widget.",
        "mw-widgets-titleinput-description-redirect": "Description label for a redirect in the title input widget.",
        "api-error-blacklisted": "Used as error message.\n\nFollowed by the link {{msg-mw|Mwe-upwiz-feedback-blacklist-info-prompt}}.",
+       "sessionmanager-tie": "Used as an error message when multiple session sources are tied in priority.\n\nParameters:\n* $1 - List of dession type descriptions, from messages like {{msg-mw|sessionprovider-mediawiki-session-cookiesessionprovider}}.",
+       "sessionprovider-generic": "Used to create a generic session type description when one isn't provided via the proper message. Should be phrased to make sense when added to a message such as {{msg-mw|cannotloginnow-text}}.\n\nParameters:\n* $1 - PHP classname.",
+       "sessionprovider-mediawiki-session-cookiesessionprovider": "Description of the sessions provided by the CookieSessionProvider class, which use HTTP cookies. Should be phrased to make sense when added to a message such as {{msg-mw|cannotloginnow-text}}.",
+       "sessionprovider-nocookies": "Used to inform the user that sessions may be missing due to lack of cookies.",
        "randomrootpage": "{{doc-special|RandomRootPage}}"
 }
index d9ebadd..023fe49 100644 (file)
        "querypage-disabled": "Această pagină specială este dezactivată din motive de performanță.",
        "apihelp": "Ajutor API",
        "apihelp-no-such-module": "Modulul „$1” nu a fost găsit.",
+       "apisandbox": "Cutia cu nisip pentru API",
+       "apisandbox-api-disabled": "API este dezactivat pe acest site.",
+       "apisandbox-submit": "Efectuați cererea",
+       "apisandbox-reset": "Curăță",
+       "apisandbox-examples": "Exemplu",
+       "apisandbox-results": "Rezultat",
+       "apisandbox-request-url-label": "URL cerere:",
+       "apisandbox-request-time": "Durata cererii: $1",
        "booksources": "Surse de cărți",
        "booksources-search-legend": "Căutare surse pentru cărți",
        "booksources-search": "Caută",
index 79fa244..1dbfd5f 100644 (file)
        "querypage-disabled": "Sta pàgena speciale jè desabbilitate pe mutive de prestaziune.",
        "apihelp": "Aijute de l'API",
        "apihelp-no-such-module": "Module \"$1\" none acchiate.",
+       "apisandbox": "Sandbox de l'API",
+       "apisandbox-api-disabled": "API non g'è abbiletate sus a stu site.",
+       "apisandbox-intro": "Ause sta pàgene pe sperimendà cu le '''API de le web service pe MediaUicchi'''.\nFà referimende a [//www.mediawiki.org/wiki/API:Main_page 'a documendazione de l'API] pe cchiù dettaglie de l'ause de l'API.\nEsembie: [//www.mediawiki.org/wiki/API#A_simple_example pigghie 'u condenute d'a Pàgene Prengepàle]. Scacchie 'n'azione pe 'ndrucà otre esembie.\n\nVide ca, pure ca queste jè 'na buatte de sabbie tu puè carrescià le cangiaminde de sta pàgene sus 'a uicchi.",
+       "apisandbox-submit": "Fà 'na richieste",
+       "apisandbox-reset": "Pulizze",
+       "apisandbox-examples": "Esembie",
+       "apisandbox-results": "Resultate",
+       "apisandbox-request-url-label": "URL richieste:",
+       "apisandbox-request-time": "Tiembe cercate: $1",
        "booksources": "Sorgende de le libbre",
        "booksources-search-legend": "Cirche pe le fonde de le libbre",
        "booksources-isbn": "ISBN:",
index 4b477dd..393b309 100644 (file)
        "querypage-disabled": "Эта спецстраница отключена для повышения производительности.",
        "apihelp": "Справка по API",
        "apihelp-no-such-module": "Модуль «$1» не найден.",
+       "apisandbox": "Песочница API",
+       "apisandbox-api-disabled": "API отключен на этом сайте.",
+       "apisandbox-intro": "Используйте эту страницу для экспериментов с '''MediaWiki API'''.\nОбратитесь к [//www.mediawiki.org/wiki/API:Main_page документации API] для получения дополнительной информации об использовании API. Например, о том, [//www.mediawiki.org/wiki/API#A_simple_example как получить содержание Заглавной страницы]. Выберите действие, чтобы увидеть другие примеры.\nОбратите внимание, что, хотя это и песочница, действия, выполненные на этой странице, могут внести изменения в вики.",
+       "apisandbox-submit": "Сделать запрос",
+       "apisandbox-reset": "Очистить",
+       "apisandbox-examples": "Пример",
+       "apisandbox-results": "Результат",
+       "apisandbox-request-url-label": "URL-адрес запроса:",
+       "apisandbox-request-time": "Время запроса: $1",
        "booksources": "Источники книг",
        "booksources-search-legend": "Поиск информации о книге",
        "booksources-isbn": "ISBN:",
index 2b3f1d2..64b8316 100644 (file)
        "right-managechangetags": "[[Special:Tags|Бэлиэлэри]] билии олоҕуттан ылыы уонна сотуу",
        "right-applychangetags": "Улартыыларгын кытта [[Special:Tags|тиэктэри]] тутун",
        "right-changetags": "Ханнык баҕарар [[Special:Tags|тиэктэри]] биирдиилээн уларытыыларга уонна сурунаал суруйууларыгар эбэри уонна сотору көҥүллээ",
+       "grant-group-other": "Эгэлгэ тэрээһиннэр",
+       "grant-blockusers": "Бэлиэ ааттары хааччахтааһын ууонна хааччаҕын устуу",
+       "grant-createaccount": "Бэлиэтэнии",
+       "grant-createeditmovepage": "Сирэй оҥоруу, тупсарыы уонна аатын уларытыы",
+       "grant-delete": "Сурунаалтан сирэйи, уларытыыны уонна суруктарын сотуу",
+       "grant-editinterface": "MediaWiki аат далыгар уонна тус CSS/JavaScript иһинэн үлэ",
+       "grant-editmycssjs": "Бэйэҥ тус CSS/JavaScript-кын уларытыы",
+       "grant-editmyoptions": "Бэйэҥ туруорууларгын уларытыы",
+       "grant-editmywatchlist": "Кэтиир тиһиликкин уларытыы",
+       "grant-editpage": "Баар сирэйдэри уларытыы",
+       "grant-editprotected": "Көмүскэммит сирэйдэри уларытыы",
+       "grant-highvolume": "Түргэнник элбэҕи уларытыы",
        "newuserlogpage": "Кыттааччылары бэлиэтиир сурунаал",
        "newuserlogpagetext": "Соторутааҕыта бэлиэтэммит кыттааччылар.",
        "rightslog": "Кыттаачы бырааптарын сурунаала",
        "mostrevisions": "Саамай элбэхтик уларытыллыбыт ыстатыйалар",
        "prefixindex": "Мантан саҕаланар (префикстаах) сирэйдэр барыта",
        "prefixindex-namespace": "Сирэй саҕаланыытынан наардаан көрдөрүү ($1 аат далыгар)",
+       "prefixindex-submit": "Көрдөр",
        "prefixindex-strip": "Түмүк тиһигэр префиксы көрдөрүмэ",
        "shortpages": "Кылгас ыстатыйалар",
        "longpages": "Уһун ыстатыйалар",
        "protectedpages-performer": "Кытааччы көмүскээһинэ",
        "protectedpages-params": "Көмүскээһин кээмэйдэрэ",
        "protectedpages-reason": "Төрүөтэ",
+       "protectedpages-submit": "Сирэйдэри көрдөр",
        "protectedpages-unknown-timestamp": "Биллибэт",
        "protectedpages-unknown-performer": "Биллибэт кыттааччы",
        "protectedtitles": "Көмүскэммит ааттар",
        "protectedtitles-summary": "Манна бобуллубут сирэйдэр ааттара сурулуннулар. Билигин көмүскэммит сирэйдэр тиһиктэрин манна көрүөххэ сөп: [[{{#special:ProtectedPages}}|{{int:protectedpages}}]].",
        "protectedtitlesempty": "Биир да аат бу параметрдарынан көмүскэммэт",
+       "protectedtitles-submit": "Ааттарын көрдөр",
        "listusers": "Кыттааччылар испииһэктэрэ",
        "listusers-editsonly": "Саатар биир көннөрүүнү оҥорбут кыттааччылары көрдөр",
        "listusers-creationsort": "Айыллыбыт күнүнэн наардаа",
        "usereditcount": "$1 {{PLURAL:$1|көннөрүү|көннөрүү}}",
        "usercreated": "Баччаҕа {{GENDER:$3|бэлиэтэммит}} $1,  $2",
        "newpages": "Саҥа ыстатыйалар",
+       "newpages-submit": "Көрдөр",
        "newpages-username": "Кыттааччы:",
        "ancientpages": "Бүтэһик уларытыы киирбитинэн наардаммыт ыстатыйалар",
        "move": "Аатын уларыт",
        "specialloguserlabel": "Толорооччу:",
        "speciallogtitlelabel": "Сыал (тиэкис эбэтэр {{ns:user}}:кыттааччы аата):",
        "log": "Сурунааллар",
+       "logeventslist-submit": "Көрдөрүү",
        "all-logs-page": "Көстөр сурунааллар барыта",
        "alllogstext": "{{SITENAME}} сурунаалларын уопсай испииһэгэ.\nСурунаал көрүҥүнэн, кыттааччы аатынан (улахан-кыра буукубата учуоттанар) эбэтэр сирэй аатынан (эмиэ улахана-кырата учуоттанар) наардыаххытын сөп.",
        "logempty": "Сурунаалга сөп түбэһэр элэмиэннэр суохтар.",
        "log-title-wildcard": "Бу сурук бэлиэлэриттэн (буукубалартан) саҕаланар ааттары бул",
        "showhideselectedlogentries": "Талыллыбыт суруктары кистээ/көрдөр",
        "log-edit-tags": "Сурунаалтан талбыт суругуҥ тиэгин уларыт",
+       "checkbox-select": "Талыы: $1",
+       "checkbox-all": "Бары (барыта)",
+       "checkbox-none": "Суох",
        "allpages": "Сирэйдэр барыта",
        "nextpage": "Аныгыскы сирэй ($1)",
        "prevpage": "Бу иннинээҕи сирэй ($1)",
index 79f99b3..4fd5ef6 100644 (file)
        "querypage-disabled": "Sta pàggina spiciali fu disattivata pi mutivi di pristazzioni.",
        "apihelp": "Guida a l'API",
        "apihelp-no-such-module": "Mòdulu «$1» nun attruvatu.",
+       "apisandbox": "Pàggina di prova API",
+       "apisandbox-submit": "Addumanna",
        "booksources": "Fonti libbrarî",
        "booksources-search-legend": "Arricerca di fonti libbrarî",
        "booksources-isbn": "Còdici ISBN:",
index 4cc1883..a85e4da 100644 (file)
        "querypage-disabled": "Ta posebna stran je onemogočena iz zmogljivostnih razlogov.",
        "apihelp": "Pomoč za API",
        "apihelp-no-such-module": "Modula »$1« nismo našli.",
+       "apisandbox": "Peskovnik API",
+       "apisandbox-api-disabled": "API je onemogočen na tej spletni strani.",
+       "apisandbox-intro": "Uporabite to stran za preizkušanje '''API spletnih storitev MediaWiki'''.\nOglejte si [//www.mediawiki.org/wiki/API:Main_page dokumentacijo API] za nadaljnje podrobnosti o uporabi API. Primer: [//www.mediawiki.org/wiki/API#A_simple_example pridobi vsebino Glavne strani]. Izberite dejanje, da si ogledate več primerov.\n\nPomnite, da čeprav je to peskovnik, bodo dejanja, izvedena na tej strani, morda spremenila wiki.",
+       "apisandbox-submit": "Izvedi zahtevo",
+       "apisandbox-reset": "Počisti",
+       "apisandbox-examples": "Primer",
+       "apisandbox-results": "Rezultat",
+       "apisandbox-request-url-label": "URL zahteve:",
+       "apisandbox-request-time": "Trajanje zahteve: $1",
        "booksources": "Viri knjig",
        "booksources-search-legend": "Išči knjižne vire",
        "booksources-search": "Išči",
index d214644..3c84b4f 100644 (file)
        "recentchanges-page-added-to-category-bundled": "[[:$1]] и још {{PLURAL:$2|једна страница|$2 странице}} су додате у категорију",
        "recentchanges-page-removed-from-category": "[[:$1]] је уклоњена из категорије",
        "recentchanges-page-removed-from-category-bundled": "[[:$1]] и још {{PLURAL:$2|једна страница|$2 странице}} су уклоњене из категорије",
+       "autochange-username": "Медијавики аутоматска измена",
        "upload": "Пошаљи датотеку",
        "uploadbtn": "Пошаљи датотеку",
        "reuploaddesc": "Назад на образац за отпремање",
        "protectedpages-performer": "Заштитио",
        "protectedpages-params": "Ниво заштите",
        "protectedpages-reason": "Разлог",
+       "protectedpages-submit": "Прикажи странице",
        "protectedpages-unknown-timestamp": "нема",
        "protectedpages-unknown-performer": "нема",
        "protectedtitles": "Заштићени наслови",
        "protectedtitles-summary": "На овој страници се налази списак тренутно заштићених наслова. За списак тренутно заштићених страница види [[{{#special:ProtectedPages}}|{{int:protectedpages}}]].",
        "protectedtitlesempty": "Нема заштићених наслова с овим параметрима.",
+       "protectedtitles-submit": "Прикажи наслове",
        "listusers": "Списак корисника",
        "listusers-editsonly": "Прикажи само кориснике који су уређивали",
        "listusers-creationsort": "Поређај по датуму стварања",
        "querypage-disabled": "Ова посебна страница је онемогућена ради побољшања перформанси.",
        "apihelp": "API помоћ",
        "apihelp-no-such-module": "Модул „$1“ није пронађен.",
+       "apisandbox": "API песак",
+       "apisandbox-api-disabled": "АПИ је онемогућен на овом сајту.",
+       "apisandbox-submit": "Постави захтев",
+       "apisandbox-reset": "Очисти",
+       "apisandbox-results": "Резултат",
+       "apisandbox-request-url-label": "Адреса захтева:",
        "booksources": "Штампани извори",
        "booksources-search-legend": "Тражи књижевне изворе",
        "booksources-isbn": "ISBN:",
index 3140260..1a15cb1 100644 (file)
@@ -28,6 +28,7 @@
        "tog-hideminor": "Sakrij manje izmene u spisku skorašnjih izmena",
        "tog-hidepatrolled": "Sakrij patrolirane izmene u spisku skorašnjih izmena",
        "tog-newpageshidepatrolled": "Sakrij patrolirane stranice sa spiska novih stranica",
+       "tog-hidecategorization": "Sakrij kategorizaciju stranica",
        "tog-extendwatchlist": "Proširi spisak nadgledanja za prikaz svih izmena, ne samo skorašnjih",
        "tog-usenewrc": "Grupni prikaz izmena svake pojedinačne stranice u skorašnjim izmenama i spisku nadgledanja",
        "tog-numberheadings": "Automatski numeriši podnaslove",
@@ -57,6 +58,7 @@
        "tog-watchlisthideliu": "Sakrij izmene prijavljenih korisnika sa spiska nadgledanja",
        "tog-watchlisthideanons": "Sakrij izmene anonimnih korisnika sa spiska nadgledanja",
        "tog-watchlisthidepatrolled": "Sakrij patrolirane izmene sa spiska nadgledanja",
+       "tog-watchlisthidecategorization": "Sakrij kategorizaciju stranica",
        "tog-ccmeonemails": "Pošalji mi primerke e-poruka koje pošaljem drugim korisnicima",
        "tog-diffonly": "Ne prikazuj sadržaj stranice ispod razlika",
        "tog-showhiddencats": "Prikaži skrivene kategorije",
        "createacct-benefit-body2": "{{PLURAL:$1|stranica|stranice}}",
        "createacct-benefit-body3": "{{PLURAL:$1|aktivni korisnik|aktivnih korisnika}}",
        "badretype": "Unete lozinke se ne poklapaju.",
+       "usernameinprogress": "Nalog za ovo korisničko ime se već pravi, molimo sačekajte.",
        "userexists": "Korisničko ime je zauzeto. Izaberite drugo.",
        "loginerror": "Greška pri prijavljivanju",
        "createacct-error": "Došlo je do greške pri otvaranju naloga",
        "passwordreset-emailerror-capture": "E-poruka za resetovanje lozinke, prikazana ispod je poslata, ali slanje {{GENDER:$2|korisniku|korisnici}} nije uspelo: $1",
        "changeemail": "Promeni ili ukloni e-adresu",
        "changeemail-header": "Promenite e-adresu naloga",
+       "changeemail-passwordrequired": "Morate uneti lozinku da bi potvrdili ovu izmenu.",
        "changeemail-no-info": "Morate biti prijavljeni da biste pristupili ovoj stranici.",
        "changeemail-oldemail": "Trenutna e-adresa:",
        "changeemail-newemail": "Nova e-adresa:",
        "permissionserrorstext-withaction": "Nemate dozvolu za $2 iz {{PLURAL:$1|sledećeg|sledećih}} razloga:",
        "recreate-moveddeleted-warn": "<strong>Upozorenje: ponovo pravite stranicu koja je prethodno obrisana.</strong>\n\nRazmotrite da li je prikladno da nastavite s uređivanjem ove stranice.\nOvde je navedena istorija brisanja i premeštanja s obrazloženjem:",
        "moveddeleted-notice": "Ova stranica je obrisana.\nIstorija njenog brisanja i premeštanja nalazi se ispod:",
+       "moveddeleted-notice-recent": "Žao nam je, ova stranica je nedavno obrisana (u poslednjih 24 sata).\nOvde je navedena istorija brisanja i premeštanja s obrazloženjem.",
        "log-fulllog": "Pogledaj celu istoriju",
        "edit-hook-aborted": "Izmenu je prekinula kuka.\nNije dato nikakvo obrazloženje.",
        "edit-gone-missing": "Ne mogu da ažuriram stranicu.\nIzgleda da je obrisana.",
        "columns": "Kolona",
        "searchresultshead": "Pretraga",
        "stub-threshold": "Prag za oblikovanje veze kao klice ($1):",
+       "stub-threshold-sample-link": "primer",
        "stub-threshold-disabled": "Onemogućeno",
        "recentchangesdays": "Broj dana u skorašnjim izmenama:",
        "recentchangesdays-max": "Najviše $1 {{PLURAL:$1|dan|dana}}",
        "prefs-help-recentchangescount": "Podrazumeva skorašnje izmene, istorije stranica i dnevnike.",
        "prefs-help-watchlist-token2": "Ovo je tajni ključ za veb-dovod Vašeg spiska nadgledanja. \nSvako ko zna ovaj ključ biće u mogućnosti da vidi Vaša nadgledanja; stoga, ključ nemojte odavati nikome. \nAko je potrebno, ključ možete [[Special:ResetTokens|resetovati]].",
        "savedprefs": "Vaša podešavanja su sačuvana.",
+       "savedrights": "Korisnička prava za {{GENDER:$1|$1}} su sačuvana.",
        "timezonelegend": "Vremenska zona:",
        "localtime": "Lokalno vreme:",
        "timezoneuseserverdefault": "podrazumevane vrednosti ($1)",
        "rcshowhidemine": "$1 moje izmene",
        "rcshowhidemine-show": "Prikaži",
        "rcshowhidemine-hide": "Sakrij",
+       "rcshowhidecategorization": "$1 kategorizaciju stranica",
+       "rcshowhidecategorization-show": "Prikaži",
+       "rcshowhidecategorization-hide": "Sakrij",
        "rclinks": "Prikaži poslednjih $1 izmena {{PLURAL:$2|prethodni dan|u poslednja $2 dana|u poslednjih $2 dana}}<br />$3",
        "diff": "razl",
        "hist": "ist",
        "recentchangeslinked-summary": "Ova posebna stranica prikazuje spisak poslednjih izmena na stranicama koje su povezane (ili članovi određene kategorije).\nStranice s [[Special:Watchlist|vašeg spiska nadgledanja]] su '''podebljane'''.",
        "recentchangeslinked-page": "Naziv stranice:",
        "recentchangeslinked-to": "Prikaži izmene stranica koje su povezane s datom stranicom",
+       "recentchanges-page-added-to-category": "[[:$1]] je dodata u kategoriju",
+       "recentchanges-page-added-to-category-bundled": "[[:$1]] i još {{PLURAL:$2|jedna stranica|$2 stranice}} su dodate u kategoriju",
+       "recentchanges-page-removed-from-category": "[[:$1]] je uklonjena iz kategorije",
+       "recentchanges-page-removed-from-category-bundled": "[[:$1]] i još {{PLURAL:$2|jedna stranica|$2 stranice}} su uklonjene iz kategorije",
        "upload": "Pošalji datoteku",
        "uploadbtn": "Pošalji datoteku",
        "reuploaddesc": "Nazad na obrazac za otpremanje",
        "upload-too-many-redirects": "Adresa sadrži previše preusmerenja",
        "upload-http-error": "Došlo je do HTTP greške: $1",
        "upload-copy-upload-invalid-domain": "Primerci otpremanja nisu dostupni na ovom domenu.",
+       "upload-dialog-button-cancel": "Otkaži",
+       "upload-dialog-button-done": "Gotovo",
+       "upload-dialog-button-save": "Sačuvaj",
+       "upload-dialog-button-upload": "Pošalji",
+       "upload-form-label-select-file": "Izaberi datoteku",
+       "upload-form-label-infoform-title": "Detalji",
+       "upload-form-label-infoform-name": "Ime",
+       "upload-form-label-infoform-description": "Opis",
+       "upload-form-label-usage-filename": "Naziv datoteke",
+       "foreign-structured-upload-form-label-own-work": "Ovo je moje sopstveno delo",
+       "foreign-structured-upload-form-label-infoform-categories": "Kategorije",
+       "foreign-structured-upload-form-label-infoform-date": "Datum",
        "backend-fail-stream": "Ne mogu da emitujem datoteku $1.",
        "backend-fail-backup": "Ne mogu da napravim rezervu datoteke $1.",
        "backend-fail-notexists": "Datoteka $1 ne postoji.",
        "querypage-disabled": "Ova posebna stranica je onemogućena radi poboljšanja performansi.",
        "apihelp": "API pomoć",
        "apihelp-no-such-module": "Modul „$1“ nije pronađen.",
+       "apisandbox": "API pesak",
+       "apisandbox-api-disabled": "API je onemogućen na ovom sajtu.",
+       "apisandbox-submit": "Postavi zahtev",
+       "apisandbox-results": "Rezultat",
+       "apisandbox-request-url-label": "Adresa zahteva:",
        "booksources": "Štampani izvori",
        "booksources-search-legend": "Traži književne izvore",
        "booksources-isbn": "ISBN:",
        "logempty": "Nema pronađenih unosa u dnevniku.",
        "log-title-wildcard": "Traži naslove koji počinju s ovim tekstom",
        "showhideselectedlogentries": "Prikaži/sakrij izabrane događaje",
+       "log-edit-tags": "Uredi oznake izabranih unosa u dnevnicima",
        "allpages": "Sve stranice",
        "nextpage": "Sledeća stranica ($1)",
        "prevpage": "Prethodna stranica ($1)",
        "blockipsuccesstext": "[[Special:Contributions/$1|$1]] je {{GENDER:$1|blokiran|blokirana|blokiran}}.<br />\nBlokiranja možete da pogledate [[Special:BlockList|ovde]].",
        "ipb-blockingself": "Ovom radnjom ćete blokirati sebe! Jeste li sigurni da to želite?",
        "ipb-confirmhideuser": "Upravo ćete blokirati korisnika s uključenom mogućnošću „sakrij korisnika“. Ovim će korisničko ime biti sakriveno u svim spiskovima i izveštajima. Želite li to da uradite?",
+       "ipb-confirmaction": "Ako ste sigurni da želite nastaviti označite polje „{{int:ipb-confirm}}“ na dnu stranice.",
        "ipb-edit-dropdown": "Uredi razloge blokiranja",
        "ipb-unblock-addr": "Deblokiraj $1",
        "ipb-unblock": "Deblokiraj korisničko ime ili IP adresu",
        "import-interwiki-history": "Kopiraj sve verzije istorije za ovu stranicu",
        "import-interwiki-templates": "Uključi sve šablone",
        "import-interwiki-submit": "Uvezi",
+       "import-mapping-default": "Isto kao i izvorne stranice",
        "import-mapping-namespace": "Uvezi u imenski prostor:",
+       "import-mapping-subpage": "Uvezi kao podstranice sledeće stranice:",
        "import-upload-filename": "Naziv datoteke:",
        "import-comment": "Komentar:",
        "importtext": "Izvezite datoteku s izvornog vikija koristeći [[Special:Export|izvoz]].\nSačuvajte je na računar i pošaljite ovde.",
        "patrol-log-page": "Dnevnik patroliranja",
        "patrol-log-header": "Ovo je dnevnik patroliranih izmena.",
        "log-show-hide-patrol": "$1 dnevnik patroliranja",
+       "log-show-hide-tag": "$1 dnevnik oznaka",
        "deletedrevision": "Obrisana stara izmena $1.",
        "filedeleteerror-short": "Greška pri brisanju datoteke: $1",
        "filedeleteerror-long": "Došlo je do grešaka pri brisanju datoteke:\n\n$1",
        "svg-long-error": "Neispravna SVG datoteka: $1",
        "show-big-image": "Puna veličina",
        "show-big-image-preview": "Veličina ovog prikaza: $1.",
+       "show-big-image-preview-differ": "Veličina ovog $3 pregleda za ovu $2 datoteku je $1.",
        "show-big-image-other": "{{PLURAL:$2|Druga rezolucija|Druge rezolucije}}: $1.",
        "show-big-image-size": "$1 × $2 piksela",
        "file-info-gif-looped": "petlja",
        "signature": "[[{{ns:user}}:$1|$2]] ([[{{ns:user_talk}}:$1|razgovor]])",
        "timezone-utc": "UTC",
        "duplicate-defaultsort": "'''Upozorenje:''' podrazumevani ključ svrstavanja „$2“ menja nekadašnji ključ „$1“.",
+       "duplicate-displaytitle": "<strong>Upozorenje:</strong> naslov za prikaz „$2“ zameniće postojeći „$1“.",
        "version": "Verzija",
        "version-extensions": "Instalirana proširenja",
        "version-skins": "Instalirane teme",
        "version-libraries": "Instalirane biblioteke",
        "version-libraries-library": "Biblioteka",
        "version-libraries-version": "Verzija",
+       "version-libraries-license": "Licenca",
+       "version-libraries-description": "Opis",
+       "version-libraries-authors": "Autori",
        "redirect": "Preusmerenje na datoteku, korisnika, stranicu ili izmenu",
        "redirect-legend": "Preusmeri na datoteku ili stranicu",
        "redirect-submit": "Idi",
        "tags-actions-header": "Radnje",
        "tags-active-yes": "Da",
        "tags-active-no": "Ne",
+       "tags-source-extension": "Deo ekstenzije",
+       "tags-source-manual": "Ručno je dodaju korisnici i botovi",
        "tags-source-none": "Van upotrebe",
        "tags-edit": "uredi",
        "tags-delete": "obriši",
        "tags-hitcount": "$1 {{PLURAL:$1|izmena|izmene|izmena}}",
        "tags-manage-no-permission": "Nemate dozvolu da menjate oznake.",
        "tags-create-heading": "Nova oznaka",
+       "tags-create-explanation": "Po podrazumevanim podešavanjima nove oznake moći će da koriste korisnici i botovi.",
        "tags-create-tag-name": "Naziv oznake:",
        "tags-create-reason": "Razlog:",
        "tags-create-submit": "Napravi",
        "tags-create-warnings-below": "Pravite novu oznaku, želite li da nastavite?",
        "tags-delete-title": "Brisanje oznaka",
        "tags-delete-explanation-initial": "Brišete oznaku „$1“ iz baze podataka.",
+       "tags-delete-explanation-warning": "Ova radnja je <strong>nepovratna</strong> i <strong>ne može se poništiti</strong>, čak ni administratori baze podataka je ne mogu poništiti. Budite sigurni da je ovo oznaka koju želite obrisati.",
        "tags-delete-reason": "Razlog:",
        "tags-delete-submit": "Nepovratno obriši ovu oznaku",
        "tags-delete-not-found": "Oznaka „$1“ ne postoji.",
        "tags-deactivate-reason": "Razlog:",
        "tags-deactivate-not-allowed": "Nije moguće deaktivirati oznaku „$1“.",
        "tags-deactivate-submit": "Dekativiraj",
+       "tags-edit-title": "Uredi oznake",
        "tags-edit-existing-tags": "Postojeće oznake:",
        "tags-edit-new-tags": "Nove oznake:",
        "tags-edit-reason": "Razlog:",
        "htmlform-cloner-create": "Dodaj još",
        "htmlform-cloner-delete": "Ukloni",
        "htmlform-cloner-required": "Bar jedna vrednost je potrebna.",
+       "htmlform-title-not-exists": "$1 ne postoji.",
        "htmlform-user-not-exists": "<strong>$1</strong> ne postoji.",
        "htmlform-user-not-valid": "<strong>$1</strong> nije ispravno korisničko ime.",
        "sqlite-has-fts": "$1 s podrškom pretrage celog teksta",
        "logentry-newusers-create2": "$1 je {{GENDER:$2|otvorio|otvorila}} korisnički nalog $3",
        "logentry-newusers-byemail": "$1 je {{GENDER:$2|otvorio|otvorila}} korisnički nalog $3 i lozinka je poslata na e-poštu",
        "logentry-newusers-autocreate": "Korisnički nalog $1 je automatski {{GENDER:$2|otvoren}}",
+       "logentry-protect-move_prot": "$1 je {{GENDER:$2|premestio|premestila}} postavke zaštite sa $4 na $3",
+       "logentry-protect-unprotect": "$1 je {{GENDER:$2|skinuo|skinula}} zaštitu sa stranice $3",
+       "logentry-protect-protect": "$1 je {{GENDER:$2|zaštitio|zaštitila}} $3 $4",
+       "logentry-protect-protect-cascade": "$1 je {{GENDER:$2|zaštitio|zaštitila}} $3 $4 [prenosiva zaštita]",
+       "logentry-protect-modify": "$1 je {{GENDER:$2|promenio|promenila}} stepen zaštite za $3 $4",
+       "logentry-protect-modify-cascade": "$1 je {{GENDER:$2|promenio|promenila}} stepen zaštite za $3 $4 [prenosiva zaštita]",
        "logentry-rights-rights": "$1 je {{GENDER:$2|promenio|promenila}} članstvo grupe za $3 iz $4 u $5",
        "logentry-rights-rights-legacy": "$1 je {{GENDER:$2|promenio|promenila}} čalnstvo grupe za $3",
        "logentry-rights-autopromote": "$1 je automatski {{GENDER:$1|unapređen|unapređena}} iz $4 u $5",
        "logentry-managetags-delete": "$1 je {{GENDER:$2|obrisao|obrisala}} oznaku „$4“ (uklonjena je iz $5 {{PLURAL:$5|izmene ili dnevnika|izmena i/ili dnevnika}})",
        "logentry-managetags-activate": "$1 je {{GENDER:$2|aktivirao|aktivirala}} oznaku „$4“ za upotrebu od strane korisnika i botova",
        "logentry-managetags-deactivate": "$1 je {{GENDER:$2|deaktivirao|deaktivirala}} oznaku „$4“ za upotrebu od strane korisnika i botova",
+       "log-name-tag": "Dnevnik oznaka",
        "rightsnone": "(nema)",
        "revdelete-summary": "opis izmene",
        "feedback-adding": "Dodajem povratnu informaciju na stranicu…",
index 4d34682..11f7a75 100644 (file)
        "querypage-disabled": "Den här specialsidan är inaktiverad av prestandaskäl.",
        "apihelp": "API-hjälp",
        "apihelp-no-such-module": "Modulen ”$1” hittades inte",
+       "apisandbox": "API-sandlåda",
+       "apisandbox-api-disabled": "API är inaktiverat på denna webbplats.",
+       "apisandbox-intro": "Använd den här sidan för att experimentera med '''MediaWikis API för webbtjänster''.\nSe [//www.mediawiki.org/wiki/API:Main_page API-dokumentationen] för ytterligare detaljer kring API-användningen. Exempel: [//www.mediawiki.org/wiki/API#A_simple_example få innehållet från en huvudsida]. Välj en handling för att se fler exempel.\n\nObservera att även om detta är en sandlåda kan handlingar du utför på denna sida påverka wikin.",
+       "apisandbox-submit": "Utför begäran",
+       "apisandbox-reset": "Rensa",
+       "apisandbox-examples": "Exempel",
+       "apisandbox-results": "Resultat",
+       "apisandbox-request-url-label": "Begärd URL:",
+       "apisandbox-request-time": "Tid för begäran: $1",
        "booksources": "Bokkällor",
        "booksources-search-legend": "Sök efter bokkällor",
        "booksources-search": "Sök",
index fb9281e..955f89b 100644 (file)
        "querypage-disabled": "Bu özel sayfa, performansa dayalı nedenlerle devre dışı bırakılır.",
        "apihelp": "API yardımı",
        "apihelp-no-such-module": "\"$1\" modülü bulunamadı.",
+       "apisandbox-submit": "İstek yap",
+       "apisandbox-reset": "Temizle",
+       "apisandbox-examples": "Örnek",
+       "apisandbox-results": "Sonuç",
+       "apisandbox-request-url-label": "İstek URL:",
+       "apisandbox-request-time": "İstek zamanı: $1",
        "booksources": "Kaynak kitaplar",
        "booksources-search-legend": "Kitap kaynaklarını ara",
        "booksources-isbn": "ISBN:",
index e5efa1c..cf5e812 100644 (file)
        "mainpage": "Кол арын",
        "mainpage-description": "Кол арын",
        "policy-url": "Project:Чурум",
-       "portal": "Ниитилелдиң порталы",
-       "portal-url": "Project:Ниитилелдиң порталы",
+       "portal": "Ниитилел хаалгазы",
+       "portal-url": "Project:Ниитилел хаалгазы",
        "privacy": "Актыг дуржулга",
        "privacypage": "Project:Бүзүрел дугуржулгазы",
        "badaccess": "Алдаг:Эргеңер чок.",
index 6c4a2fa..ccd940b 100644 (file)
        "querypage-disabled": "Цю спеціальну сторінку вимкнуто для покращення продуктивності.",
        "apihelp": "Довідка з API",
        "apihelp-no-such-module": "Додаток \"$1\" не знайдено.",
+       "apisandbox": "Майданчик для тестування API",
+       "apisandbox-api-disabled": "API вимкнуто на цьому сайті.",
+       "apisandbox-intro": "Ця сторінка служить для експериментування з '''MediaWiki API'''.\nЗвертайтеся до [//www.mediawiki.org/wiki/API:Main_page документації] для докладнішої інформації про використання API.  Наприклад: [//www.mediawiki.org/wiki/API#A_simple_example як отримати вміст головної сторінки].  Виберіть дію, щоб побачити більше прикладів.\n\nЗверніть увагу, що, хоча це пісочниця, дії, виконані вами, на цій сторінці можуть змінити вікі.",
+       "apisandbox-submit": "Зробити запит",
+       "apisandbox-reset": "Очистити",
+       "apisandbox-examples": "Приклад",
+       "apisandbox-results": "Результат",
+       "apisandbox-request-url-label": "URL-адреса запиту:",
+       "apisandbox-request-time": "Час запиту $1",
        "booksources": "Джерела книг",
        "booksources-search-legend": "Пошук інформації про книгу",
        "booksources-isbn": "ISBN:",
        "log-title-wildcard": "Знайти заголовки, що починаються з цих символів",
        "showhideselectedlogentries": "Показати/приховати виділені записи журналу",
        "log-edit-tags": "Змінити мітки для вибраних записів журналів",
+       "checkbox-all": "Всі",
+       "checkbox-none": "Нічого",
+       "checkbox-invert": "Інвертувати",
        "allpages": "Усі сторінки",
        "nextpage": "Наступна сторінка ($1)",
        "prevpage": "Попередня сторінка ($1)",
index 56a55de..ba19298 100644 (file)
        "pager-older-n": "{{PLURAL:$1|پُرانا 1|پُرانے $1}}",
        "apihelp": "معاونت اے پی آئی",
        "apihelp-no-such-module": "ماڈیول \"$1\" نہیں ملا",
+       "apisandbox-submit": "بنانے کی درخواست",
+       "apisandbox-reset": "واضح",
+       "apisandbox-examples": "مثال کے طور پر",
+       "apisandbox-results": "نتیجہ",
        "booksources": "کتابی وسائل",
        "booksources-search-legend": "تلاش برائے مآخذاتِ کتاب",
        "booksources-search": "تلاش",
index fbcc83e..bff4cf9 100644 (file)
        "querypage-disabled": "Trang đặc biệt này bị tắt vì lý do hiệu suất.",
        "apihelp": "Trợ giúp API",
        "apihelp-no-such-module": "Không tìm thấy mô đun “$1”",
+       "apisandbox": "Chỗ thử API",
+       "apisandbox-api-disabled": "API đã bị vô hiệu hóa trên trang web này.",
+       "apisandbox-intro": "Trang này dùng để thử nghiệm với '''API dịch vụ Web của MediaWiki'''.\nHãy tra cứu [//www.mediawiki.org/wiki/API:Main_page tài liệu API] để biết chi tiết về cách sử dụng API. Ví dụ: [//www.mediawiki.org/wiki/API#A_simple_example lấy nội dung của Trang Chính]. Chọn một tác vụ để xem thêm ví dụ.\n\nLưu ý rằng, mặc dù đây là một chỗ thử, nhưng các tác vụ của bạn tại trang này có thể thực hiện các thay đổi trên wiki.",
+       "apisandbox-submit": "Yêu cầu",
+       "apisandbox-reset": "Tẩy trống",
+       "apisandbox-examples": "Ví dụ",
+       "apisandbox-results": "Kết quả",
+       "apisandbox-request-url-label": "URL của yêu cầu:",
+       "apisandbox-request-time": "Thời gian xử lý: $1",
        "booksources": "Nguồn sách",
        "booksources-search-legend": "Tìm kiếm nguồn sách",
        "booksources-search": "Tìm kiếm",
index 0bbeced..3dda0f6 100644 (file)
        "pager-newer-n": "{{PLURAL:$1|nulikum 1|nulikum $1}}",
        "pager-older-n": "{{PLURAL:$1|büikum 1|büikum $1}}",
        "suppress": "Lovelogam",
+       "apisandbox-examples": "Sam",
        "booksources": "Bukafons",
        "booksources-search-legend": "Sukön bukafonis:",
        "booksources-search": "Sukön",
index 3ad6bf6..3c84b98 100644 (file)
        "databaseerror-function": "函数:$1",
        "databaseerror-error": "错误:$1",
        "transaction-duration-limit-exceeded": "因为写入时间($1)超过了$2{{PLURAL:$2|秒}}的限制,为防止创建大量复制延迟,此次处理已被中止。如果您正在同时更改很多项目,请尝试进行多次小规模操作。",
-       "laggedslavemode": "'''警告:'''页面中可能没有包含最近的更新。",
+       "laggedslavemode": "<strong>警告:</strong>页面中可能没有包含最近的更新。",
        "readonly": "数据库被锁定",
        "enterlockreason": "请输入锁定的原因,这包括预计解除锁定的时间",
        "readonlytext": "数据库当前被锁定,不能添加新条目或进行其他修改,锁定可能是因为例行的数据库维护,完成后即可恢复正常。\n\n锁定数据库的系统管理员做出如下解释:$1",
        "editinginterface": "<strong>警告:</strong>您正在编辑用于提供软件的界面文字的页面。改变此页将影响其他在此wiki上其他用户的用户界面外观。",
        "translateinterface": "要加入或更改所有wiki的翻译,请访问MediaWiki本地化项目网站[//translatewiki.net/ translatewiki.net]。",
        "cascadeprotected": "本页面已经受到保护,不能编辑,因为它被嵌入于以下被“连锁保护”的{{PLURAL:$1|页面}}:\n$2",
-       "namespaceprotected": "您没有权限编辑'''$1'''名字空间内的页面。",
+       "namespaceprotected": "您没有权限编辑<strong>$1</strong>名字空间内的页面。",
        "customcssprotected": "您没有权限编辑此CSS页面,因为它包含另一位用户的个人设置。",
        "customjsprotected": "您没有权限编辑此JavaScript页面,因为它包含另一位用户的个人设置。",
        "mycustomcssprotected": "您没有权限编辑这个 CSS 页面。",
        "mycustomjsprotected": "您没有权限编辑这个 JavaScript 页面。",
-       "myprivateinfoprotected": "你没有权限编辑你的私人信息。",
+       "myprivateinfoprotected": "您没有权限编辑您的私人信息。",
        "mypreferencesprotected": "您没有权限来编辑您的个人设置。",
        "ns-specialprotected": "特殊页面不可编辑。",
-       "titleprotected": "此标题已被[[User:$1|$1]]保护以防止创建。理由是“$2”。",
+       "titleprotected": "此标题已被[[User:$1|$1]]保护以防止创建。理由是“<em>$2</em>”。",
        "filereadonlyerror": "因为媒体库“$2”处于只读模式而无法修改文件“$1”。\n\n锁定数据库的系统管理员做出如下解释:“$3”。",
        "invalidtitle-knownnamespace": "使用名字空间“$2”和文本“$3”的无效标题",
        "invalidtitle-unknownnamespace": "使用未知名字空间编号$1和文本“$2”的无效标题",
        "exception-nologin": "未登录",
        "exception-nologin-text": "请登录以访问此页面或进行操作。",
        "exception-nologin-text-manual": "查看该页面或进行此操作需要您$1。",
-       "virus-badscanner": "错误的配置:未知的病毒扫描程序:''$1''",
+       "virus-badscanner": "错误的配置:未知的病毒扫描程序:<em>$1</em>",
        "virus-scanfailed": "扫描失败(代码 $1)",
        "virus-unknownscanner": "未知的反病毒软件:",
        "logouttext": "<strong>您现在已经退出登录。</strong>\n\n请注意一些页面可能仍然显示您处于登录状态,直到您清空浏览器缓存为止。",
        "welcomeuser": "欢迎,$1!",
-       "welcomecreation-msg": "你的账户已创建。请不要忘记更改你的[[Special:Preferences|{{SITENAME}}设置]]。",
+       "welcomecreation-msg": "您的账户已创建。\n如果需要,您可以更改您在{{SITENAME}}的[[Special:Preferences|参数设置]]。",
        "yourname": "用户名:",
        "userlogin-yourname": "用户名",
        "userlogin-yourname-ph": "请输入你的用户名",
        "showpreview": "显示预览",
        "showdiff": "显示更改",
        "blankarticle": "<strong>警告</strong>:您创建的页面是空白的。如果您再次点击“{{int:savearticle}}”,您将真的创建没有任何内容的页面。",
-       "anoneditwarning": "<strong>警告:</strong>您没有登录。如果您做出任意编辑,您的IP地址将会公开可见。如果您<strong>[$1 登]</strong>或<strong>[$2 创建]</strong>一个账户,您的编辑将归属于您的用户名,且将享受其他好处。",
+       "anoneditwarning": "<strong>警告:</strong>您没有登录。如果您做出任意编辑,您的IP地址将会公开可见。如果您<strong>[$1 登]</strong>或<strong>[$2 创建]</strong>一个账户,您的编辑将归属于您的用户名,且将享受其他好处。",
        "anonpreviewwarning": "<em>您没有登录。保存将您的IP地址记录至此页面的编辑历史中。</em>",
-       "missingsummary": "'''提示:'''你没有提供编辑摘要。如果你再次点击“{{int:savearticle}}”,你的编辑将不带编辑摘要保存。",
+       "missingsummary": "<strong>提示:</strong>您没有提供编辑摘要。如果您再次点击“{{int:savearticle}}”,您的编辑将不带摘要保存。",
        "selfredirect": "<strong>警告:</strong>您正在将此页面重定向至它自己。\n您可能指定了错误的重定向目标,或者您正在编辑错误的页面。\n如果您再次点击“{{int:savearticle}}”,重定向将无论如何被创建。",
        "missingcommenttext": "请在下面输入评论。",
        "missingcommentheader": "<strong>提示:</strong>您还没有为此评论提供一个标题。如果您再次点击“{{int:savearticle}}”,您的编辑将不带标题保存。",
        "subject-preview": "主题预览:",
        "previewerrortext": "尝试预览您的更改时发生未知错误。",
        "blockedtitle": "用户被封禁",
-       "blockedtext": "<strong>你的用户名或IP地址已被封禁。</strong>\n\n执行封禁的管理员是$1。封禁原因是<em>$2</em>。\n\n* 开始时间:$8\n* 到期时间:$6\n* 目标用户:$7\n\n你可以联系$1或其他[[{{MediaWiki:Grouppage-sysop}}|管理员]]讨论该封禁。只有当你在[[Special:Preferences|系统设置]]确认了电子邮件地址且未被禁止使用“电邮联系”功能时,才可以使用它。你当前的IP地址是$3,该封禁ID是#$5。请在你的询问中包含上面的所有信息。",
+       "blockedtext": "<strong>您的用户名或IP地址已被封禁。</strong>\n\n执行封禁的管理员是$1。封禁原因是<em>$2</em>。\n\n* 开始时间:$8\n* 到期时间:$6\n* 目标用户:$7\n\n您可以联络$1或其他[[{{MediaWiki:Grouppage-sysop}}|管理员]]讨论该封禁。只有当您在[[Special:Preferences|系统设置]]确认了电子邮件地址且未被禁止使用“电邮联系”功能时,才可以使用它。您当前的IP地址是$3,该封禁ID是#$5。请在您的询问中包含上面的所有信息。",
        "autoblockedtext": "您的IP地址因曾被一位被$1封禁的用户使用而被自动封禁。封禁原因:\n\n:<em>$2</em>\n\n* 开始时间:$8\n* 到期时间:$6\n* 目标用户:$7\n\n您可以联系$1或其他[[{{MediaWiki:Grouppage-sysop}}|管理员]]申诉该封禁。\n\n请注意,只有当您已在[[Special:Preferences|系统设置]]确认了电子邮件地址且未被禁止使用“电邮联系”功能时,才能发送电子邮件联系管理员。\n\n您当前的IP地址为$3,该封禁ID为#$5。\n请您在申诉内容中说明以上所有信息。",
        "blockednoreason": "未给出原因",
        "whitelistedittext": "请$1以编辑页面。",
        "accmailtext": "为[[User talk:$1|$1]]随机生成的密码已送至$2。登录后可以在<em>[[Special:ChangePassword|更改密码]]</em>页面中修改。",
        "newarticle": "(新页面)",
        "newarticletext": "您点击了一个尚不存在的页面的链接。要创建该页面,请在下面的编辑框中输入内容(更多信息请见[$1 帮助页面])。如果您是错误地进入了此页面,请点击您的浏览器的<strong>返回</strong>按钮。",
-       "anontalkpagetext": "---- ''这是一个还未建立账户的匿名用户的讨论页, 因此我们只能用IP地址来与他或她联络。该IP地址可能由几名用户共享。如果您是一名匿名用户并认为此页上的评语与您无关,请[[Special:UserLogin/signup|创建新账户]]或[[Special:UserLogin|登录]]以避免在未来与其他匿名用户混淆。''",
-       "noarticletext": "本页面目前没有内容。可以在其他页面中[[Special:Search/{{PAGENAME}}|搜索本页标题]]、<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} 搜索相关日志]或[{{fullurl:{{FULLPAGENAME}}|action=edit}} 编辑本页面]。</span>",
-       "noarticletext-nopermission": "本页面目前没有内容。你可以在其他页面中[[Special:Search/{{PAGENAME}}|搜索本页标题]]或<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} 搜索相关日志]</span>,但你没有权限创建本页面。",
+       "anontalkpagetext": "----\n<em>这是一个还未建立账户的匿名用户的讨论页, 因此我们只能用IP地址来与他或她联络。</em>该IP地址可能由几名用户共享。如果您是一名匿名用户并认为此页上的评语与您无关,请[[Special:UserLogin/signup|创建新账户]]或[[Special:UserLogin|登录]]以避免在未来与其他匿名用户混淆。",
+       "noarticletext": "本页面目前没有内容。可以在其他页面中[[Special:Search/{{PAGENAME}}|搜索本页标题]]、<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} 搜索相关日志]或[{{fullurl:{{FULLPAGENAME}}|action=edit}} 编辑本页面]。</span>",
+       "noarticletext-nopermission": "本页面目前没有内容。您可以在其他页面中[[Special:Search/{{PAGENAME}}|搜索本页标题]]或<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} 搜索相关日志]</span>,但您没有权限创建本页面。",
        "missing-revision": "“{{FULLPAGENAME}}”的版本#$1不存在。\n\n这通常是因为进入了一个已被删除的页面的历史链接。\n详细信息可以在[{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} 删除日志]中找到。",
        "userpage-userdoesnotexist": "用户账户“$1”没有注册。请在创建/编辑本页前检查。",
        "userpage-userdoesnotexist-view": "用户账户“$1”没有被注册。",
        "clearyourcache": "<strong>注意:</strong>在保存之后,您可能需要清除浏览器缓存才能看到所作出的变更的影响。\n* <strong>Firefox或Safari:</strong>按住“Shift”的同时单击“刷新”,或按“Ctrl-F5”或“Ctrl-R”(Mac为“⌘-R”)\n* <strong>Google Chrome:</strong>按“Ctrl-Shift-R”(Mac为“⌘-Shift-R”)\n* <strong>Internet Explorer:</strong>按住“Ctrl”的同时单击“刷新”,或按“Ctrl-F5”\n* <strong>Opera:</strong>在“工具→首选项”中清除缓存",
        "usercssyoucanpreview": "<strong>提示:</strong>在保存前请用“{{int:showpreview}}”按钮来测试您新的 CSS 。",
        "userjsyoucanpreview": "<strong>提示:</strong>在保存前请用“{{int:showpreview}}”按钮来测试您新的 JavaScript 。",
-       "usercsspreview": "'''请记住你现在只是在预览你的用户CSS。它尚未保存!'''",
-       "userjspreview": "'''请记住你现在只是在测试/预览你的用户JavaScript。它尚未保存!'''",
-       "sitecsspreview": "'''请记住你现在只是在预览该CSS。它尚未保存!'''",
-       "sitejspreview": "'''请记住你现在只是在预览该JavaScript代码。它尚未保存!'''",
+       "usercsspreview": "<strong>请记住您现在只是在预览你的用户CSS。它尚未保存!</strong>",
+       "userjspreview": "<strong>请记住你现在只是在测试/预览你的用户JavaScript。它尚未保存!</strong>",
+       "sitecsspreview": "<strong>请记住你现在只是在预览该CSS。它尚未保存!</strong>",
+       "sitejspreview": "<strong>请记住你现在只是在预览该JavaScript代码。它尚未保存!</strong>",
        "userinvalidcssjstitle": "<strong>警告:</strong>不存在皮肤“$1”。注意自定义的 .css 和 .js 页要使用小写标题,例如,{{ns:user}}:Foo/vector.css 不同于 {{ns:user}}:Foo/Vector.css。",
        "updated": "(已更新)",
-       "note": "'''注意:'''",
-       "previewnote": "'''请记住这只是预览。'''你的更改还没有保存!",
+       "note": "<strong>注意:</strong>",
+       "previewnote": "<strong>请记住这只是预览。</strong>\n您的更改还没有保存!",
        "continue-editing": "前往编辑区",
        "previewconflict": "该预览反映了上面文字编辑区中的文字在你保存后的显示状况。",
-       "session_fail_preview": "<strong>对不起!由于会话数据丢失,我们无法处理的编辑。</strong>\n请重试。如果仍然失败,请尝试[[Special:UserLogout|退出登录]]后重新登录。",
-       "session_fail_preview_html": "<strong>对不起!由于会话数据丢失,我们无法处理的编辑。</strong>\n\n<em>因为{{SITENAME}}已启用原始HTML,为了预防JavaScript攻击,预览被隐藏。</em>\n\n<strong>如果该编辑尝试合法,请重试。</strong>如果仍然失败,请尝试[[Special:UserLogout|退出登录]]后重新登录。",
+       "session_fail_preview": "<strong>对不起!由于会话数据丢失,我们无法处理的编辑。</strong>\n请重试。如果仍然失败,请尝试[[Special:UserLogout|退出登录]]后重新登录。",
+       "session_fail_preview_html": "<strong>对不起!由于会话数据丢失,我们无法处理的编辑。</strong>\n\n<em>因为{{SITENAME}}已启用原始HTML,为了预防JavaScript攻击,预览被隐藏。</em>\n\n<strong>如果该编辑尝试合法,请重试。</strong>如果仍然失败,请尝试[[Special:UserLogout|退出登录]]后重新登录。",
        "token_suffix_mismatch": "<strong>由于您客户端中的编辑令牌毁损了一些标点符号字符,您的编辑已经被拒绝。</strong>\n此次编辑被拒绝以防止页面文本损坏。\n这种情况通常在您使用含有故障的网页式匿名代理服务的时候出现。",
-       "edit_form_incomplete": "'''编辑表格的某些部分没有到达服务器,请检查你的编辑是否完整并重试。'''",
+       "edit_form_incomplete": "<strong>编辑表格的某些部分没有到达服务器,请检查您的编辑是否完整并重试。</strong>",
        "editing": "编辑“$1”",
        "creating": "创建“$1”",
        "editingsection": "编辑“$1(段落)”",
        "explainconflict": "其他用户在你开始编辑后更改了该页面。上面的文字区含有该页面当前的文字。下面的文字区显示你的更改。你必须把你的更改合并至现有文字。'''只有'''当你单击“{{int:savearticle}}”后,上面的文字区中的文字才会被保存。",
        "yourtext": "您的文字",
        "storedversion": "已保存的版本",
-       "nonunicodebrowser": "'''警告:您的浏览器不兼容Unicode编码。'''这里有一个工作区将使您能安全地编辑页面:非ASCII字符将以十六进制编码方式出现在编辑框中。",
-       "editingold": "'''警告:你正在编辑的是本页面的旧版本。'''如果你保存该编辑,该版本后的所有更改都会丢失。",
+       "nonunicodebrowser": "<strong>警告:您的浏览器不兼容Unicode编码。</strong>这里有一个工作区将使您能安全地编辑页面:非ASCII字符将以十六进制编码方式出现在编辑框中。",
+       "editingold": "<strong>警告:您正在编辑的是本页面的旧版本。</strong>如果您保存该编辑,该版本后的所有更改都会丢失。",
        "yourdiff": "差异",
        "copyrightwarning": "请注意您对{{SITENAME}}的所有贡献都被认为是在$2下发布,请查看在$1的细节。\n如果您不希望您的文字被任意修改和再散布,请不要提交。<br />\n您同时也要向我们保证您所提交的内容是您自己所作,或得自一个不受版权保护或相似自由的来源。\n'''不要在未获授权的情况下发表!'''<br />",
-       "copyrightwarning2": "请注意,您对{{SITENAME}}的所有贡献都可能被其他贡献者编辑,修改或删除。如果您不希望您的文字被任意修改和再散布,请不要提交。<br />\n您同时也要向我们保证您所提交的内容是您自己所作,或得自一个不受版权保护或相似自由的来源(参阅$1的细节)。'''不要在未获授权的情况下发表!'''",
+       "copyrightwarning2": "请注意,您对{{SITENAME}}的所有贡献都可能被其他贡献者编辑,修改或删除。如果您不希望您的文字被任意修改和再散布,请不要提交。<br />\n您同时也要向我们保证您所提交的内容是您自己所作,或得自一个不受版权保护或相似自由的来源(参阅$1的细节)。<strong>不要在未获授权的情况下发表!</strong>",
        "editpage-cannot-use-custom-model": "此页面的内容模型不能被更改。",
-       "longpageerror": "'''错误:您所提交的文本长度有{{PLURAL:$1|1|$1}}KB,这大于{{PLURAL:$2|1|$2}}KB的最大值。'''\n因此,该文本无法保存。",
+       "longpageerror": "<strong>错误:您所提交的文本长度有{{PLURAL:$1|1|$1}}KB,这大于{{PLURAL:$2|1|$2}}KB的最大值。</strong>\n因此,该文本无法保存。",
        "readonlywarning": "<strong>警告:数据库被锁定以进行维护,所以您目前将无法保存您的编辑。</strong>您可以将您的文本复制粘贴到一个文本文档并保存它,以便稍后更改。\n\n锁定数据库的系统管理员做出如下解释:$1",
        "protectedpagewarning": "'''警告:本页面已被保护,只有拥有管理员权限的用户可以编辑。'''下面提供最后的日志条目以供参考:",
-       "semiprotectedpagewarning": "'''注意:'''本页面已被保护,只有注册用户可以编辑。下面提供最后的日志条目以供参考:",
+       "semiprotectedpagewarning": "<strong>注意:</strong>本页面已被保护,只有注册用户可以编辑。下面提供最后的日志条目以供参考:",
        "cascadeprotectedwarning": "<strong>警告:</strong>本页面已经被保护,只有拥有管理员权限的用户可以编辑,因为它被嵌入于以下启用连锁保护的{{PLURAL:$1|页面}}中:",
-       "titleprotectedwarning": "'''警告:本页面已被保护,创建本页面需要[[Special:ListGroupRights|特定权限]]。'''下面提供最后的日志条目以供参考:",
+       "titleprotectedwarning": "<strong>警告:本页面已被保护,创建本页面需要[[Special:ListGroupRights|特定权限]]。</strong>下面提供最后的日志条目以供参考:",
        "templatesused": "该页面使用的{{PLURAL:$1|模板}}:",
        "templatesusedpreview": "本预览使用的{{PLURAL:$1|模板}}:",
        "templatesusedsection": "该段落使用的{{PLURAL:$1|模板}}:",
        "template-semiprotected": "(受半保护)",
        "hiddencategories": "该页面属于$1个隐藏分类:",
        "edittools": "<!-- 这里的文字将显示在编辑和上传表格下面。 -->",
-       "nocreatetext": "{{SITENAME}}已经限制创建新页面功能。可以返回编辑现有页面或[[Special:UserLogin|登录或创建账户]]。",
-       "nocreate-loggedin": "没有权限创建新页面。",
+       "nocreatetext": "{{SITENAME}}已经限制创建新页面功能。可以返回编辑现有页面或[[Special:UserLogin|登录或创建账户]]。",
+       "nocreate-loggedin": "没有权限创建新页面。",
        "sectioneditnotsupported-title": "段落编辑不支持",
        "sectioneditnotsupported-text": "本页面不支持段落编辑。",
        "permissionserrors": "权限错误",
-       "permissionserrorstext": "因为以下{{PLURAL:$1|原因}},没有权限进行该操作:",
-       "permissionserrorstext-withaction": "因为以下{{PLURAL:$1|原因}},没有权限$2:",
+       "permissionserrorstext": "因为以下{{PLURAL:$1|原因}},没有权限进行该操作:",
+       "permissionserrorstext-withaction": "因为以下{{PLURAL:$1|原因}},没有权限$2:",
        "contentmodelediterror": "您不能编辑此修订版本,因为它的内容模型是<code>$1</code>,这与当前页面<code>$2</code>的内容模型不同。",
-       "recreate-moveddeleted-warn": "'''警告:你正在重新创建曾经被删除的页面。'''\n\n你应该考虑继续编辑本页是否合适。这里提供本页的删除和移动日志以供参考:",
+       "recreate-moveddeleted-warn": "<strong>警告:您正在重新创建曾经被删除的页面。</strong>\n\n您应该考虑继续编辑本页是否合适。这里提供本页的删除和移动日志以供参考:",
        "moveddeleted-notice": "本页面已被删除。下面提供本页的删除和移动日志以供参考。",
        "moveddeleted-notice-recent": "抱歉,此页面刚刚被删除(在最近24小时内)。\n页面的删除和移动日志在下方提供以供参考。",
        "log-fulllog": "查看完整日志",
        "edit-hook-aborted": "编辑被hook指令取消。\n无解释。",
        "edit-gone-missing": "不能更新页面。\n它可能刚刚被删除。",
        "edit-conflict": "编辑冲突。",
-       "edit-no-change": "因为没有文字更改,的编辑已被忽略。",
+       "edit-no-change": "因为没有文字更改,的编辑已被忽略。",
        "postedit-confirmation-created": "页面已创建。",
        "postedit-confirmation-restored": "页面已恢复。",
-       "postedit-confirmation-saved": "的编辑已保存。",
+       "postedit-confirmation-saved": "的编辑已保存。",
        "edit-already-exists": "不可以建立一个新页面。\n它已经存在。",
        "defaultmessagetext": "默认消息文本",
        "content-failed-to-parse": "未能将 $2 内容转换为 $1:$3",
        "invalid-content-data": "无效的内容数据",
        "content-not-allowed-here": "[[$2]]页面上不允许“$1”内容",
-       "editwarning-warning": "离开本页面可能导致你失去任何你已经作出的更改。如果你处于登录状态,你可以在你的设置的“{{int:prefs-editing}}”部分停用该警告。",
+       "editwarning-warning": "离开本页面可能导致您失去任何你已经作出的更改。如果您处于登录状态,您可以在您的设置的“{{int:prefs-editing}}”部分停用该警告。",
        "editpage-notsupportedcontentformat-title": "内容格式尚不支持",
        "editpage-notsupportedcontentformat-text": "内容模型$2尚不支持内容格式$1。",
        "content-model-wikitext": "维基文字",
        "duplicate-args-warning": "<strong>警告:</strong>[[:$1]]正在调用超过一个[[:$2]]中“$3”参数的值。只有最后提供的值会被使用。",
        "duplicate-args-category": "调用重复模板参数的页面",
        "duplicate-args-category-desc": "页面包含调用了重复参数的模板,例如<code><nowiki>{{foo|bar=1|bar=2}}</nowiki></code>或<code><nowiki>{{foo|bar|1=baz}}</nowiki></code>。",
-       "expensive-parserfunction-warning": "<strong>警告:</strong>这个页面有太多高昂的语法功能调用。\n\n它应该少过$2次呼叫,现在有$1次呼叫。",
+       "expensive-parserfunction-warning": "<strong>警告:</strong>这个页面有太多高开销解析器函数调用。\n\n它应少于$2次{{PLURAL:$2|调用}},而目前有{{PLURAL:$1|$1次调用}}。",
        "expensive-parserfunction-category": "有过多高开销解析器函数调用的页面",
-       "post-expand-template-inclusion-warning": "'''警告:'''包含模板大小过大。\n一些模板将不会包含。",
+       "post-expand-template-inclusion-warning": "<strong>警告:</strong>包含模板大小过大。\n一些模板将不会包含。",
        "post-expand-template-inclusion-category": "模板包含上限已经超过的页面",
        "post-expand-template-argument-warning": "<strong>警告:</strong>本页面包含至少一个模板参数有过大扩展大小。这些参数会被略过。",
        "post-expand-template-argument-category": "含有略过模板参数的页面",
        "expansion-depth-exceeded-category": "扩展深度超出限制的页面",
        "expansion-depth-exceeded-category-desc": "页面超出最大展开深度限制。",
        "expansion-depth-exceeded-warning": "页面超出展开深度限制",
-       "parser-unstrip-loop-warning": "检测到回圈",
-       "parser-unstrip-recursion-limit": "递归超过限制 ($1)",
+       "parser-unstrip-loop-warning": "检测到Unstrip循环",
+       "parser-unstrip-recursion-limit": "已超出Unstrip递归限制($1)",
        "converter-manual-rule-error": "在手动语言转换规则中检测到错误",
-       "undo-success": "该编辑可以被撤销。请检查下面的对比以核实想要撤销的内容,然后保存下面的更改以完成撤销。",
+       "undo-success": "该编辑可以被撤销。请检查下面的对比以核实想要撤销的内容,然后保存下面的更改以完成撤销。",
        "undo-failure": "因存在冲突的中间编辑,本编辑不能撤销。",
        "undo-norev": "该编辑无法撤消,因为它不存在或已被删除。",
        "undo-nochange": "这次编辑似乎已被撤销。",
        "rev-deleted-user": "(用户名被删除)",
        "rev-deleted-event": "(日志详情已移除)",
        "rev-deleted-user-contribs": "[用户名或IP地址被删除 - 编辑在贡献中隐藏]",
-       "rev-deleted-text-permission": "本页面版本已被'''删除'''。详情请见[{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} 删除日志]。",
+       "rev-deleted-text-permission": "本页面版本已被<strong>删除</strong>。详情请见[{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} 删除日志]。",
        "rev-suppressed-text-permission": "此页面修订已经被<strong>监督隐藏</strong>。详细信息可在[{{fullurl:{{#Special:Log}}/suppress|page={{FULLPAGENAMEE}}}} 监督日志]中找到。",
-       "rev-deleted-text-unhide": "本页面版本已被'''删除'''。详情请见[{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} 删除日志]。如果你想继续操作,你仍然可以[$1 查看本版本]。",
-       "rev-suppressed-text-unhide": "该页面版本已经被'''监督隐藏'''。在[{{fullurl:{{#Special:Log}}/suppress|page={{FULLPAGENAMEE}}}} 监督日志]中可以找到详细的信息。如果您想继续的话,您可以仍然[$1 去查看这次版本]。",
-       "rev-deleted-text-view": "本页面版本已被'''删除'''。你可以查看它,详情请见[{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} 删除日志]。",
-       "rev-suppressed-text-view": "该页面版本已经被'''监督隐藏'''。您可以查看它。在[{{fullurl:{{#Special:Log}}/suppress|page={{FULLPAGENAMEE}}}} 监督日志]中可以找到详细的信息。",
-       "rev-deleted-no-diff": "你不能查看该差异,因为其中一个版本已被'''删除'''。详情请见[{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} 删除日志]。",
+       "rev-deleted-text-unhide": "本页面版本已被<strong>删除</strong>。详情请见[{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} 删除日志]。如果您想继续操作,您仍然可以[$1 查看本版本]。",
+       "rev-suppressed-text-unhide": "该页面版本已经被<strong>监督隐藏</strong>。在[{{fullurl:{{#Special:Log}}/suppress|page={{FULLPAGENAMEE}}}} 监督日志]中可以找到详细的信息。如果您想继续的话,您可以仍然[$1 去查看这次版本]。",
+       "rev-deleted-text-view": "本页面版本已被<strong>删除</strong>。您可以查看它,详情请见[{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} 删除日志]。",
+       "rev-suppressed-text-view": "该页面版本已经被<strong>监督隐藏</strong>。您可以查看它。在[{{fullurl:{{#Special:Log}}/suppress|page={{FULLPAGENAMEE}}}} 监督日志]中可以找到详细的信息。",
+       "rev-deleted-no-diff": "您不能查看该差异,因为其中一个版本已被<strong>删除</strong>。详情请见[{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} 删除日志]。",
        "rev-suppressed-no-diff": "无法查看该差异,因为其中一个版本已被<strong>删除<strong>。",
        "rev-deleted-unhide-diff": "该差异对比的其中的一个版本已经被<strong>删除</strong>。在[{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} 删除日志]中可以找到更多的信息。如果您想继续的话,您仍然可以[$1 查看此版本]。",
        "rev-suppressed-unhide-diff": "该页面的其中一次版本已经被<strong>监督隐藏</strong>。\n在[{{fullurl:{{#Special:Log}}/suppress|page={{FULLPAGENAMEE}}}} 监督日志]中可以找到更多的资料。如果您想继续的话,您可以仍然[$1 去查看这版本]。",
        "logdelete-text": "已删除日志事件仍将在日志中显示,但涉及部分的内容将对公众不可见。",
        "revdelete-text-others": "其他管理员仍将可以访问隐藏内容并删除它,除非附加条件被设定。",
        "revdelete-confirm": "请确认该操作,明白其后果,并确保该操作符合[[{{MediaWiki:Policy-url}}|方针]]。",
-       "revdelete-suppress-text": "阻止应'''仅'''用于以下情况:\n* 潜在的诽谤信息\n* 不合适的个人信息\n*: ''家庭地址、电话号码和社保号码等。''",
+       "revdelete-suppress-text": "阻止应<strong>仅</strong>用于以下情况:\n* 潜在的诽谤信息\n* 不合适的个人信息\n*: <em>家庭地址、电话号码和社保号码等。</em>",
        "revdelete-legend": "设置可见性之限制",
        "revdelete-hide-text": "版本文字",
        "revdelete-hide-image": "隐藏文件内容",
        "revdelete-submit": "应用于选中的{{PLURAL:$1|版本}}",
        "revdelete-success": "版本可见性更新成功。",
        "revdelete-failure": "版本可见性无法更新:\n$1",
-       "logdelete-success": "'''事件的可见性已经成功设置。'''",
-       "logdelete-failure": "'''事件的可见性无法设置:'''\n$1",
+       "logdelete-success": "事件的可见性已经成功设置。",
+       "logdelete-failure": "事件的可见性无法设置:\n$1",
        "revdel-restore": "更改可见性",
        "pagehist": "页面历史",
        "deletedhist": "已删除历史",
        "revdelete-show-no-access": "正在显示于$1 $2之项目错误:这个项目已经标示为\"已限制\",您对它并无通行权。",
        "revdelete-modify-no-access": "正在更改于$1 $2之项目错误:这个项目已经标示为\"已限制\",您对它并无通行权。",
        "revdelete-modify-missing": "正在更改项目ID $1错误:它在资料库中遗失!",
-       "revdelete-no-change": "警告:于$1 $2之项目已经请求了可见性的设置。",
+       "revdelete-no-change": "<strong>警告:</strong>于$1 $2之项目已经请求了可见性的设置。",
        "revdelete-concurrent-change": "正在更改于$1 $2之项目错误:当我们尝试更改它的设置时,已经被另一些人更改过。请检查纪录。",
        "revdelete-only-restricted": "在隐藏$1 $2的项目时发生错误:您不能在选择了另一可见性选项后废止管理员查看该项目。",
        "revdelete-reason-dropdown": "*常用删除理由\n** 侵犯版权\n** 不适当的评论或个人信息\n** 不适当的用户名\n** 潜在毁谤性信息",
        "nextn-title": "后$1个结果",
        "shown-title": "每页显示$1项结果",
        "viewprevnext": "查看($1{{int:pipe-separator}}$2)($3)",
-       "searchmenu-exists": "'''本wiki上有名为“[[:$1]]”的页面。'''",
+       "searchmenu-exists": "<strong>本wiki上有名为“[[:$1]]”的页面。</strong>{{PLURAL:$2|0=|另请查看找到的其他搜索结果。}}",
        "searchmenu-new": "<strong>在本Wiki上新建名为“[[:$1]]”的页面!</strong>{{PLURAL:$2|0=|另请查看您的搜索找的结果。|另请查看搜索结果。}}",
        "searchprofile-articles": "内容页面",
        "searchprofile-images": "多媒体",
        "search-relatedarticle": "相关",
        "searchrelated": "相关",
        "searchall": "所有",
-       "showingresults": "下面显示从第'''$2'''条结果开始的'''$1'''条结果。",
+       "showingresults": "下面显示从第<strong>$2</strong>条结果开始的<strong>$1</strong>条结果。",
        "showingresultsinrange": "下面显示区间#<strong>$2</strong>至#<strong>$3</strong>的<strong>$1</strong>条结果。",
        "search-showingresults": "{{PLURAL:$4|<strong>$3</strong>条结果中的<strong>$1</strong>条|<strong>$3</strong>条结果中的<strong>$1~$2</strong>条}}",
        "search-nonefound": "找不到和查询相匹配的结果。",
        "saveusergroups": "保存{{GENDER:$1|用户}}组",
        "userrights-groupsmember": "用户组:",
        "userrights-groupsmember-auto": "自动用户组:",
-       "userrights-groups-help": "可以更改该用户的用户组:\n* 选中的选项框表示该用户属于该用户组。\n* 未选中的选项框表示该用户不属于该用户组。\n* 星号(*)表示一旦添加该用户组后不能删除,反之亦然。",
+       "userrights-groups-help": "可以更改该用户的用户组:\n* 选中的选项框表示该用户属于该用户组。\n* 未选中的选项框表示该用户不属于该用户组。\n* 星号(*)表示一旦添加该用户组后不能删除,反之亦然。",
        "userrights-reason": "原因:",
        "userrights-no-interwiki": "您并没有权限去编辑在其它wiki上的用户权限。",
        "userrights-nodatabase": "数据库$1不存在或并非为本地的。",
        "userrights-nologin": "您必须要以管理员帐户[[Special:UserLogin|登录]]之后才可以指定用户权限。",
-       "userrights-notallowed": "没有权限添加或删除用户权限。",
-       "userrights-changeable-col": "可以更改的用户组",
-       "userrights-unchangeable-col": "不能更改的用户组",
+       "userrights-notallowed": "没有权限添加或删除用户权限。",
+       "userrights-changeable-col": "可以更改的用户组",
+       "userrights-unchangeable-col": "不能更改的用户组",
        "userrights-conflict": "用户权限的更改存在冲突!请检查并确认您的更改。",
        "userrights-removed-self": "您已成功删除您自己的权利。因此,您不再能够访问此页。",
        "group": "用户组:",
        "group-suppress": "Flow监督员",
        "group-all": "(所有)",
        "group-user-member": "{{GENDER:$1|用户}}",
-       "group-autoconfirmed-member": "自动确认用户",
-       "group-bot-member": "机器人",
+       "group-autoconfirmed-member": "{{GENDER:$1|自动确认用户}}",
+       "group-bot-member": "{{GENDER:$1|机器人}}",
        "group-sysop-member": "{{GENDER:$1|管理员}}",
-       "group-bureaucrat-member": "行政员",
+       "group-bureaucrat-member": "{{GENDER:$1|行政员}}",
        "group-suppress-member": "{{GENDER:$1|Flow监督员}}",
        "grouppage-user": "{{ns:project}}:用户",
        "grouppage-autoconfirmed": "{{ns:project}}:自动确认用户",
        "upload_directory_missing": "上传目录($1)遗失,不能由网页服务器建立。",
        "upload_directory_read_only": "上传目录($1)不存在或无写权限。",
        "uploaderror": "上传出错",
-       "upload-recreate-warning": "'''警告:一个相同名字的文件曾经被删除或者移动至别处。'''\n\n这个页面的删除和移动日志在这里提供以便参考:",
+       "upload-recreate-warning": "<strong>警告:一个相同名字的文件曾经被删除或者移动至别处。</strong>\n\n这个页面的删除和移动日志在这里提供以便参考:",
        "uploadtext": "请使用下面的表格上传文件。要查看或搜索以往上传的文件,请前往[[Special:FileList|上传的文件的列表]],(重新)上传也将记录在[[Special:Log/upload|上传日志]]中,删除将记录在[[Special:Log/delete|删除日志]]中。\n\n要在页面中包含文件,请使用一种以下形式的链接:\n* <strong><code><nowiki>[[</nowiki>{{ns:file}}<nowiki>:File.jpg]]</nowiki></code></strong>使用文件的完整版本\n* <strong><code><nowiki>[[</nowiki>{{ns:file}}<nowiki>:File.png|200px|thumb|left|替代文字]]</nowiki></code></strong>使用位于页面左边的框内的200像素宽的图片,以“替代文字”作为说明\n* <strong><code><nowiki>[[</nowiki>{{ns:media}}<nowiki>:File.ogg]]</nowiki></code></strong>直接链接到文件而不显示文件",
        "upload-permitted": "允许的文件{{PLURAL:$2|类型}}:$1。",
        "upload-preferred": "建议的文件{{PLURAL:$2|类型}}:$1。",
        "filetype-mime-mismatch": "文件扩展名“.$1”与检测到的文件MIME类型($2)不匹配。",
        "filetype-badmime": "“$1”类型的文件已被禁止上传。",
        "filetype-bad-ie-mime": "无法上传该文件,因为Internet Explorer会将它检测为“$1”,这是一种禁止且带有潜在危险的文件类型。",
-       "filetype-unwanted-type": "'''“.$1”'''是一种不需要的文件类型。\n建议的{{PLURAL:$3|一种|多种}}文件类型有$2。",
-       "filetype-banned-type": "'''\".$1\"'''{{PLURAL:$4|不是一个允许的文件类型|不是一个允许的文件类型}}。\n允许 {{PLURAL:$3|文件类型是}} $2。",
+       "filetype-unwanted-type": "<strong>“.$1”</strong>是一种不需要的文件类型。\n建议的{{PLURAL:$3|一种|多种}}文件类型有$2。",
+       "filetype-banned-type": "<strong>“.$1”</strong>{{PLURAL:$4|不是允许的文件类型}}。\n允许的{{PLURAL:$3|文件类型是}}$2。",
        "filetype-missing": "该文件名称并没有扩展名(例如“.jpg”)。",
        "empty-file": "您所提交的文件为空文件。",
        "file-too-large": "您所提交的文件过大。",
        "filename-tooshort": "文件名过短。",
        "filetype-banned": "此类文件被禁止。",
        "verification-error": "文件未通过验证。",
-       "hookaborted": "尝试的修改被扩展程序中止。",
+       "hookaborted": "尝试的修改被扩展程序中止。",
        "illegal-filename": "文件名非法。",
        "overwrite": "不允许覆盖现有文件。",
        "unknown-error": "发生未知错误。",
        "largefileserver": "这个文件的大小比服务器配置允许的大小还要大。",
        "emptyfile": "您所上传的文件不存在。这可能是由于文件名键入错误。请检查您是否真的要上传此文件。",
        "windows-nonascii-filename": "本wiki不支持在文件名中使用特殊字符。",
-       "fileexists": "已存在相同名称的文件,如果您无法确定您是否要改变它,请检查<strong><strong>[[:$1]]</strong></strong>。 [[$1|thumb]]",
-       "filepageexists": "该文件的说明页面已经创建于<strong>[[:$1]]</strong>,但是目前没有名称为此的文件存在。你输入的摘要不会显示在说明页面上。要使你的摘要在那里显示,你需要手工编辑它。[[$1|thumb]]",
+       "fileexists": "已存在相同名称的文件,如果您无法确定您是否要改变它,请检查<strong>[[:$1]]</strong>。 [[$1|thumb]]",
+       "filepageexists": "该文件的说明页面已经创建于<strong>[[:$1]]</strong>,但是目前没有名称为此的文件存在。您输入的摘要不会显示在说明页面上。要使你的摘要在那里显示,您需要手工编辑它。[[$1|thumb]]",
        "fileexists-extension": "一个相似名称的文件已经存在: [[$2|thumb]]\n* 上传文件的文件名:<strong>[[:$1]]</strong>\n* 现有文件的文件名:<strong>[[:$2]]</strong>\n请选择一个不同的名字。",
-       "fileexists-thumbnail-yes": "此文件可能是另一幅图像的缩小版本''(缩略图)''。 [[$1|thumb]]\n请仔细检查该文件<strong>[[:$1]]</strong>。\n如果被检查文件与原始大小的图像是同一幅图像,您无需上传多余的缩略图。",
-       "file-thumbnail-no": "文件名以<strong>$1</strong>开始。它似乎是缩小的图像''(缩略图)''。如果你有完整分辨率的该图像,请上传它,否则请更改文件名。",
+       "fileexists-thumbnail-yes": "此文件可能是另一幅图像的缩小版本<em>(缩略图)</em>。 [[$1|thumb]]\n请仔细检查该文件<strong>[[:$1]]</strong>。\n如果被检查文件与原始大小的图像是同一幅图像,您无需上传多余的缩略图。",
+       "file-thumbnail-no": "文件名以<strong>$1</strong>开始。它似乎是缩小的图像<em>(缩略图)</em>。如果您有完整分辨率的该图像,请上传它,否则请更改文件名。",
        "fileexists-forbidden": "已存在相同名称的文件,且不能覆盖;请返回并用一个新的名称来上传此文件。[[File:$1|thumb|center|$1]]",
-       "fileexists-shared-forbidden": "共享文件库中存在该名称的文件。如果仍想上传你的文件,请返回使用其他名称。[[File:$1|thumb|center|$1]]",
+       "fileexists-shared-forbidden": "共享文件库中存在该名称的文件。如果仍想上传你的文件,请返回使用其他名称。[[File:$1|thumb|center|$1]]",
        "file-exists-duplicate": "本文件是以下{{PLURAL:$1|文件}}的副本:",
        "file-deleted-duplicate": "一个相同名称的文件 ([[:$1]]) 在先前删除过。您应该在重新上传之前检查一下该文件之删除纪录。",
        "file-deleted-duplicate-notitle": "之前有与此相同的文件被删除和取消标题。您应该询问查看过改文件数据的任何人以复查重新上传时的诸多问题。",
        "uploaded-setting-handler-svg": "通过远程/数据/脚本设置“handler”属性的SVG时受阻。在上传的SVG文件中找到<code>$1=\"$2\"</code>。",
        "uploaded-remote-url-svg": "通过远程URL设置任意样式属性的SVG时受阻。在上传的SVG文件中找到<code>$1=\"$2\"</code>。",
        "uploaded-image-filter-svg": "在上传的SVG文件中找到图片过滤器带URL:<code>&lt;$1 $2=\"$3\"&gt;</code>。",
-       "uploadscriptednamespace": "此SVG文件包含非法名字空间“$1”",
+       "uploadscriptednamespace": "此SVG文件包含非法名字空间“$1”",
        "uploadinvalidxml": "上传文件中的XML无法解析。",
        "uploadvirus": "该文件包含病毒!\n详情:$1",
        "uploadjava": "该文件是 ZIP 文件,其中包含 Java 的.class 文件。上传Java文件不被允许,因为它们可能绕过限制,从而引起安全问题。",
        "backend-fail-closetemp": "无法创建临时文件。",
        "backend-fail-read": "找不到文件“$1”。",
        "backend-fail-create": "无法写入文件 $1 。",
-       "backend-fail-maxsize": "无法写入文件 $1,因为它大于$2字节。",
-       "backend-fail-readonly": "“$1”存储后端目前在只读模式,因为:“$2”",
+       "backend-fail-maxsize": "无法写入文件“$1”,因为它大于$2字节。",
+       "backend-fail-readonly": "“$1”存储后端目前在只读模式,因为:“<em>$2</em>”",
        "backend-fail-synced": "文件\"$1\"在内部存储后端之中处于不一致状态",
        "backend-fail-connect": "无法连接到存储后端“$1。",
        "backend-fail-internal": "存储后端“$1”发生了一个未知错误。",
        "uploadstash": "上传隐藏",
        "uploadstash-summary": "这个页面提供已经上传(或者上传中)但未发布到wiki之文件存取。这些文件除了上传的用户之外不会被其他人可见。",
        "uploadstash-clear": "清除贮藏文件",
-       "uploadstash-nofiles": "没有被隐藏的文件。",
+       "uploadstash-nofiles": "没有被隐藏的文件。",
        "uploadstash-badtoken": "该操作执行失败,可能是因为你的编辑凭证已过期。请重试。",
        "uploadstash-errclear": "清除文件不成功。",
        "uploadstash-refresh": "更新文件列表",
        "upload-disallowed-here": "您不可以覆盖此文件。",
        "filerevert": "恢复$1",
        "filerevert-legend": "恢复文件",
-       "filerevert-intro": "你将要恢复文件'''[[Media:$1|$1]]'''至[$4 $2 $3的版本]。",
+       "filerevert-intro": "您将要恢复文件<strong>[[Media:$1|$1]]</strong>至[$4 $2 $3的版本]。",
        "filerevert-comment": "原因:",
        "filerevert-defaultcomment": "回退至$1 $2($3)的版本",
        "filerevert-submit": "恢复",
        "filerevert-badversion": "文件并无所请求时间戳下的早期本地版本。",
        "filedelete": "删除$1",
        "filedelete-legend": "删除文件",
-       "filedelete-intro": "你将要删除文件'''[[Media:$1|$1]]'''及其全部历史。",
-       "filedelete-intro-old": "你正在删除'''[[Media:$1|$1]]'''[$4 $2$3]的版本。",
+       "filedelete-intro": "您将要删除文件<strong>[[Media:$1|$1]]</strong>及其全部历史。",
+       "filedelete-intro-old": "你正在删除<strong>[[Media:$1|$1]]</strong>[$4 $2$3]的版本。",
        "filedelete-comment": "原因:",
        "filedelete-submit": "删除",
-       "filedelete-success": "'''$1'''已经删除。",
-       "filedelete-success-old": "'''[[Media:$1|$1]]'''于 $2 $3 的版本已经删除。",
-       "filedelete-nofile": "'''$1'''不存在。",
-       "filedelete-nofile-old": "在已指定属性的情况下,这里没有'''$1'''的保存版本。",
+       "filedelete-success": "<strong>$1</strong>已经删除。",
+       "filedelete-success-old": "<strong>[[Media:$1|$1]]</strong> 于 $2 $3 的版本已经删除。",
+       "filedelete-nofile": "<strong>$1</strong>不存在。",
+       "filedelete-nofile-old": "在已指定属性的情况下,这里没有<strong>$1</strong>的保存版本。",
        "filedelete-otherreason": "其他/附加原因:",
        "filedelete-reason-otherlist": "其他原因",
-       "filedelete-reason-dropdown": "\n*常用删除理由\n** 侵犯版权\n** 重复文件",
+       "filedelete-reason-dropdown": "*常用删除理由\n** 侵犯版权\n** 重复文件",
        "filedelete-edit-reasonlist": "编辑删除原因",
        "filedelete-maintenance": "维护期间文件删除和恢复暂时停用。",
        "filedelete-maintenance-title": "无法删除文件",
        "randompage-nopages": "在以下{{PLURAL:$2|名字空间}}中没有页面:$1。",
        "randomincategory": "分类中随机页面",
        "randomincategory-invalidcategory": "“$1”不是一个有效的分类名称。",
-       "randomincategory-nopages": "[[:Category:$1]]中没有页面。",
+       "randomincategory-nopages": "[[:Category:$1|$1]]分类中没有页面。",
        "randomincategory-category": "分类:",
-       "randomincategory-legend": "分中随机页面",
+       "randomincategory-legend": "分中随机页面",
        "randomincategory-submit": "提交",
        "randomredirect": "随机重定向",
        "randomredirect-nopages": "“$1”名字空间中没有重定向。",
        "querypage-disabled": "本特殊页面因性能问题而停用。",
        "apihelp": "API 帮助",
        "apihelp-no-such-module": "找不到模块“$1”。",
+       "apisandbox": "API 沙盒",
+       "apisandbox-api-disabled": "API在该网站停用。",
+       "apisandbox-intro": "使用这个页面来试验“MediaWiki Web 服务应用程序接口(API)”。\n欲知API使用详情,请参阅[//www.mediawiki.org/wiki/API:Main_page API文档]。\n例如:[//www.mediawiki.org/wiki/API#A_simple_example 取得某个主页的内容],然后选择一个操作来看更多范例。\n\n请注意,虽然这是一个沙盒,但是你在这个页面上的改动可能会修改维基。",
+       "apisandbox-submit": "提交请求",
+       "apisandbox-reset": "清除",
+       "apisandbox-examples": "示例",
+       "apisandbox-results": "结果",
+       "apisandbox-request-url-label": "请求的URL:",
+       "apisandbox-request-time": "请求时间:$1",
        "booksources": "网络书源",
        "booksources-search-legend": "搜索图书来源",
        "booksources-isbn": "ISBN:",
        "trackingcategories-nodesc": "没有可用说明。",
        "trackingcategories-disabled": "分类被禁用",
        "mailnologin": "无电子邮件地址",
-       "mailnologintext": "你必须[[Special:UserLogin|登录]]并在你的[[Special:Preferences|系统设置]]中拥有有效的电子邮件地址才能向其他用户发送电子邮件。",
+       "mailnologintext": "您必须[[Special:UserLogin|登录]]并在您的[[Special:Preferences|系统设置]]中拥有有效的电子邮件地址才能向其他用户发送电子邮件。",
        "emailuser": "电邮联系",
        "emailuser-title-target": "电邮联系该{{GENDER:$1|用户}}",
        "emailuser-title-notarget": "电邮联系",
-       "emailpagetext": "你可以使用下面的表格发送电子邮件信息至该{{GENDER:$1|用户}}。你在[[Special:Preferences|系统设置]]中输入的电子邮件地址将显示为邮件的“发件人”地址,所以该用户将可以直接回复你。",
+       "emailpagetext": "您可以使用下面的表格发送电子邮件信息至该{{GENDER:$1|用户}}。您在[[Special:Preferences|系统设置]]中输入的电子邮件地址将显示为邮件的“发件人”地址,所以该用户将可以直接回复您。",
        "defemailsubject": "来自{{SITENAME}}用户“$1”的电子邮件",
        "usermaildisabled": "用户电子邮件停用",
        "usermaildisabledtext": "你不能发送电子邮件至本wiki的其他用户",
        "watchlist": "监视列表",
        "mywatchlist": "监视列表",
        "watchlistfor2": "$1的监视列表$2",
-       "nowatchlist": "的监视列表为空。",
+       "nowatchlist": "的监视列表为空。",
        "watchlistanontext": "请登录以查看或编辑您的监视列表。",
        "watchnologin": "未登录",
        "addwatch": "添加至监视列表",
index 96b039b..1cac6a1 100644 (file)
        "rev-suppressed-unhide-diff": "檢視差異的其中一個修訂已被 <strong>禁止顯示</strong>。\n可至 [{{fullurl:{{#Special:Log}}/suppress|page={{FULLPAGENAMEE}}}} 禁止顯示日誌] 取得詳細資訊。\n若您要繼續,您仍可以 [$1 檢視此差異]。",
        "rev-deleted-diff-view": "檢視差異的其中一個修訂已被 <strong>刪除</strong>。\n您可繼續檢視差異,可至 [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} 刪除日誌] 取得詳細資訊。",
        "rev-suppressed-diff-view": "檢視差異的其中一個修訂已被 <strong>禁止顯示</strong>。\n您可繼續檢視差異,可至 [{{fullurl:{{#Special:Log}}/suppress|page={{FULLPAGENAMEE}}}} 禁止顯示日誌] 取得詳細資訊。",
-       "rev-delundel": "變更可見",
+       "rev-delundel": "變更可見",
        "rev-showdeleted": "顯示",
        "revisiondelete": "刪除/取消刪除修訂",
        "revdelete-nooldid-title": "無效的目標修訂",
        "querypage-disabled": "此特殊頁面因考量效能問題已被停用。",
        "apihelp": "API 說明",
        "apihelp-no-such-module": "查無模組 \"$1\"。",
+       "apisandbox": "API 沙盒",
+       "apisandbox-api-disabled": "此網站已關閉 API 使用。",
+       "apisandbox-intro": "使用此頁面可測試 '''MediaWiki Web Service API'''。\n請參考 [//www.mediawiki.org/wiki/API:Main_page API 說明文件] 以取得詳細資訊。例:[//www.mediawiki.org/wiki/API#A_simple_example 取得主頁的內容]。 請選擇動作以取得更多範例。\n\n請注意,雖然此為沙盒,您在此頁所執行的動作仍有可能會修改到 Wiki。",
+       "apisandbox-submit": "發出請求",
+       "apisandbox-reset": "清除",
+       "apisandbox-examples": "範例",
+       "apisandbox-results": "結果",
+       "apisandbox-request-url-label": "請求 URL:",
+       "apisandbox-request-time": "請求時間:$1",
        "booksources": "圖書資源",
        "booksources-search-legend": "尋找圖書資源",
        "booksources-isbn": "國際標準書號:",
index 8fa13c6..bd2ac04 100644 (file)
@@ -390,11 +390,13 @@ $specialPageAliases = array(
        'AllMyUploads'              => array( 'AllMyUploads', 'AllMyFiles' ),
        'Allpages'                  => array( 'AllPages' ),
        'ApiHelp'                   => array( 'ApiHelp' ),
+       'ApiSandbox'                => array( 'ApiSandbox' ),
        'Ancientpages'              => array( 'AncientPages' ),
        'Badtitle'                  => array( 'Badtitle' ),
        'Blankpage'                 => array( 'BlankPage' ),
        'Block'                     => array( 'Block', 'BlockIP', 'BlockUser' ),
        'Booksources'               => array( 'BookSources' ),
+       'BotPasswords'              => array( 'BotPasswords' ),
        'BrokenRedirects'           => array( 'BrokenRedirects' ),
        'Categories'                => array( 'Categories' ),
        'ChangeContentModel'        => array( 'ChangeContentModel' ),
diff --git a/maintenance/archives/patch-bot_passwords.sql b/maintenance/archives/patch-bot_passwords.sql
new file mode 100644 (file)
index 0000000..bd60ff7
--- /dev/null
@@ -0,0 +1,25 @@
+--
+-- This table contains a user's bot passwords: passwords that allow access to
+-- the account via the API with limited rights.
+--
+CREATE TABLE /*_*/bot_passwords (
+  -- Foreign key to user.user_id
+  bp_user int NOT NULL,
+
+  -- Application identifier
+  bp_app_id varbinary(32) NOT NULL,
+
+  -- Password hashes, like user.user_password
+  bp_password tinyblob NOT NULL,
+
+  -- Like user.user_token
+  bp_token binary(32) NOT NULL default '',
+
+  -- JSON blob for MWRestrictions
+  bp_restrictions blob NOT NULL,
+
+  -- Grants allowed to the account when authenticated with this bot-password
+  bp_grants blob NOT NULL,
+
+  PRIMARY KEY ( bp_user, bp_app_id )
+) /*$wgDBTableOptions*/;
diff --git a/maintenance/postgres/archives/patch-bot_passwords.sql b/maintenance/postgres/archives/patch-bot_passwords.sql
new file mode 100644 (file)
index 0000000..8e8a794
--- /dev/null
@@ -0,0 +1,9 @@
+CREATE TABLE bot_passwords (
+  bp_user INTEGER NOT NULL,
+  bp_app_id TEXT NOT NULL,
+  bp_password TEXT NOT NULL,
+  bp_token TEXT NOT NULL,
+  bp_restrictions TEXT NOT NULL,
+  bp_grants TEXT NOT NULL,
+  PRIMARY KEY ( bp_user, bp_app_id )
+);
index ad7bd9d..c9f049b 100644 (file)
@@ -74,6 +74,15 @@ CREATE TABLE user_newtalk (
 CREATE INDEX user_newtalk_id_idx ON user_newtalk (user_id);
 CREATE INDEX user_newtalk_ip_idx ON user_newtalk (user_ip);
 
+CREATE TABLE bot_passwords (
+  bp_user INTEGER NOT NULL,
+  bp_app_id TEXT NOT NULL,
+  bp_password TEXT NOT NULL,
+  bp_token TEXT NOT NULL,
+  bp_restrictions TEXT NOT NULL,
+  bp_grants TEXT NOT NULL,
+  PRIMARY KEY ( bp_user, bp_app_id )
+);
 
 CREATE SEQUENCE page_page_id_seq;
 CREATE TABLE page (
index 831e2dc..48d9705 100755 (executable)
@@ -44,9 +44,15 @@ mkdir -p "$REPO_DIR/$TARGET_DIR/i18n"
 mkdir -p "$REPO_DIR/$TARGET_DIR/images"
 mkdir -p "$REPO_DIR/$TARGET_DIR/themes/mediawiki/images"
 mkdir -p "$REPO_DIR/$TARGET_DIR/themes/apex/images"
-cp ./node_modules/oojs-ui/dist/oojs-ui.js "$REPO_DIR/$TARGET_DIR"
-cp ./node_modules/oojs-ui/dist/{oojs-ui-mediawiki-noimages.css,oojs-ui-mediawiki.js} "$REPO_DIR/$TARGET_DIR"
-cp ./node_modules/oojs-ui/dist/{oojs-ui-apex-noimages.css,oojs-ui-apex.js} "$REPO_DIR/$TARGET_DIR"
+cp ./node_modules/oojs-ui/dist/oojs-ui-core.js "$REPO_DIR/$TARGET_DIR"
+cp ./node_modules/oojs-ui/dist/oojs-ui-core-{mediawiki,apex}.css "$REPO_DIR/$TARGET_DIR"
+cp ./node_modules/oojs-ui/dist/oojs-ui-widgets.js "$REPO_DIR/$TARGET_DIR"
+cp ./node_modules/oojs-ui/dist/oojs-ui-widgets-{mediawiki,apex}.css "$REPO_DIR/$TARGET_DIR"
+cp ./node_modules/oojs-ui/dist/oojs-ui-toolbars.js "$REPO_DIR/$TARGET_DIR"
+cp ./node_modules/oojs-ui/dist/oojs-ui-toolbars-{mediawiki,apex}.css "$REPO_DIR/$TARGET_DIR"
+cp ./node_modules/oojs-ui/dist/oojs-ui-windows.js "$REPO_DIR/$TARGET_DIR"
+cp ./node_modules/oojs-ui/dist/oojs-ui-windows-{mediawiki,apex}.css "$REPO_DIR/$TARGET_DIR"
+cp ./node_modules/oojs-ui/dist/oojs-ui-{mediawiki,apex}.js "$REPO_DIR/$TARGET_DIR"
 cp -R ./node_modules/oojs-ui/dist/i18n "$REPO_DIR/$TARGET_DIR"
 cp -R ./node_modules/oojs-ui/dist/images "$REPO_DIR/$TARGET_DIR"
 cp -R ./node_modules/oojs-ui/dist/themes/mediawiki/images "$REPO_DIR/$TARGET_DIR/themes/mediawiki"
index 756f6c0..743b9be 100644 (file)
@@ -220,6 +220,32 @@ CREATE TABLE /*_*/user_properties (
 CREATE UNIQUE INDEX /*i*/user_properties_user_property ON /*_*/user_properties (up_user,up_property);
 CREATE INDEX /*i*/user_properties_property ON /*_*/user_properties (up_property);
 
+--
+-- This table contains a user's bot passwords: passwords that allow access to
+-- the account via the API with limited rights.
+--
+CREATE TABLE /*_*/bot_passwords (
+  -- User ID obtained from CentralIdLookup.
+  bp_user int NOT NULL,
+
+  -- Application identifier
+  bp_app_id varbinary(32) NOT NULL,
+
+  -- Password hashes, like user.user_password
+  bp_password tinyblob NOT NULL,
+
+  -- Like user.user_token
+  bp_token binary(32) NOT NULL default '',
+
+  -- JSON blob for MWRestrictions
+  bp_restrictions blob NOT NULL,
+
+  -- Grants allowed to the account when authenticated with this bot-password
+  bp_grants blob NOT NULL,
+
+  PRIMARY KEY ( bp_user, bp_app_id )
+) /*$wgDBTableOptions*/;
+
 --
 -- Core of the wiki: each page has an entry here which identifies
 -- it by title and contains some essential metadata.
index 458d5f1..7c3bc74 100644 (file)
@@ -1695,6 +1695,61 @@ return array(
                'scripts' => 'resources/src/mediawiki.special/mediawiki.special.js',
                'styles' => 'resources/src/mediawiki.special/mediawiki.special.css',
        ),
+       'mediawiki.special.apisandbox.styles' => array(
+               'styles' => 'resources/src/mediawiki.special/mediawiki.special.apisandbox.top.css',
+       ),
+       'mediawiki.special.apisandbox' => array(
+               'styles' => 'resources/src/mediawiki.special/mediawiki.special.apisandbox.css',
+               'scripts' => 'resources/src/mediawiki.special/mediawiki.special.apisandbox.js',
+               'dependencies' => array(
+                       'mediawiki.special',
+                       'mediawiki.api',
+                       'mediawiki.jqueryMsg',
+                       'oojs-ui',
+                       'mediawiki.widgets.datetime',
+               ),
+               'messages' => array(
+                       'apisandbox-intro',
+                       'apisandbox-submit',
+                       'apisandbox-reset',
+                       'apisandbox-fullscreen',
+                       'apisandbox-fullscreen-tooltip',
+                       'apisandbox-unfullscreen',
+                       'apisandbox-unfullscreen-tooltip',
+                       'apisandbox-retry',
+                       'apisandbox-loading',
+                       'apisandbox-load-error',
+                       'apisandbox-fetch-token',
+                       'apisandbox-helpurls',
+                       'apisandbox-examples',
+                       'apisandbox-dynamic-parameters',
+                       'apisandbox-dynamic-parameters-add-label',
+                       'apisandbox-dynamic-parameters-add-placeholder',
+                       'apisandbox-dynamic-error-exists',
+                       'apisandbox-deprecated-parameters',
+                       'apisandbox-no-parameters',
+                       'api-help-param-limit',
+                       'api-help-param-limit2',
+                       'api-help-param-integer-min',
+                       'api-help-param-integer-max',
+                       'api-help-param-integer-minmax',
+                       'api-help-param-multi-separate',
+                       'api-help-param-multi-max',
+                       'apisandbox-submit-invalid-fields-title',
+                       'apisandbox-submit-invalid-fields-message',
+                       'apisandbox-results',
+                       'apisandbox-sending-request',
+                       'apisandbox-loading-results',
+                       'apisandbox-results-error',
+                       'apisandbox-request-url-label',
+                       'apisandbox-request-time',
+                       'apisandbox-results-fixtoken',
+                       'apisandbox-results-fixtoken-fail',
+                       'apisandbox-alert-page',
+                       'apisandbox-alert-field',
+                       'blanknamespace',
+               ),
+       ),
        'mediawiki.special.block' => array(
                'scripts' => 'resources/src/mediawiki.special/mediawiki.special.block.js',
                'styles' => 'resources/src/mediawiki.special/mediawiki.special.block.css',
@@ -2036,8 +2091,6 @@ return array(
                        'jquery.byteLimit',
                        // TitleOptionWidget
                        'jquery.autoEllipsis',
-                       // FIXME: Kept for bc
-                       'mediawiki.widgets.CategorySelector',
                ),
                'messages' => array(
                        // NamespaceInputWidget
index d3b74f2..647efa2 100644 (file)
@@ -32,19 +32,38 @@ return call_user_func( function () {
        $themes = array_map( 'strtolower', $themes );
        $themes['default'] = 'mediawiki';
 
+       // Helper function to generate paths to files used in 'skinStyles' and 'skinScripts'.
+       $getSkinSpecific = function ( $module, $ext = 'css' ) use ( $themes ) {
+               return array_combine(
+                       array_keys( $themes ),
+                       array_map( function ( $theme ) use ( $module, $ext ) {
+                               $module = $module ? "$module-" : '';
+                               // TODO Allow extensions to specify this path somehow
+                               return "resources/lib/oojs-ui/oojs-ui-$module$theme.$ext";
+                       }, array_values( $themes ) )
+               );
+       };
+
        $modules = array();
+
+       // Omnibus module.
        $modules['oojs-ui'] = array(
+               'dependencies' => array(
+                       'oojs-ui-core',
+                       'oojs-ui-widgets',
+                       'oojs-ui-toolbars',
+                       'oojs-ui-windows',
+               ),
+               'targets' => array( 'desktop', 'mobile' ),
+       );
+
+       // The core JavaScript library.
+       $modules['oojs-ui-core'] = array(
                'scripts' => array(
-                       'resources/lib/oojs-ui/oojs-ui.js',
+                       'resources/lib/oojs-ui/oojs-ui-core.js',
                        'resources/src/oojs-ui-local.js',
                ),
-               'skinScripts' => array_combine(
-                       array_keys( $themes ),
-                       array_map( function ( $theme ) {
-                               // TODO Allow extensions to specify this path somehow
-                               return "resources/lib/oojs-ui/oojs-ui-$theme.js";
-                       }, array_values( $themes ) )
-               ),
+               'skinScripts' => $getSkinSpecific( null, 'js' ),
                'dependencies' => array(
                        'es5-shim',
                        'oojs',
@@ -54,13 +73,25 @@ return call_user_func( function () {
                        'oojs-ui.styles.textures',
                        'mediawiki.language',
                ),
+               'targets' => array( 'desktop', 'mobile' ),
+       );
+       // This contains only the styles required by core widgets.
+       $modules['oojs-ui-core.styles'] = array(
+               'position' => 'top',
+               'styles' => 'resources/src/oojs-ui-local.css', // HACK, see inside the file
+               'skinStyles' => $getSkinSpecific( 'core' ),
+               'targets' => array( 'desktop', 'mobile' ),
+       );
+
+       // Deprecated old name for the module 'oojs-ui-core.styles'.
+       $modules['oojs-ui.styles'] = $modules['oojs-ui-core.styles'];
+
+       // Additional widgets and layouts module.
+       $modules['oojs-ui-widgets'] = array(
+               'scripts' => 'resources/lib/oojs-ui/oojs-ui-widgets.js',
+               'skinStyles' => $getSkinSpecific( 'widgets' ),
+               'dependencies' => 'oojs-ui-core',
                'messages' => array(
-                       'ooui-dialog-message-accept',
-                       'ooui-dialog-message-reject',
-                       'ooui-dialog-process-continue',
-                       'ooui-dialog-process-dismiss',
-                       'ooui-dialog-process-error',
-                       'ooui-dialog-process-retry',
                        'ooui-outline-control-move-down',
                        'ooui-outline-control-move-up',
                        'ooui-outline-control-remove',
@@ -68,21 +99,33 @@ return call_user_func( function () {
                        'ooui-selectfile-dragdrop-placeholder',
                        'ooui-selectfile-not-supported',
                        'ooui-selectfile-placeholder',
+               ),
+               'targets' => array( 'desktop', 'mobile' ),
+       );
+       // Toolbar and tools module.
+       $modules['oojs-ui-toolbars'] = array(
+               'scripts' => 'resources/lib/oojs-ui/oojs-ui-toolbars.js',
+               'skinStyles' => $getSkinSpecific( 'toolbars' ),
+               'dependencies' => 'oojs-ui-core',
+               'messages' => array(
                        'ooui-toolbar-more',
                        'ooui-toolgroup-collapse',
                        'ooui-toolgroup-expand',
                ),
                'targets' => array( 'desktop', 'mobile' ),
        );
-       $modules['oojs-ui.styles'] = array(
-               'position' => 'top',
-               'styles' => 'resources/src/oojs-ui-local.css', // HACK, see inside the file
-               'skinStyles' => array_combine(
-                       array_keys( $themes ),
-                       array_map( function ( $theme ) {
-                               // TODO Allow extensions to specify this path somehow
-                               return "resources/lib/oojs-ui/oojs-ui-$theme-noimages.css";
-                       }, array_values( $themes ) )
+       // Windows and dialogs module.
+       $modules['oojs-ui-windows'] = array(
+               'scripts' => 'resources/lib/oojs-ui/oojs-ui-windows.js',
+               'skinStyles' => $getSkinSpecific( 'windows' ),
+               'dependencies' => 'oojs-ui-core',
+               'messages' => array(
+                       'ooui-dialog-message-accept',
+                       'ooui-dialog-message-reject',
+                       'ooui-dialog-process-continue',
+                       'ooui-dialog-process-dismiss',
+                       'ooui-dialog-process-error',
+                       'ooui-dialog-process-retry',
                ),
                'targets' => array( 'desktop', 'mobile' ),
        );
diff --git a/resources/lib/oojs-ui/oojs-ui-apex-noimages.css b/resources/lib/oojs-ui/oojs-ui-apex-noimages.css
deleted file mode 100644 (file)
index 5d31973..0000000
+++ /dev/null
@@ -1,3071 +0,0 @@
-/*!
- * OOjs UI v0.15.2
- * https://www.mediawiki.org/wiki/OOjs_UI
- *
- * Copyright 2011–2016 OOjs UI Team and other contributors.
- * Released under the MIT license
- * http://oojs.mit-license.org
- *
- * Date: 2016-02-02T22:07:06Z
- */
-@-webkit-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@-moz-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@-ms-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@-o-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-.oo-ui-element-hidden {
-       display: none !important;
-}
-.oo-ui-buttonElement > .oo-ui-buttonElement-button {
-       cursor: pointer;
-       display: inline-block;
-       vertical-align: middle;
-       font: inherit;
-       white-space: nowrap;
-       -webkit-touch-callout: none;
-       -webkit-user-select: none;
-          -moz-user-select: none;
-           -ms-user-select: none;
-               user-select: none;
-}
-.oo-ui-buttonElement > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon,
-.oo-ui-buttonElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
-       display: none;
-}
-.oo-ui-buttonElement.oo-ui-widget-disabled > .oo-ui-buttonElement-button {
-       cursor: default;
-}
-.oo-ui-buttonElement.oo-ui-indicatorElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator,
-.oo-ui-buttonElement.oo-ui-iconElement > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
-       display: inline-block;
-       vertical-align: middle;
-}
-.oo-ui-buttonElement-frameless {
-       display: inline-block;
-       position: relative;
-}
-.oo-ui-buttonElement-frameless.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       display: inline-block;
-       vertical-align: middle;
-}
-.oo-ui-buttonElement-framed > .oo-ui-buttonElement-button {
-       display: inline-block;
-       vertical-align: top;
-       text-align: center;
-}
-.oo-ui-buttonElement-framed.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       display: inline-block;
-       vertical-align: middle;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-disabled > .oo-ui-buttonElement-button,
-.oo-ui-buttonElement-framed.oo-ui-widget-disabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button,
-.oo-ui-buttonElement-framed.oo-ui-widget-disabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
-       cursor: default;
-}
-.oo-ui-buttonElement > .oo-ui-buttonElement-button {
-       color: #333333;
-}
-.oo-ui-buttonElement.oo-ui-iconElement > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
-       margin-left: 0;
-}
-.oo-ui-buttonElement.oo-ui-indicatorElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
-       width: 0.9375em;
-       height: 0.9375em;
-       margin: 0.46875em;
-}
-.oo-ui-buttonElement.oo-ui-iconElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
-       margin-left: 0.46875em;
-}
-.oo-ui-buttonElement.oo-ui-iconElement > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
-       width: 1.875em;
-       height: 1.875em;
-}
-.oo-ui-buttonElement-frameless > .oo-ui-buttonElement-button:hover,
-.oo-ui-buttonElement-frameless > .oo-ui-buttonElement-button:focus {
-       outline: none;
-}
-.oo-ui-buttonElement-frameless > .oo-ui-buttonElement-button:hover > .oo-ui-iconElement-icon,
-.oo-ui-buttonElement-frameless > .oo-ui-buttonElement-button:focus > .oo-ui-iconElement-icon {
-       opacity: 1;
-}
-.oo-ui-buttonElement-frameless > .oo-ui-buttonElement-button:hover > .oo-ui-labelElement-label,
-.oo-ui-buttonElement-frameless > .oo-ui-buttonElement-button:focus > .oo-ui-labelElement-label {
-       color: #000000;
-}
-.oo-ui-buttonElement-frameless > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       color: #333333;
-}
-.oo-ui-buttonElement-frameless.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       margin-left: 0.25em;
-}
-.oo-ui-buttonElement-frameless > input.oo-ui-buttonElement-button {
-       padding-left: 0.25em;
-       color: #333333;
-}
-.oo-ui-buttonElement-frameless > input.oo-ui-buttonElement-button:hover,
-.oo-ui-buttonElement-frameless > input.oo-ui-buttonElement-button:focus {
-       color: #000000;
-}
-.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       color: #087ecc;
-}
-.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       color: #76ab36;
-}
-.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-destructive > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       color: #d45353;
-}
-.oo-ui-buttonElement-frameless.oo-ui-widget-disabled > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
-       opacity: 0.2;
-}
-.oo-ui-buttonElement-frameless.oo-ui-widget-disabled > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       color: #cccccc;
-}
-.oo-ui-buttonElement-framed > .oo-ui-buttonElement-button {
-       padding: 0.2em 0.8em;
-       border-radius: 0.3em;
-       text-shadow: 0 1px 1px rgba(255, 255, 255, 0.5);
-       border: 1px #c9c9c9 solid;
-       -webkit-transition: border-color 100ms ease;
-          -moz-transition: border-color 100ms ease;
-               transition: border-color 100ms ease;
-       background-color: #eeeeee;
-       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #ffffff), color-stop(100%, #dddddd));
-       background-image: -webkit-linear-gradient(top, #ffffff 0, #dddddd 100%);
-       background-image:    -moz-linear-gradient(top, #ffffff 0, #dddddd 100%);
-       background-image:         linear-gradient(to bottom, #ffffff 0, #dddddd 100%);
-       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffffffff', endColorstr='#ffdddddd' )";
-}
-.oo-ui-buttonElement-framed > .oo-ui-buttonElement-button:hover,
-.oo-ui-buttonElement-framed > .oo-ui-buttonElement-button:focus {
-       border-color: #aaaaaa;
-       outline: none;
-}
-.oo-ui-buttonElement-framed > input.oo-ui-buttonElement-button,
-.oo-ui-buttonElement-framed.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       line-height: 1.875em;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active,
-.oo-ui-buttonElement-framed.oo-ui-buttonElement-active > .oo-ui-buttonElement-button,
-.oo-ui-buttonElement-framed.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
-       box-shadow: inset 0 1px 4px 0 rgba(0, 0, 0, 0.07);
-       color: black;
-       border-color: #c9c9c9;
-       background-color: #eeeeee;
-       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #dddddd), color-stop(100%, #ffffff));
-       background-image: -webkit-linear-gradient(top, #dddddd 0, #ffffff 100%);
-       background-image:    -moz-linear-gradient(top, #dddddd 0, #ffffff 100%);
-       background-image:         linear-gradient(to bottom, #dddddd 0, #ffffff 100%);
-       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffdddddd', endColorstr='#ffffffff' )";
-}
-.oo-ui-buttonElement-framed.oo-ui-iconElement > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
-       margin-left: -0.5em;
-       margin-right: -0.5em;
-}
-.oo-ui-buttonElement-framed.oo-ui-iconElement.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
-       margin-right: 0.3em;
-}
-.oo-ui-buttonElement-framed.oo-ui-indicatorElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
-       margin-left: -0.005em;
-       margin-right: -0.005em;
-}
-.oo-ui-buttonElement-framed.oo-ui-indicatorElement.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator,
-.oo-ui-buttonElement-framed.oo-ui-indicatorElement.oo-ui-iconElement:not( .oo-ui-labelElement ) > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
-       margin-left: 0.46875em;
-       margin-right: -0.275em;
-}
-.oo-ui-buttonElement-framed.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button {
-       border: 1px solid #a6cee1;
-       background-color: #cde7f4;
-       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #eaf4fa), color-stop(100%, #b0d9ee));
-       background-image: -webkit-linear-gradient(top, #eaf4fa 0, #b0d9ee 100%);
-       background-image:    -moz-linear-gradient(top, #eaf4fa 0, #b0d9ee 100%);
-       background-image:         linear-gradient(to bottom, #eaf4fa 0, #b0d9ee 100%);
-       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffeaf4fa', endColorstr='#ffb0d9ee' )";
-}
-.oo-ui-buttonElement-framed.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button:hover,
-.oo-ui-buttonElement-framed.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button:focus {
-       border-color: #9dc2d4;
-}
-.oo-ui-buttonElement-framed.oo-ui-flaggedElement-progressive.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active,
-.oo-ui-buttonElement-framed.oo-ui-flaggedElement-progressive.oo-ui-buttonElement-active > .oo-ui-buttonElement-button,
-.oo-ui-buttonElement-framed.oo-ui-flaggedElement-progressive.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
-       border: 1px solid #a6cee1;
-       background-color: #cde7f4;
-       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #b0d9ee), color-stop(100%, #eaf4fa));
-       background-image: -webkit-linear-gradient(top, #b0d9ee 0, #eaf4fa 100%);
-       background-image:    -moz-linear-gradient(top, #b0d9ee 0, #eaf4fa 100%);
-       background-image:         linear-gradient(to bottom, #b0d9ee 0, #eaf4fa 100%);
-       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffb0d9ee', endColorstr='#ffeaf4fa' )";
-}
-.oo-ui-buttonElement-framed.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button {
-       border: 1px solid #b8d892;
-       background-color: #daf0bd;
-       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #f0fbe1), color-stop(100%, #c3e59a));
-       background-image: -webkit-linear-gradient(top, #f0fbe1 0, #c3e59a 100%);
-       background-image:    -moz-linear-gradient(top, #f0fbe1 0, #c3e59a 100%);
-       background-image:         linear-gradient(to bottom, #f0fbe1 0, #c3e59a 100%);
-       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#fff0fbe1', endColorstr='#ffc3e59a' )";
-}
-.oo-ui-buttonElement-framed.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button:hover,
-.oo-ui-buttonElement-framed.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button:focus {
-       border-color: #adcb89;
-}
-.oo-ui-buttonElement-framed.oo-ui-flaggedElement-constructive.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active,
-.oo-ui-buttonElement-framed.oo-ui-flaggedElement-constructive.oo-ui-buttonElement-active > .oo-ui-buttonElement-button,
-.oo-ui-buttonElement-framed.oo-ui-flaggedElement-constructive.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
-       border: 1px solid #b8d892;
-       background-color: #daf0bd;
-       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #c3e59a), color-stop(100%, #f0fbe1));
-       background-image: -webkit-linear-gradient(top, #c3e59a 0, #f0fbe1 100%);
-       background-image:    -moz-linear-gradient(top, #c3e59a 0, #f0fbe1 100%);
-       background-image:         linear-gradient(to bottom, #c3e59a 0, #f0fbe1 100%);
-       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffc3e59a', endColorstr='#fff0fbe1' )";
-}
-.oo-ui-buttonElement-framed.oo-ui-flaggedElement-destructive > .oo-ui-buttonElement-button {
-       color: #d45353;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-disabled > .oo-ui-buttonElement-button,
-.oo-ui-buttonElement-framed.oo-ui-widget-disabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button,
-.oo-ui-buttonElement-framed.oo-ui-widget-disabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
-       opacity: 0.5;
-       -webkit-transform: translate3d(0, 0, 0);
-       box-shadow: none;
-       color: #333333;
-       background: #eeeeee;
-       border-color: #cccccc;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-disabled > .oo-ui-buttonElement-button:hover,
-.oo-ui-buttonElement-framed.oo-ui-widget-disabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button:hover,
-.oo-ui-buttonElement-framed.oo-ui-widget-disabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button:hover,
-.oo-ui-buttonElement-framed.oo-ui-widget-disabled > .oo-ui-buttonElement-button:focus,
-.oo-ui-buttonElement-framed.oo-ui-widget-disabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button:focus,
-.oo-ui-buttonElement-framed.oo-ui-widget-disabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button:focus {
-       border-color: #cccccc;
-       box-shadow: none;
-}
-.oo-ui-clippableElement-clippable {
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-iconElement .oo-ui-iconElement-icon,
-.oo-ui-iconElement.oo-ui-iconElement-icon {
-       background-size: contain;
-       background-position: center center;
-       background-repeat: no-repeat;
-}
-.oo-ui-iconElement .oo-ui-iconElement-icon,
-.oo-ui-iconElement.oo-ui-iconElement-icon {
-       opacity: 0.8;
-}
-.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator,
-.oo-ui-indicatorElement.oo-ui-indicatorElement-indicator {
-       background-size: contain;
-       background-position: center center;
-       background-repeat: no-repeat;
-}
-.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator,
-.oo-ui-indicatorElement.oo-ui-indicatorElement-indicator {
-       opacity: 0.8;
-}
-.oo-ui-pendingElement-pending {
-       background-image: /* @embed */ url(themes/apex/images/textures/pending.gif);
-}
-.oo-ui-fieldLayout {
-       display: block;
-       margin-bottom: 1em;
-}
-.oo-ui-fieldLayout:before,
-.oo-ui-fieldLayout:after {
-       content: " ";
-       display: table;
-}
-.oo-ui-fieldLayout:after {
-       clear: both;
-}
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-left > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label,
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-right > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label,
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-left > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field,
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-right > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field {
-       display: block;
-       float: left;
-}
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-right > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
-       text-align: right;
-}
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline > .oo-ui-fieldLayout-body {
-       display: table;
-}
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label,
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field {
-       display: table-cell;
-       vertical-align: middle;
-}
-.oo-ui-fieldLayout.oo-ui-labelElement.oo-ui-fieldLayout-align-top > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
-       display: inline-block;
-}
-.oo-ui-fieldLayout > .oo-ui-fieldLayout-help {
-       float: right;
-}
-.oo-ui-fieldLayout > .oo-ui-fieldLayout-help > .oo-ui-popupWidget > .oo-ui-popupWidget-popup {
-       z-index: 1;
-}
-.oo-ui-fieldLayout > .oo-ui-fieldLayout-help .oo-ui-fieldLayout-help-content {
-       padding: 0.5em 0.75em;
-       line-height: 1.5em;
-}
-.oo-ui-fieldLayout:last-child {
-       margin-bottom: 0;
-}
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-left.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label,
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-right.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
-       padding-top: 0.5em;
-       margin-right: 5%;
-       width: 35%;
-}
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-left > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field,
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-right > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field {
-       width: 60%;
-}
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline {
-       margin-bottom: 1.25em;
-}
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
-       padding: 0.25em 0.25em 0.25em 0.5em;
-}
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-top.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
-       padding: 0.5em 0;
-}
-.oo-ui-fieldLayout > .oo-ui-popupButtonWidget {
-       margin-right: 0;
-       margin-top: 0.25em;
-}
-.oo-ui-fieldLayout > .oo-ui-popupButtonWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-fieldLayout-disabled > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
-       color: #cccccc;
-}
-.oo-ui-fieldLayout-messages {
-       list-style: none none;
-       margin: 0;
-       padding: 0;
-       margin-top: 0.25em;
-       margin-left: 0.25em;
-}
-.oo-ui-fieldLayout-messages > li {
-       margin: 0;
-       padding: 0;
-}
-.oo-ui-fieldLayout-messages .oo-ui-iconWidget {
-       display: none;
-}
-.oo-ui-fieldLayout-messages .oo-ui-fieldLayout-messages-error {
-       color: #d45353;
-}
-.oo-ui-fieldLayout-messages .oo-ui-labelWidget {
-       padding: 0;
-       line-height: 1.875em;
-       vertical-align: middle;
-}
-.oo-ui-actionFieldLayout-input,
-.oo-ui-actionFieldLayout-button {
-       display: table-cell;
-       vertical-align: middle;
-}
-.oo-ui-actionFieldLayout-input {
-       padding-right: 1em;
-}
-.oo-ui-actionFieldLayout-button {
-       width: 1%;
-       white-space: nowrap;
-}
-.oo-ui-fieldsetLayout {
-       position: relative;
-       margin: 0;
-       padding: 0;
-       border: none;
-}
-.oo-ui-fieldsetLayout.oo-ui-iconElement > .oo-ui-iconElement-icon {
-       display: block;
-       position: absolute;
-}
-.oo-ui-fieldsetLayout.oo-ui-labelElement > .oo-ui-labelElement-label {
-       display: inline-block;
-}
-.oo-ui-fieldsetLayout > .oo-ui-fieldsetLayout-help {
-       float: right;
-}
-.oo-ui-fieldsetLayout > .oo-ui-fieldsetLayout-help > .oo-ui-popupWidget > .oo-ui-popupWidget-popup {
-       z-index: 1;
-}
-.oo-ui-fieldsetLayout > .oo-ui-fieldsetLayout-help .oo-ui-fieldsetLayout-help-content {
-       padding: 0.5em 0.75em;
-       line-height: 1.5em;
-}
-.oo-ui-fieldsetLayout + .oo-ui-fieldsetLayout,
-.oo-ui-fieldsetLayout + .oo-ui-formLayout {
-       margin-top: 2em;
-}
-.oo-ui-fieldsetLayout > .oo-ui-labelElement-label {
-       font-size: 1.1em;
-       margin-bottom: 0.5em;
-       padding: 0.25em 0;
-       font-weight: bold;
-}
-.oo-ui-fieldsetLayout.oo-ui-iconElement > .oo-ui-labelElement-label {
-       padding-left: 2em;
-       line-height: 1.8em;
-}
-.oo-ui-fieldsetLayout.oo-ui-iconElement > .oo-ui-iconElement-icon {
-       left: 0;
-       top: 0.25em;
-       width: 1.875em;
-       height: 1.875em;
-}
-.oo-ui-fieldsetLayout > .oo-ui-popupButtonWidget {
-       margin-right: 0;
-}
-.oo-ui-fieldsetLayout > .oo-ui-popupButtonWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-formLayout + .oo-ui-fieldsetLayout,
-.oo-ui-formLayout + .oo-ui-formLayout {
-       margin-top: 2em;
-}
-.oo-ui-panelLayout {
-       position: relative;
-}
-.oo-ui-panelLayout-scrollable {
-       overflow-y: auto;
-}
-.oo-ui-panelLayout-expanded {
-       position: absolute;
-       top: 0;
-       left: 0;
-       right: 0;
-       bottom: 0;
-}
-.oo-ui-panelLayout-padded {
-       padding: 1.25em;
-}
-.oo-ui-panelLayout-framed {
-       border-radius: 0.5em;
-       box-shadow: 0 0.25em 1em rgba(0, 0, 0, 0.25);
-}
-.oo-ui-panelLayout-padded.oo-ui-panelLayout-framed {
-       margin: 1em 0;
-}
-.oo-ui-horizontalLayout > .oo-ui-widget {
-       display: inline-block;
-       vertical-align: middle;
-}
-.oo-ui-horizontalLayout > .oo-ui-layout {
-       display: inline-block;
-}
-.oo-ui-horizontalLayout > .oo-ui-layout,
-.oo-ui-horizontalLayout > .oo-ui-widget {
-       margin-right: 0.5em;
-}
-.oo-ui-horizontalLayout > .oo-ui-layout:last-child,
-.oo-ui-horizontalLayout > .oo-ui-widget:last-child {
-       margin-right: 0;
-}
-.oo-ui-horizontalLayout > .oo-ui-layout {
-       margin-bottom: 0;
-}
-.oo-ui-optionWidget {
-       position: relative;
-       display: block;
-       padding: 0.25em 0.5em;
-       border: none;
-}
-.oo-ui-optionWidget.oo-ui-widget-enabled {
-       cursor: pointer;
-}
-.oo-ui-optionWidget.oo-ui-labelElement .oo-ui-labelElement-label {
-       display: block;
-       white-space: nowrap;
-       text-overflow: ellipsis;
-       overflow: hidden;
-}
-.oo-ui-optionWidget-highlighted {
-       background-color: #e1f3ff;
-}
-.oo-ui-optionWidget .oo-ui-labelElement-label {
-       line-height: 1.5em;
-}
-.oo-ui-selectWidget-depressed .oo-ui-optionWidget-selected {
-       background-color: #a7dcff;
-}
-.oo-ui-selectWidget-pressed .oo-ui-optionWidget-pressed,
-.oo-ui-selectWidget-pressed .oo-ui-optionWidget-pressed.oo-ui-optionWidget-highlighted,
-.oo-ui-selectWidget-pressed .oo-ui-optionWidget-pressed.oo-ui-optionWidget-highlighted.oo-ui-optionWidget-selected {
-       background-color: #a7dcff;
-}
-.oo-ui-optionWidget.oo-ui-widget-disabled {
-       color: #cccccc;
-}
-.oo-ui-decoratedOptionWidget {
-       padding: 0.5em 2em 0.5em 3em;
-}
-.oo-ui-decoratedOptionWidget .oo-ui-iconElement-icon,
-.oo-ui-decoratedOptionWidget .oo-ui-indicatorElement-indicator {
-       position: absolute;
-}
-.oo-ui-decoratedOptionWidget.oo-ui-iconElement .oo-ui-iconElement-icon,
-.oo-ui-decoratedOptionWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
-       top: 0;
-       height: 100%;
-}
-.oo-ui-decoratedOptionWidget.oo-ui-iconElement .oo-ui-iconElement-icon {
-       width: 1.875em;
-       left: 0.5em;
-}
-.oo-ui-decoratedOptionWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
-       width: 0.9375em;
-       right: 0.5em;
-}
-.oo-ui-decoratedOptionWidget.oo-ui-widget-disabled .oo-ui-iconElement-icon,
-.oo-ui-decoratedOptionWidget.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator {
-       opacity: 0.2;
-}
-.oo-ui-radioSelectWidget {
-       padding: 0.75em 0 0.5em 0;
-}
-.oo-ui-radioOptionWidget {
-       cursor: default;
-       padding: 0;
-       background-color: transparent;
-}
-.oo-ui-radioOptionWidget .oo-ui-radioInputWidget,
-.oo-ui-radioOptionWidget.oo-ui-labelElement .oo-ui-labelElement-label {
-       display: inline-block;
-       vertical-align: middle;
-}
-.oo-ui-radioOptionWidget.oo-ui-optionWidget-selected,
-.oo-ui-radioOptionWidget.oo-ui-optionWidget-pressed,
-.oo-ui-radioOptionWidget.oo-ui-optionWidget-highlighted {
-       background-color: transparent;
-}
-.oo-ui-radioOptionWidget.oo-ui-labelElement .oo-ui-labelElement-label {
-       padding-left: 0.5em;
-}
-.oo-ui-radioOptionWidget .oo-ui-radioInputWidget {
-       margin-right: 0;
-}
-.oo-ui-labelWidget {
-       display: inline-block;
-       padding: 0.5em 0;
-}
-.oo-ui-iconWidget {
-       display: inline-block;
-       vertical-align: middle;
-       line-height: 2.5em;
-       height: 1.875em;
-       width: 1.875em;
-}
-.oo-ui-iconWidget.oo-ui-widget-disabled {
-       opacity: 0.2;
-}
-.oo-ui-indicatorWidget {
-       display: inline-block;
-       vertical-align: middle;
-       line-height: 2.5em;
-       height: 0.9375em;
-       width: 0.9375em;
-       margin: 0.46875em;
-}
-.oo-ui-indicatorWidget.oo-ui-widget-disabled {
-       opacity: 0.2;
-}
-.oo-ui-buttonWidget {
-       display: inline-block;
-       vertical-align: middle;
-       margin-right: 0.5em;
-}
-.oo-ui-buttonWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-buttonGroupWidget {
-       display: inline-block;
-       white-space: nowrap;
-       border-radius: 0.3em;
-       margin-right: 0.5em;
-}
-.oo-ui-buttonGroupWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-buttonGroupWidget .oo-ui-buttonElement {
-       margin-right: 0;
-}
-.oo-ui-buttonGroupWidget .oo-ui-buttonElement:last-child {
-       margin-right: 0;
-}
-.oo-ui-buttonGroupWidget .oo-ui-buttonElement-framed .oo-ui-buttonElement-button {
-       border-radius: 0;
-       margin-left: -1px;
-}
-.oo-ui-buttonGroupWidget .oo-ui-buttonElement-framed:first-child .oo-ui-buttonElement-button {
-       border-bottom-left-radius: 0.3em;
-       border-top-left-radius: 0.3em;
-       margin-left: 0;
-}
-.oo-ui-buttonGroupWidget .oo-ui-buttonElement-framed:last-child .oo-ui-buttonElement-button {
-       border-bottom-right-radius: 0.3em;
-       border-top-right-radius: 0.3em;
-}
-.oo-ui-popupWidget {
-       position: absolute;
-       /* @noflip */
-       left: 0;
-}
-.oo-ui-popupWidget-popup {
-       position: relative;
-       overflow: hidden;
-       z-index: 1;
-}
-.oo-ui-popupWidget-anchor {
-       display: none;
-       z-index: 1;
-}
-.oo-ui-popupWidget-anchored .oo-ui-popupWidget-anchor {
-       display: block;
-       position: absolute;
-       top: 0;
-       /* @noflip */
-       left: 0;
-       background-repeat: no-repeat;
-}
-.oo-ui-popupWidget-head {
-       -webkit-touch-callout: none;
-       -webkit-user-select: none;
-          -moz-user-select: none;
-           -ms-user-select: none;
-               user-select: none;
-}
-.oo-ui-popupWidget-head > .oo-ui-buttonWidget {
-       float: right;
-}
-.oo-ui-popupWidget-head > .oo-ui-labelElement-label {
-       float: left;
-       cursor: default;
-}
-.oo-ui-popupWidget-body {
-       clear: both;
-       overflow: hidden;
-}
-.oo-ui-popupWidget-popup {
-       background-color: #ffffff;
-       border: 1px solid #cccccc;
-       border-radius: 0.25em;
-       box-shadow: 0 0.15em 0.5em 0 rgba(0, 0, 0, 0.2);
-}
-.oo-ui-popupWidget-anchored .oo-ui-popupWidget-popup {
-       margin-top: 6px;
-}
-.oo-ui-popupWidget-anchored .oo-ui-popupWidget-anchor:before,
-.oo-ui-popupWidget-anchored .oo-ui-popupWidget-anchor:after {
-       content: "";
-       position: absolute;
-       width: 0;
-       height: 0;
-       border-style: solid;
-       border-color: transparent;
-       border-top: 0;
-}
-.oo-ui-popupWidget-anchored .oo-ui-popupWidget-anchor:before {
-       bottom: -7px;
-       left: -6px;
-       border-bottom-color: #aaaaaa;
-       border-width: 7px;
-}
-.oo-ui-popupWidget-anchored .oo-ui-popupWidget-anchor:after {
-       bottom: -7px;
-       left: -5px;
-       border-bottom-color: #ffffff;
-       border-width: 6px;
-}
-.oo-ui-popupWidget-transitioning .oo-ui-popupWidget-popup {
-       -webkit-transition: width 100ms ease, height 100ms ease, left 100ms ease;
-          -moz-transition: width 100ms ease, height 100ms ease, left 100ms ease;
-               transition: width 100ms ease, height 100ms ease, left 100ms ease;
-}
-.oo-ui-popupWidget-head {
-       height: 2.5em;
-}
-.oo-ui-popupWidget-head > .oo-ui-buttonWidget {
-       margin: 0.25em;
-}
-.oo-ui-popupWidget-head > .oo-ui-labelElement-label {
-       margin: 0.75em 1em;
-}
-.oo-ui-popupWidget-body-padded {
-       padding: 0 1em;
-}
-.oo-ui-popupButtonWidget {
-       position: relative;
-}
-.oo-ui-popupButtonWidget .oo-ui-popupWidget {
-       position: absolute;
-       cursor: auto;
-}
-.oo-ui-popupButtonWidget.oo-ui-buttonElement-frameless > .oo-ui-popupWidget {
-       /* @noflip */
-       left: 1em;
-}
-.oo-ui-popupButtonWidget.oo-ui-buttonElement-framed > .oo-ui-popupWidget {
-       /* @noflip */
-       left: 1.25em;
-}
-.oo-ui-inputWidget {
-       margin-right: 0.5em;
-}
-.oo-ui-inputWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-buttonInputWidget {
-       display: inline-block;
-       vertical-align: middle;
-}
-.oo-ui-buttonInputWidget > button,
-.oo-ui-buttonInputWidget > input {
-       border: 0;
-       padding: 0;
-       background-color: transparent;
-}
-.oo-ui-dropdownInputWidget {
-       position: relative;
-       vertical-align: middle;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-       width: 100%;
-       max-width: 50em;
-}
-.oo-ui-dropdownInputWidget select {
-       display: inline-block;
-       width: 100%;
-       resize: none;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-dropdownInputWidget select {
-       background-color: #ffffff;
-       height: 2.5em;
-       padding: 0.5em;
-       font-size: inherit;
-       font-family: inherit;
-       border: 1px solid rgba(0, 0, 0, 0.1);
-       border-radius: 0.25em;
-}
-.oo-ui-dropdownInputWidget.oo-ui-widget-enabled select:hover,
-.oo-ui-dropdownInputWidget.oo-ui-widget-enabled select:focus {
-       border-color: rgba(0, 0, 0, 0.2);
-       outline: none;
-}
-.oo-ui-dropdownInputWidget.oo-ui-widget-disabled select {
-       color: #cccccc;
-       border-color: #dddddd;
-       background-color: #f3f3f3;
-}
-.oo-ui-radioSelectInputWidget .oo-ui-fieldLayout {
-       margin-bottom: 0;
-}
-.oo-ui-textInputWidget {
-       position: relative;
-       vertical-align: middle;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-       width: 100%;
-       max-width: 50em;
-}
-.oo-ui-textInputWidget input,
-.oo-ui-textInputWidget textarea {
-       display: inline-block;
-       width: 100%;
-       resize: none;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-textInputWidget textarea {
-       overflow: auto;
-}
-.oo-ui-textInputWidget input[type="search"] {
-       -webkit-appearance: none;
-}
-.oo-ui-textInputWidget input[type="search"]::-ms-clear {
-       display: none;
-}
-.oo-ui-textInputWidget input[type="search"]::-ms-reveal {
-       display: none;
-}
-.oo-ui-textInputWidget input[type="search"]::-webkit-search-decoration,
-.oo-ui-textInputWidget input[type="search"]::-webkit-search-cancel-button,
-.oo-ui-textInputWidget input[type="search"]::-webkit-search-results-button,
-.oo-ui-textInputWidget input[type="search"]::-webkit-search-results-decoration {
-       display: none;
-}
-.oo-ui-textInputWidget > .oo-ui-iconElement-icon,
-.oo-ui-textInputWidget > .oo-ui-indicatorElement-indicator,
-.oo-ui-textInputWidget > .oo-ui-labelElement-label {
-       display: none;
-}
-.oo-ui-textInputWidget.oo-ui-iconElement > .oo-ui-iconElement-icon,
-.oo-ui-textInputWidget.oo-ui-indicatorElement > .oo-ui-indicatorElement-indicator {
-       display: block;
-       position: absolute;
-       top: 0;
-       height: 100%;
-       -webkit-touch-callout: none;
-       -webkit-user-select: none;
-          -moz-user-select: none;
-           -ms-user-select: none;
-               user-select: none;
-}
-.oo-ui-textInputWidget.oo-ui-widget-enabled > .oo-ui-iconElement-icon,
-.oo-ui-textInputWidget.oo-ui-widget-enabled > .oo-ui-indicatorElement-indicator {
-       cursor: text;
-}
-.oo-ui-textInputWidget.oo-ui-widget-enabled.oo-ui-textInputWidget-type-search > .oo-ui-indicatorElement-indicator {
-       cursor: pointer;
-}
-.oo-ui-textInputWidget.oo-ui-labelElement > .oo-ui-labelElement-label {
-       display: block;
-}
-.oo-ui-textInputWidget > .oo-ui-iconElement-icon {
-       left: 0;
-}
-.oo-ui-textInputWidget > .oo-ui-indicatorElement-indicator {
-       right: 0;
-}
-.oo-ui-textInputWidget > .oo-ui-labelElement-label {
-       position: absolute;
-       top: 0;
-}
-.oo-ui-textInputWidget-labelPosition-after > .oo-ui-labelElement-label {
-       right: 0;
-}
-.oo-ui-textInputWidget-labelPosition-before > .oo-ui-labelElement-label {
-       left: 0;
-}
-.oo-ui-textInputWidget input,
-.oo-ui-textInputWidget textarea {
-       padding: 0.5em;
-       line-height: 1.275em;
-       font-size: inherit;
-       font-family: inherit;
-       background-color: #ffffff;
-       color: black;
-       border: 1px solid #cccccc;
-       box-shadow: 0 0 0 white, inset 0 0.1em 0.2em #dddddd;
-       border-radius: 0.25em;
-       -webkit-transition: border-color 250ms ease, box-shadow 250ms ease;
-          -moz-transition: border-color 250ms ease, box-shadow 250ms ease;
-               transition: border-color 250ms ease, box-shadow 250ms ease;
-}
-.oo-ui-textInputWidget input.oo-ui-pendingElement-pending,
-.oo-ui-textInputWidget textarea.oo-ui-pendingElement-pending {
-       background-color: transparent;
-}
-.oo-ui-textInputWidget.oo-ui-widget-enabled input:focus,
-.oo-ui-textInputWidget.oo-ui-widget-enabled textarea:focus {
-       outline: none;
-       border-color: #a7dcff;
-       box-shadow: 0 0 0.3em #a7dcff, 0 0 0 white;
-}
-.oo-ui-textInputWidget.oo-ui-widget-enabled input[readonly],
-.oo-ui-textInputWidget.oo-ui-widget-enabled textarea[readonly] {
-       color: #777777;
-}
-.oo-ui-textInputWidget.oo-ui-widget-enabled.oo-ui-flaggedElement-invalid input,
-.oo-ui-textInputWidget.oo-ui-widget-enabled.oo-ui-flaggedElement-invalid textarea {
-       background-color: #ffdddd;
-}
-.oo-ui-textInputWidget.oo-ui-widget-disabled input,
-.oo-ui-textInputWidget.oo-ui-widget-disabled textarea {
-       color: #cccccc;
-       text-shadow: 0 1px 1px #ffffff;
-       border-color: #dddddd;
-       background-color: #f3f3f3;
-}
-.oo-ui-textInputWidget.oo-ui-widget-disabled .oo-ui-iconElement-icon,
-.oo-ui-textInputWidget.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator {
-       opacity: 0.2;
-}
-.oo-ui-textInputWidget.oo-ui-widget-disabled .oo-ui-labelElement-label {
-       color: #dddddd;
-       text-shadow: 0 1px 1px #ffffff;
-}
-.oo-ui-textInputWidget.oo-ui-iconElement input,
-.oo-ui-textInputWidget.oo-ui-iconElement textarea {
-       padding-left: 2.475em;
-}
-.oo-ui-textInputWidget.oo-ui-iconElement .oo-ui-iconElement-icon {
-       width: 1.875em;
-       max-height: 2.375em;
-       margin-left: 0.3em;
-}
-.oo-ui-textInputWidget.oo-ui-indicatorElement input,
-.oo-ui-textInputWidget.oo-ui-indicatorElement textarea {
-       padding-right: 2.4875em;
-}
-.oo-ui-textInputWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
-       width: 0.9375em;
-       max-height: 2.375em;
-       margin-right: 0.775em;
-}
-.oo-ui-textInputWidget > .oo-ui-labelElement-label {
-       padding: 0.4em;
-       line-height: 1.5em;
-       color: #888888;
-}
-.oo-ui-textInputWidget-labelPosition-after.oo-ui-indicatorElement > .oo-ui-labelElement-label {
-       margin-right: 2.0875em;
-}
-.oo-ui-textInputWidget-labelPosition-before.oo-ui-iconElement > .oo-ui-labelElement-label {
-       margin-left: 2.075em;
-}
-.oo-ui-menuSelectWidget {
-       position: absolute;
-       background-color: #ffffff;
-       margin-top: -1px;
-       border: 1px solid #cccccc;
-       border-radius: 0 0 0.25em 0.25em;
-       box-shadow: 0 0.15em 1em 0 rgba(0, 0, 0, 0.2);
-}
-.oo-ui-menuSelectWidget input {
-       position: absolute;
-       width: 0;
-       height: 0;
-       overflow: hidden;
-       opacity: 0;
-}
-.oo-ui-menuOptionWidget {
-       position: relative;
-}
-.oo-ui-menuOptionWidget .oo-ui-iconElement-icon {
-       display: none;
-}
-.oo-ui-menuOptionWidget.oo-ui-optionWidget-selected {
-       background-color: transparent;
-}
-.oo-ui-menuOptionWidget.oo-ui-optionWidget-selected .oo-ui-iconElement-icon {
-       display: block;
-}
-.oo-ui-menuOptionWidget.oo-ui-optionWidget-selected {
-       background-color: transparent;
-}
-.oo-ui-menuOptionWidget.oo-ui-optionWidget-highlighted,
-.oo-ui-menuOptionWidget.oo-ui-optionWidget-highlighted.oo-ui-optionWidget-selected {
-       background-color: #e1f3ff;
-}
-.oo-ui-menuSectionOptionWidget {
-       cursor: default;
-       padding: 0.33em 0.75em;
-       color: #888888;
-}
-.oo-ui-dropdownWidget {
-       display: inline-block;
-       position: relative;
-       width: 100%;
-       max-width: 50em;
-       background-color: #ffffff;
-       margin-right: 0.5em;
-}
-.oo-ui-dropdownWidget-handle {
-       width: 100%;
-       display: inline-block;
-       white-space: nowrap;
-       overflow: hidden;
-       text-overflow: ellipsis;
-       -webkit-touch-callout: none;
-       -webkit-user-select: none;
-          -moz-user-select: none;
-           -ms-user-select: none;
-               user-select: none;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-dropdownWidget-handle .oo-ui-indicatorElement-indicator,
-.oo-ui-dropdownWidget-handle .oo-ui-iconElement-icon {
-       position: absolute;
-}
-.oo-ui-dropdownWidget > .oo-ui-menuSelectWidget {
-       z-index: 1;
-       width: 100%;
-}
-.oo-ui-dropdownWidget.oo-ui-widget-enabled .oo-ui-dropdownWidget-handle {
-       cursor: pointer;
-}
-.oo-ui-dropdownWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-dropdownWidget-handle {
-       height: 2.5em;
-       border: 1px solid rgba(0, 0, 0, 0.1);
-       border-radius: 0.25em;
-}
-.oo-ui-dropdownWidget-handle:hover {
-       border-color: rgba(0, 0, 0, 0.2);
-}
-.oo-ui-dropdownWidget-handle .oo-ui-indicatorElement-indicator {
-       right: 0;
-}
-.oo-ui-dropdownWidget-handle .oo-ui-iconElement-icon {
-       left: 0.25em;
-}
-.oo-ui-dropdownWidget-handle .oo-ui-labelElement-label {
-       line-height: 2.5em;
-       margin: 0 0.5em;
-}
-.oo-ui-dropdownWidget-handle .oo-ui-indicatorElement-indicator {
-       top: 0;
-       width: 0.9375em;
-       height: 0.9375em;
-       margin: 0.775em;
-}
-.oo-ui-dropdownWidget-handle .oo-ui-iconElement-icon {
-       top: 0;
-       width: 1.875em;
-       height: 1.875em;
-       margin: 0.3em;
-}
-.oo-ui-dropdownWidget.oo-ui-widget-disabled .oo-ui-dropdownWidget-handle {
-       color: #cccccc;
-       text-shadow: 0 1px 1px #ffffff;
-       border-color: #dddddd;
-       background-color: #f3f3f3;
-}
-.oo-ui-dropdownWidget.oo-ui-widget-disabled .oo-ui-dropdownWidget-handle:focus {
-       outline: 0;
-}
-.oo-ui-dropdownWidget.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator {
-       opacity: 0.2;
-}
-.oo-ui-dropdownWidget.oo-ui-iconElement .oo-ui-dropdownWidget-handle .oo-ui-labelElement-label {
-       margin-left: 3em;
-}
-.oo-ui-dropdownWidget.oo-ui-indicatorElement .oo-ui-dropdownWidget-handle .oo-ui-labelElement-label {
-       margin-right: 2em;
-}
-.oo-ui-comboBoxInputWidget {
-       display: inline-block;
-       position: relative;
-       width: 100%;
-       max-width: 50em;
-       margin-right: 0.5em;
-}
-.oo-ui-comboBoxInputWidget > .oo-ui-menuSelectWidget {
-       z-index: 1;
-       width: 100%;
-}
-.oo-ui-comboBoxInputWidget.oo-ui-widget-enabled > .oo-ui-indicatorElement-indicator {
-       cursor: pointer;
-}
-.oo-ui-comboBoxInputWidget-php input::-webkit-calendar-picker-indicator {
-       opacity: 0 !important;
-       position: absolute;
-       right: 0;
-       top: 0;
-       height: 2.5em;
-       width: 2.5em;
-       padding: 0;
-}
-.oo-ui-comboBoxInputWidget-php > .oo-ui-indicatorElement-indicator {
-       pointer-events: none;
-}
-.oo-ui-comboBoxInputWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-comboBoxInputWidget.oo-ui-widget-disabled .oo-ui-textInputWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator,
-.oo-ui-comboBoxInputWidget-empty .oo-ui-textInputWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
-       cursor: default;
-       opacity: 0.2;
-}
-.oo-ui-comboBoxInputWidget > .oo-ui-selectWidget {
-       margin-top: -3px;
-}
-
-/*!
- * OOjs UI v0.15.2
- * https://www.mediawiki.org/wiki/OOjs_UI
- *
- * Copyright 2011–2016 OOjs UI Team and other contributors.
- * Released under the MIT license
- * http://oojs.mit-license.org
- *
- * Date: 2016-02-02T22:07:06Z
- */
-@-webkit-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@-moz-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@-ms-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@-o-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-.oo-ui-draggableElement {
-       cursor: -webkit-grab -moz-grab, url(images/grab.cur), move;
-}
-.oo-ui-draggableElement-dragging {
-       cursor: -webkit-grabbing -moz-grabbing, url(images/grabbing.cur), move;
-       background: rgba(0, 0, 0, 0.2);
-       opacity: 0.4;
-}
-.oo-ui-draggableGroupElement-horizontal .oo-ui-draggableElement.oo-ui-optionWidget {
-       display: inline-block;
-}
-.oo-ui-draggableGroupElement-placeholder {
-       position: absolute;
-       display: block;
-       background: rgba(0, 0, 0, 0.4);
-}
-.oo-ui-lookupElement > .oo-ui-menuSelectWidget {
-       z-index: 1;
-       width: 100%;
-}
-.oo-ui-bookletLayout-stackLayout.oo-ui-stackLayout-continuous > .oo-ui-panelLayout-scrollable {
-       overflow-y: hidden;
-}
-.oo-ui-bookletLayout-stackLayout > .oo-ui-panelLayout {
-       width: 100%;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-bookletLayout-stackLayout > .oo-ui-panelLayout-scrollable {
-       overflow-y: auto;
-}
-.oo-ui-bookletLayout-stackLayout > .oo-ui-panelLayout-padded {
-       padding: 2em;
-}
-.oo-ui-bookletLayout-outlinePanel-editable > .oo-ui-outlineSelectWidget {
-       position: absolute;
-       top: 0;
-       left: 0;
-       right: 0;
-       bottom: 3em;
-       overflow-y: auto;
-}
-.oo-ui-bookletLayout-outlinePanel > .oo-ui-outlineControlsWidget {
-       position: absolute;
-       bottom: 0;
-       left: 0;
-       right: 0;
-}
-.oo-ui-bookletLayout-stackLayout > .oo-ui-panelLayout {
-       padding: 1.5em;
-}
-.oo-ui-bookletLayout-outlinePanel {
-       border-right: 1px solid #dddddd;
-}
-.oo-ui-bookletLayout-outlinePanel > .oo-ui-outlineControlsWidget {
-       box-shadow: 0 0 0.25em rgba(0, 0, 0, 0.25);
-}
-.oo-ui-indexLayout > .oo-ui-menuLayout-menu {
-       height: 3em;
-}
-.oo-ui-indexLayout > .oo-ui-menuLayout-content {
-       top: 3em;
-}
-.oo-ui-indexLayout-stackLayout > .oo-ui-panelLayout {
-       padding: 1.5em;
-}
-.oo-ui-menuLayout {
-       position: absolute;
-       top: 0;
-       left: 0;
-       right: 0;
-       bottom: 0;
-}
-.oo-ui-menuLayout-menu,
-.oo-ui-menuLayout-content {
-       position: absolute;
-       -webkit-transition: all 200ms ease;
-          -moz-transition: all 200ms ease;
-               transition: all 200ms ease;
-}
-.oo-ui-menuLayout-menu {
-       height: 18em;
-       width: 18em;
-}
-.oo-ui-menuLayout-content {
-       top: 18em;
-       left: 18em;
-       right: 18em;
-       bottom: 18em;
-}
-.oo-ui-menuLayout.oo-ui-menuLayout-hideMenu > .oo-ui-menuLayout-menu {
-       width: 0 !important;
-       height: 0 !important;
-       overflow: hidden;
-}
-.oo-ui-menuLayout.oo-ui-menuLayout-hideMenu > .oo-ui-menuLayout-content {
-       top: 0 !important;
-       left: 0 !important;
-       right: 0 !important;
-       bottom: 0 !important;
-}
-.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-top > .oo-ui-menuLayout-menu {
-       width: auto !important;
-       left: 0;
-       top: 0;
-       right: 0;
-}
-.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-top > .oo-ui-menuLayout-content {
-       right: 0 !important;
-       bottom: 0 !important;
-       left: 0 !important;
-}
-.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-after > .oo-ui-menuLayout-menu {
-       height: auto !important;
-       top: 0;
-       right: 0;
-       bottom: 0;
-}
-.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-after > .oo-ui-menuLayout-content {
-       bottom: 0 !important;
-       left: 0 !important;
-       top: 0 !important;
-}
-.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-bottom > .oo-ui-menuLayout-menu {
-       width: auto !important;
-       right: 0;
-       bottom: 0;
-       left: 0;
-}
-.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-bottom > .oo-ui-menuLayout-content {
-       left: 0 !important;
-       top: 0 !important;
-       right: 0 !important;
-}
-.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-before > .oo-ui-menuLayout-menu {
-       height: auto !important;
-       bottom: 0;
-       left: 0;
-       top: 0;
-}
-.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-before > .oo-ui-menuLayout-content {
-       top: 0 !important;
-       right: 0 !important;
-       bottom: 0 !important;
-}
-.oo-ui-stackLayout-continuous > .oo-ui-panelLayout {
-       display: block;
-       position: relative;
-}
-.oo-ui-buttonSelectWidget {
-       display: inline-block;
-       white-space: nowrap;
-       border-radius: 0.3em;
-       margin-right: 0.5em;
-}
-.oo-ui-buttonSelectWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-buttonSelectWidget .oo-ui-buttonOptionWidget .oo-ui-buttonElement-button {
-       border-radius: 0;
-       margin-left: -1px;
-}
-.oo-ui-buttonSelectWidget .oo-ui-buttonOptionWidget:first-child .oo-ui-buttonElement-button {
-       border-bottom-left-radius: 0.3em;
-       border-top-left-radius: 0.3em;
-       margin-left: 0;
-}
-.oo-ui-buttonSelectWidget .oo-ui-buttonOptionWidget:last-child .oo-ui-buttonElement-button {
-       border-bottom-right-radius: 0.3em;
-       border-top-right-radius: 0.3em;
-}
-.oo-ui-buttonOptionWidget {
-       display: inline-block;
-       padding: 0;
-       background-color: transparent;
-}
-.oo-ui-buttonOptionWidget .oo-ui-buttonElement-button {
-       position: relative;
-}
-.oo-ui-buttonOptionWidget.oo-ui-iconElement .oo-ui-iconElement-icon,
-.oo-ui-buttonOptionWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
-       position: static;
-       display: inline-block;
-       vertical-align: middle;
-}
-.oo-ui-buttonOptionWidget .oo-ui-buttonElement-button {
-       height: 1.875em;
-}
-.oo-ui-buttonOptionWidget.oo-ui-iconElement .oo-ui-iconElement-icon {
-       margin-top: 0;
-}
-.oo-ui-buttonOptionWidget.oo-ui-optionWidget-selected,
-.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed,
-.oo-ui-buttonOptionWidget.oo-ui-optionWidget-highlighted {
-       background-color: transparent;
-}
-.oo-ui-toggleButtonWidget {
-       display: inline-block;
-       vertical-align: middle;
-       margin-right: 0.5em;
-}
-.oo-ui-toggleButtonWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-toggleSwitchWidget {
-       position: relative;
-       display: inline-block;
-       vertical-align: middle;
-       overflow: hidden;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-       -webkit-transform: translateZ(0);
-          -moz-transform: translateZ(0);
-           -ms-transform: translateZ(0);
-               transform: translateZ(0);
-       height: 2em;
-       width: 4em;
-       border-radius: 1em;
-       box-shadow: 0 0 0 white, inset 0 0.1em 0.2em #dddddd;
-       border: 1px solid #cccccc;
-       margin-right: 0.5em;
-       background-color: #eeeeee;
-       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #dddddd), color-stop(100%, #ffffff));
-       background-image: -webkit-linear-gradient(top, #dddddd 0, #ffffff 100%);
-       background-image:    -moz-linear-gradient(top, #dddddd 0, #ffffff 100%);
-       background-image:         linear-gradient(to bottom, #dddddd 0, #ffffff 100%);
-       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffdddddd', endColorstr='#ffffffff' )";
-}
-.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled {
-       cursor: pointer;
-}
-.oo-ui-toggleSwitchWidget-grip {
-       position: absolute;
-       display: block;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-toggleSwitchWidget .oo-ui-toggleSwitchWidget-glow {
-       position: absolute;
-       top: 0;
-       bottom: 0;
-       right: 0;
-       left: 0;
-       -webkit-touch-callout: none;
-       -webkit-user-select: none;
-          -moz-user-select: none;
-           -ms-user-select: none;
-               user-select: none;
-}
-.oo-ui-toggleWidget-off .oo-ui-toggleSwitchWidget-glow {
-       display: none;
-}
-.oo-ui-toggleSwitchWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-toggleSwitchWidget.oo-ui-widget-disabled {
-       opacity: 0.5;
-}
-.oo-ui-toggleSwitchWidget-grip {
-       top: 0.25em;
-       left: 0.25em;
-       width: 1.5em;
-       height: 1.5em;
-       margin-top: -1px;
-       border-radius: 1em;
-       box-shadow: 0 0.1em 0.25em rgba(0, 0, 0, 0.1);
-       border: 1px #c9c9c9 solid;
-       -webkit-transition: left 250ms ease, margin-left 250ms ease;
-          -moz-transition: left 250ms ease, margin-left 250ms ease;
-               transition: left 250ms ease, margin-left 250ms ease;
-       background-color: #eeeeee;
-       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #ffffff), color-stop(100%, #dddddd));
-       background-image: -webkit-linear-gradient(top, #ffffff 0, #dddddd 100%);
-       background-image:    -moz-linear-gradient(top, #ffffff 0, #dddddd 100%);
-       background-image:         linear-gradient(to bottom, #ffffff 0, #dddddd 100%);
-       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffffffff', endColorstr='#ffdddddd' )";
-}
-.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:hover,
-.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:hover .oo-ui-toggleSwitchWidget-grip {
-       border-color: #aaaaaa;
-}
-.oo-ui-toggleSwitchWidget .oo-ui-toggleSwitchWidget-glow {
-       border-radius: 1em;
-       box-shadow: inset 0 1px 4px 0 rgba(0, 0, 0, 0.07);
-       -webkit-transition: opacity 250ms ease;
-          -moz-transition: opacity 250ms ease;
-               transition: opacity 250ms ease;
-       background-color: #cde7f4;
-       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #b0d9ee), color-stop(100%, #eaf4fa));
-       background-image: -webkit-linear-gradient(top, #b0d9ee 0, #eaf4fa 100%);
-       background-image:    -moz-linear-gradient(top, #b0d9ee 0, #eaf4fa 100%);
-       background-image:         linear-gradient(to bottom, #b0d9ee 0, #eaf4fa 100%);
-       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffb0d9ee', endColorstr='#ffeaf4fa' )";
-}
-.oo-ui-toggleWidget-on .oo-ui-toggleSwitchWidget-glow {
-       opacity: 1;
-}
-.oo-ui-toggleWidget-on .oo-ui-toggleSwitchWidget-grip {
-       left: 2.25em;
-       margin-left: -2px;
-}
-.oo-ui-toggleWidget-off .oo-ui-toggleSwitchWidget-glow {
-       display: block;
-       opacity: 0;
-}
-.oo-ui-toggleWidget-off .oo-ui-toggleSwitchWidget-grip {
-       left: 0.25em;
-       margin-left: 0;
-}
-.oo-ui-progressBarWidget {
-       max-width: 50em;
-       background-color: #ffffff;
-       border: 1px solid #cccccc;
-       border-radius: 0.25em;
-       overflow: hidden;
-}
-.oo-ui-progressBarWidget-bar {
-       height: 1em;
-       border-right: 1px solid #cccccc;
-       -webkit-transition: width 250ms ease, margin-left 250ms ease;
-          -moz-transition: width 250ms ease, margin-left 250ms ease;
-               transition: width 250ms ease, margin-left 250ms ease;
-       background-color: #cde7f4;
-       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #eaf4fa), color-stop(100%, #b0d9ee));
-       background-image: -webkit-linear-gradient(top, #eaf4fa 0, #b0d9ee 100%);
-       background-image:    -moz-linear-gradient(top, #eaf4fa 0, #b0d9ee 100%);
-       background-image:         linear-gradient(to bottom, #eaf4fa 0, #b0d9ee 100%);
-       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffeaf4fa', endColorstr='#ffb0d9ee' )";
-}
-.oo-ui-progressBarWidget-indeterminate .oo-ui-progressBarWidget-bar {
-       -webkit-animation: oo-ui-progressBarWidget-slide 2s infinite linear;
-          -moz-animation: oo-ui-progressBarWidget-slide 2s infinite linear;
-               animation: oo-ui-progressBarWidget-slide 2s infinite linear;
-       width: 40%;
-       margin-left: -10%;
-       border-left: 1px solid #a6cee1;
-}
-.oo-ui-progressBarWidget.oo-ui-widget-disabled {
-       opacity: 0.6;
-}
-.oo-ui-selectFileWidget {
-       display: inline-block;
-       vertical-align: middle;
-       width: 100%;
-       max-width: 50em;
-       margin-right: 0.5em;
-}
-.oo-ui-selectFileWidget-selectButton {
-       display: table-cell;
-       vertical-align: middle;
-}
-.oo-ui-selectFileWidget-selectButton > .oo-ui-buttonElement-button {
-       position: relative;
-       overflow: hidden;
-}
-.oo-ui-selectFileWidget-selectButton > .oo-ui-buttonElement-button > input[type="file"] {
-       position: absolute;
-       margin: 0;
-       top: 0;
-       bottom: 0;
-       left: 0;
-       right: 0;
-       width: 100%;
-       height: 100%;
-       opacity: 0;
-       z-index: 1;
-       cursor: pointer;
-       padding-top: 100px;
-}
-.oo-ui-selectFileWidget-selectButton.oo-ui-widget-disabled > .oo-ui-buttonElement-button > input[type="file"] {
-       display: none;
-}
-.oo-ui-selectFileWidget-info {
-       width: 100%;
-       display: table-cell;
-       vertical-align: middle;
-       position: relative;
-       overflow: hidden;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-label {
-       position: absolute;
-       top: 0;
-       bottom: 0;
-       left: 0;
-       right: 0;
-       text-overflow: ellipsis;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-label > .oo-ui-selectFileWidget-fileName {
-       float: left;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-label > .oo-ui-selectFileWidget-fileType {
-       float: right;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-indicatorElement-indicator,
-.oo-ui-selectFileWidget-info > .oo-ui-iconElement-icon,
-.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-clearButton {
-       position: absolute;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-clearButton {
-       z-index: 2;
-}
-.oo-ui-selectFileWidget-dropTarget {
-       cursor: default;
-}
-.oo-ui-selectFileWidget-supported.oo-ui-widget-enabled .oo-ui-selectFileWidget-dropTarget {
-       cursor: pointer;
-}
-.oo-ui-selectFileWidget-empty .oo-ui-selectFileWidget-clearButton,
-.oo-ui-selectFileWidget-notsupported .oo-ui-selectFileWidget-clearButton {
-       display: none;
-}
-.oo-ui-selectFileWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-selectFileWidget-selectButton > .oo-ui-buttonElement-button {
-       margin-left: 0.5em;
-}
-.oo-ui-selectFileWidget-info {
-       height: 2.4em;
-       background-color: #ffffff;
-       border: 1px solid rgba(0, 0, 0, 0.1);
-       border-radius: 0.25em;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-indicatorElement-indicator {
-       right: 0;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-iconElement-icon {
-       left: 0;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-label {
-       line-height: 2.3em;
-       margin: 0;
-       overflow: hidden;
-       white-space: nowrap;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-       text-overflow: ellipsis;
-       left: 0.5em;
-       right: 0.5em;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-label > .oo-ui-selectFileWidget-fileType {
-       color: #888888;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-clearButton {
-       top: 0;
-       width: 1.875em;
-       margin-right: 0;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-clearButton .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
-       height: 2.3em;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-indicatorElement-indicator {
-       top: 0;
-       width: 0.9375em;
-       height: 2.3em;
-       margin-right: 0.775em;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-iconElement-icon {
-       top: 0;
-       width: 1.875em;
-       height: 2.3em;
-       margin-left: 0.3em;
-}
-.oo-ui-selectFileWidget.oo-ui-widget-disabled .oo-ui-selectFileWidget-info {
-       color: #cccccc;
-       text-shadow: 0 1px 1px #ffffff;
-       border-color: #dddddd;
-       background-color: #f3f3f3;
-}
-.oo-ui-selectFileWidget.oo-ui-widget-disabled .oo-ui-selectFileWidget-info > .oo-ui-iconElement-icon,
-.oo-ui-selectFileWidget.oo-ui-widget-disabled .oo-ui-selectFileWidget-info > .oo-ui-indicatorElement-indicator {
-       opacity: 0.2;
-}
-.oo-ui-selectFileWidget-empty .oo-ui-selectFileWidget-label {
-       color: #cccccc;
-}
-.oo-ui-selectFileWidget.oo-ui-iconElement .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label {
-       left: 2.475em;
-}
-.oo-ui-selectFileWidget .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label {
-       right: 2.175em;
-}
-.oo-ui-selectFileWidget .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-clearButton {
-       right: 0;
-}
-.oo-ui-selectFileWidget.oo-ui-indicatorElement .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label {
-       right: 4.2625em;
-}
-.oo-ui-selectFileWidget.oo-ui-indicatorElement .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-clearButton {
-       right: 2.0875em;
-}
-.oo-ui-selectFileWidget-empty .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label,
-.oo-ui-selectFileWidget-notsupported .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label {
-       right: 0.5em;
-}
-.oo-ui-selectFileWidget-empty.oo-ui-indicatorElement .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label,
-.oo-ui-selectFileWidget-notsupported.oo-ui-indicatorElement .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label {
-       right: 2em;
-}
-.oo-ui-selectFileWidget-dropTarget {
-       line-height: 3.5em;
-       background-color: #ffffff;
-       border: 1px dashed #aaaaaa;
-       padding: 0.5em 1em;
-       margin-bottom: 0.5em;
-       text-align: center;
-       vertical-align: middle;
-}
-.oo-ui-selectFileWidget-supported.oo-ui-widget-enabled .oo-ui-selectFileWidget-dropTarget:hover,
-.oo-ui-selectFileWidget-supported.oo-ui-widget-enabled.oo-ui-selectFileWidget-canDrop oo-ui-selectfilewidget-droptarget {
-       background-color: #e1f3ff;
-}
-.oo-ui-selectFileWidget.oo-ui-widget-disabled .oo-ui-selectFileWidget-dropTarget,
-.oo-ui-selectFileWidget-notsupported .oo-ui-selectFileWidget-dropTarget {
-       color: #cccccc;
-       text-shadow: 0 1px 1px #ffffff;
-       border-color: #dddddd;
-       background-color: #f3f3f3;
-}
-.oo-ui-outlineOptionWidget {
-       position: relative;
-       cursor: pointer;
-       -webkit-touch-callout: none;
-       -webkit-user-select: none;
-          -moz-user-select: none;
-           -ms-user-select: none;
-               user-select: none;
-       font-size: 1.1em;
-       padding: 0.75em;
-}
-.oo-ui-outlineOptionWidget.oo-ui-indicatorElement .oo-ui-labelElement-label {
-       padding-right: 1.5em;
-}
-.oo-ui-outlineOptionWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
-       opacity: 0.5;
-}
-.oo-ui-outlineOptionWidget-level-0 {
-       padding-left: 3.5em;
-}
-.oo-ui-outlineOptionWidget-level-0 .oo-ui-iconElement-icon {
-       left: 1em;
-}
-.oo-ui-outlineOptionWidget-level-1 {
-       padding-left: 5em;
-}
-.oo-ui-outlineOptionWidget-level-1 .oo-ui-iconElement-icon {
-       left: 2.5em;
-}
-.oo-ui-outlineOptionWidget-level-2 {
-       padding-left: 6.5em;
-}
-.oo-ui-outlineOptionWidget-level-2 .oo-ui-iconElement-icon {
-       left: 4em;
-}
-.oo-ui-selectWidget-depressed .oo-ui-outlineOptionWidget.oo-ui-optionWidget-selected {
-       background-color: #a7dcff;
-       text-shadow: 0 1px 1px rgba(255, 255, 255, 0.5);
-}
-.oo-ui-outlineOptionWidget.oo-ui-flaggedElement-important {
-       font-weight: bold;
-}
-.oo-ui-outlineOptionWidget.oo-ui-flaggedElement-placeholder {
-       font-style: italic;
-}
-.oo-ui-outlineOptionWidget.oo-ui-flaggedElement-empty .oo-ui-iconElement-icon {
-       opacity: 0.5;
-}
-.oo-ui-outlineOptionWidget.oo-ui-flaggedElement-empty .oo-ui-labelElement-label {
-       color: #777777;
-}
-.oo-ui-outlineControlsWidget {
-       height: 3em;
-       background-color: #ffffff;
-}
-.oo-ui-outlineControlsWidget-items,
-.oo-ui-outlineControlsWidget-movers {
-       float: left;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-outlineControlsWidget > .oo-ui-iconElement-icon {
-       float: left;
-       background-position: right center;
-}
-.oo-ui-outlineControlsWidget-items {
-       float: left;
-}
-.oo-ui-outlineControlsWidget-items .oo-ui-buttonWidget {
-       float: left;
-}
-.oo-ui-outlineControlsWidget-movers {
-       float: right;
-}
-.oo-ui-outlineControlsWidget-movers .oo-ui-buttonWidget {
-       float: right;
-}
-.oo-ui-outlineControlsWidget-items,
-.oo-ui-outlineControlsWidget-movers {
-       height: 2em;
-       margin: 0.5em 0.5em 0.5em 0;
-       padding: 0;
-}
-.oo-ui-outlineControlsWidget > .oo-ui-iconElement-icon {
-       width: 1.5em;
-       height: 2em;
-       margin: 0.5em 0 0.5em 0.5em;
-       opacity: 0.2;
-}
-.oo-ui-tabSelectWidget {
-       text-align: left;
-       white-space: nowrap;
-       overflow: hidden;
-       background-color: #eeeeee;
-       box-shadow: inset 0 -0.015em 0.1em rgba(0, 0, 0, 0.1);
-}
-.oo-ui-tabOptionWidget {
-       display: inline-block;
-       vertical-align: bottom;
-       padding: 0.5em 1em;
-       margin: 0.5em 0 0 0.75em;
-       border: 1px solid transparent;
-       border-bottom: none;
-       border-top-left-radius: 0.5em;
-       border-top-right-radius: 0.5em;
-}
-.oo-ui-tabOptionWidget.oo-ui-indicatorElement .oo-ui-labelElement-label {
-       padding-right: 1.5em;
-}
-.oo-ui-tabOptionWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
-       opacity: 0.5;
-}
-.oo-ui-selectWidget-pressed .oo-ui-tabOptionWidget.oo-ui-optionWidget-pressed {
-       background-color: transparent;
-}
-.oo-ui-tabOptionWidget.oo-ui-widget-enabled:hover {
-       background-color: rgba(255, 255, 255, 0.2);
-       border-color: #dddddd;
-}
-.oo-ui-tabOptionWidget.oo-ui-widget-enabled:active {
-       background-color: #ffffff;
-       border-color: #dddddd;
-}
-.oo-ui-selectWidget-pressed .oo-ui-tabOptionWidget.oo-ui-optionWidget-selected,
-.oo-ui-selectWidget-depressed .oo-ui-tabOptionWidget.oo-ui-optionWidget-selected,
-.oo-ui-tabOptionWidget.oo-ui-optionWidget-selected:hover {
-       background-color: #ffffff;
-       border-color: #dddddd;
-}
-.oo-ui-capsuleMultiSelectWidget {
-       display: inline-block;
-       position: relative;
-       width: 100%;
-       max-width: 50em;
-}
-.oo-ui-capsuleMultiSelectWidget-handle {
-       width: 100%;
-       display: inline-block;
-       position: relative;
-}
-.oo-ui-capsuleMultiSelectWidget-content {
-       position: relative;
-}
-.oo-ui-capsuleMultiSelectWidget.oo-ui-widget-disabled .oo-ui-capsuleMultiSelectWidget-content > input {
-       display: none;
-}
-.oo-ui-capsuleMultiSelectWidget-group {
-       display: inline;
-}
-.oo-ui-capsuleMultiSelectWidget > .oo-ui-menuSelectWidget {
-       z-index: 1;
-       width: 100%;
-}
-.oo-ui-capsuleMultiSelectWidget-handle {
-       background-color: #ffffff;
-       cursor: text;
-       min-height: 2.4em;
-       margin-right: 0.5em;
-       padding: 0.15em 0.25em;
-       border: 1px solid rgba(0, 0, 0, 0.1);
-       border-radius: 0.25em;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-capsuleMultiSelectWidget-handle:last-child {
-       margin-right: 0;
-}
-.oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-indicatorElement-indicator,
-.oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-iconElement-icon {
-       position: absolute;
-       background-position: center center;
-       background-repeat: no-repeat;
-}
-.oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-capsuleMultiSelectWidget-content > input {
-       border: none;
-       line-height: 1.675em;
-       margin: 0;
-       margin-left: 0.2em;
-       padding: 0;
-       font-size: inherit;
-       font-family: inherit;
-       background-color: transparent;
-       color: black;
-       vertical-align: middle;
-}
-.oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-capsuleMultiSelectWidget-content > input:focus {
-       outline: none;
-}
-.oo-ui-capsuleMultiSelectWidget.oo-ui-indicatorElement .oo-ui-capsuleMultiSelectWidget-handle {
-       padding-right: 2.4875em;
-}
-.oo-ui-capsuleMultiSelectWidget.oo-ui-indicatorElement .oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-indicatorElement-indicator {
-       right: 0;
-       top: 0;
-       width: 0.9375em;
-       height: 0.9375em;
-       margin: 0.775em;
-}
-.oo-ui-capsuleMultiSelectWidget.oo-ui-iconElement .oo-ui-capsuleMultiSelectWidget-handle {
-       padding-left: 2.475em;
-}
-.oo-ui-capsuleMultiSelectWidget.oo-ui-iconElement .oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-iconElement-icon {
-       left: 0;
-       top: 0;
-       width: 1.875em;
-       height: 1.875em;
-       margin: 0.3em;
-}
-.oo-ui-capsuleMultiSelectWidget:hover .oo-ui-capsuleMultiSelectWidget-handle {
-       border-color: rgba(0, 0, 0, 0.2);
-}
-.oo-ui-capsuleMultiSelectWidget.oo-ui-widget-disabled .oo-ui-capsuleMultiSelectWidget-handle {
-       color: #cccccc;
-       text-shadow: 0 1px 1px #ffffff;
-       border-color: #dddddd;
-       background-color: #f3f3f3;
-       cursor: default;
-}
-.oo-ui-capsuleMultiSelectWidget.oo-ui-widget-disabled .oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-iconElement-icon,
-.oo-ui-capsuleMultiSelectWidget.oo-ui-widget-disabled .oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-indicatorElement-indicator {
-       opacity: 0.2;
-}
-.oo-ui-capsuleMultiSelectWidget .oo-ui-selectWidget {
-       border-top-color: #ffffff;
-}
-.oo-ui-capsuleItemWidget {
-       position: relative;
-       display: inline-block;
-       cursor: default;
-       white-space: nowrap;
-       width: auto;
-       max-width: 100%;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-       vertical-align: middle;
-       padding: 0 0.4em;
-       margin: 0.1em;
-       height: 1.7em;
-       line-height: 1.7em;
-       background-color: #eeeeee;
-       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #ffffff), color-stop(100%, #dddddd));
-       background-image: -webkit-linear-gradient(top, #ffffff 0, #dddddd 100%);
-       background-image:    -moz-linear-gradient(top, #ffffff 0, #dddddd 100%);
-       background-image:         linear-gradient(to bottom, #ffffff 0, #dddddd 100%);
-       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffffffff', endColorstr='#ffdddddd' )";
-       border: 1px solid #cccccc;
-       color: #555555;
-       border-radius: 0.25em;
-}
-.oo-ui-capsuleItemWidget > .oo-ui-iconElement-icon {
-       cursor: pointer;
-}
-.oo-ui-capsuleItemWidget.oo-ui-widget-disabled > .oo-ui-iconElement-icon {
-       cursor: default;
-}
-.oo-ui-capsuleItemWidget.oo-ui-labelElement .oo-ui-labelElement-label {
-       display: block;
-       text-overflow: ellipsis;
-       overflow: hidden;
-}
-.oo-ui-capsuleItemWidget.oo-ui-indicatorElement > .oo-ui-labelElement-label {
-       padding-right: 1.3375em;
-}
-.oo-ui-capsuleItemWidget.oo-ui-indicatorElement > .oo-ui-indicatorElement-indicator {
-       position: absolute;
-       right: 0.4em;
-       top: 0;
-       width: 0.9375em;
-       height: 100%;
-       background-repeat: no-repeat;
-}
-.oo-ui-capsuleItemWidget.oo-ui-indicatorElement > .oo-ui-indicator-clear {
-       cursor: pointer;
-}
-.oo-ui-capsuleItemWidget.oo-ui-widget-disabled {
-       opacity: 0.5;
-       -webkit-transform: translate3d(0, 0, 0);
-       box-shadow: none;
-       color: #333333;
-       background: #eeeeee;
-       border-color: #cccccc;
-}
-.oo-ui-capsuleItemWidget.oo-ui-widget-disabled > .oo-ui-indicatorElement-indicator {
-       opacity: 0.2;
-}
-.oo-ui-searchWidget-query {
-       position: absolute;
-       top: 0;
-       left: 0;
-       right: 0;
-}
-.oo-ui-searchWidget-query .oo-ui-textInputWidget {
-       width: 100%;
-}
-.oo-ui-searchWidget-results {
-       position: absolute;
-       bottom: 0;
-       left: 0;
-       right: 0;
-       overflow-x: hidden;
-       overflow-y: auto;
-}
-.oo-ui-searchWidget-query {
-       height: 4em;
-       padding: 0 1em;
-       box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.2);
-}
-.oo-ui-searchWidget-query .oo-ui-textInputWidget {
-       margin: 0.75em 0;
-}
-.oo-ui-searchWidget-results {
-       top: 4em;
-       padding: 1em;
-       line-height: 0;
-}
-.oo-ui-numberInputWidget {
-       display: inline-block;
-       position: relative;
-       max-width: 50em;
-}
-.oo-ui-numberInputWidget-field {
-       display: table;
-       table-layout: fixed;
-       width: 100%;
-}
-.oo-ui-numberInputWidget-field > .oo-ui-buttonWidget,
-.oo-ui-numberInputWidget-field > .oo-ui-textInputWidget {
-       display: table-cell;
-       vertical-align: middle;
-}
-.oo-ui-numberInputWidget-field > .oo-ui-textInputWidget {
-       width: 100%;
-}
-.oo-ui-numberInputWidget-field > .oo-ui-buttonWidget {
-       white-space: nowrap;
-}
-.oo-ui-numberInputWidget-field > .oo-ui-buttonWidget > .oo-ui-buttonElement-button {
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-numberInputWidget-field > .oo-ui-buttonWidget {
-       width: 2.25em;
-}
-.oo-ui-numberInputWidget-minusButton.oo-ui-buttonElement-framed.oo-ui-widget-enabled > .oo-ui-buttonElement-button {
-       border-top-right-radius: 0;
-       border-bottom-right-radius: 0;
-       border-right-width: 0;
-}
-.oo-ui-numberInputWidget-plusButton.oo-ui-buttonElement-framed.oo-ui-widget-enabled > .oo-ui-buttonElement-button {
-       border-top-left-radius: 0;
-       border-bottom-left-radius: 0;
-       border-left-width: 0;
-}
-.oo-ui-numberInputWidget .oo-ui-textInputWidget input {
-       border-radius: 0;
-}
-
-/*!
- * OOjs UI v0.15.2
- * https://www.mediawiki.org/wiki/OOjs_UI
- *
- * Copyright 2011–2016 OOjs UI Team and other contributors.
- * Released under the MIT license
- * http://oojs.mit-license.org
- *
- * Date: 2016-02-02T22:07:06Z
- */
-@-webkit-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@-moz-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@-ms-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@-o-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-.oo-ui-popupTool .oo-ui-popupWidget-popup,
-.oo-ui-popupTool .oo-ui-popupWidget-anchor {
-       z-index: 4;
-}
-.oo-ui-popupTool .oo-ui-popupWidget {
-       /* @noflip */
-       margin-left: 1.25em;
-}
-.oo-ui-toolGroupTool > .oo-ui-popupToolGroup {
-       border: 0;
-       border-radius: 0;
-       margin: 0;
-}
-.oo-ui-toolGroupTool:first-child > .oo-ui-popupToolGroup {
-       border-top-left-radius: 0.3125em;
-       border-bottom-left-radius: 0.3125em;
-}
-.oo-ui-toolGroupTool:last-child > .oo-ui-popupToolGroup {
-       border-top-right-radius: 0.3125em;
-       border-bottom-right-radius: 0.3125em;
-}
-.oo-ui-toolGroupTool > .oo-ui-popupToolGroup > .oo-ui-popupToolGroup-handle {
-       height: 1.875em;
-       padding: 0.3125em;
-}
-.oo-ui-toolGroupTool > .oo-ui-popupToolGroup > .oo-ui-popupToolGroup-handle .oo-ui-iconElement-icon {
-       height: 1.875em;
-       width: 1.875em;
-}
-.oo-ui-toolGroupTool > .oo-ui-popupToolGroup.oo-ui-labelElement > .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
-       line-height: 2.1em;
-}
-.oo-ui-toolGroup {
-       display: inline-block;
-       vertical-align: middle;
-       margin: 0.375em;
-       border-radius: 0.3125em;
-       border: 1px solid transparent;
-       -webkit-transition: border-color 250ms ease;
-          -moz-transition: border-color 250ms ease;
-               transition: border-color 250ms ease;
-}
-.oo-ui-toolGroup-empty {
-       display: none;
-}
-.oo-ui-toolGroup .oo-ui-tool-link {
-       text-decoration: none;
-}
-.oo-ui-toolbar-narrow .oo-ui-toolGroup + .oo-ui-toolGroup {
-       margin-left: 0;
-}
-.oo-ui-toolGroup.oo-ui-widget-enabled:hover {
-       border-color: rgba(0, 0, 0, 0.1);
-}
-.oo-ui-toolGroup.oo-ui-widget-enabled .oo-ui-tool-link .oo-ui-tool-title {
-       color: #000000;
-}
-.oo-ui-barToolGroup > .oo-ui-iconElement-icon,
-.oo-ui-barToolGroup > .oo-ui-labelElement-label {
-       display: none;
-}
-.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link {
-       cursor: pointer;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool {
-       display: inline-block;
-       position: relative;
-       vertical-align: top;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link {
-       display: block;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link .oo-ui-tool-accel {
-       display: none;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-iconElement > .oo-ui-tool-link .oo-ui-iconElement-icon {
-       display: inline-block;
-       vertical-align: top;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-iconElement > .oo-ui-tool-link .oo-ui-tool-title {
-       display: none;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-iconElement.oo-ui-tool-with-label > .oo-ui-tool-link .oo-ui-tool-title {
-       display: inline;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-disabled > .oo-ui-tool-link {
-       outline: 0;
-       cursor: default;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool {
-       margin: -1px 0 -1px -1px;
-       border: 1px solid transparent;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool:first-child {
-       border-top-left-radius: 0.3125em;
-       border-bottom-left-radius: 0.3125em;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool:last-child {
-       margin-right: -1px;
-       border-top-right-radius: 0.3125em;
-       border-bottom-right-radius: 0.3125em;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link {
-       height: 1.875em;
-       padding: 0.3125em;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link .oo-ui-iconElement-icon {
-       height: 1.875em;
-       width: 1.875em;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link .oo-ui-tool-title {
-       line-height: 2.1em;
-}
-.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-enabled:hover {
-       border-color: rgba(0, 0, 0, 0.2);
-}
-.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-tool-active.oo-ui-widget-enabled {
-       border-color: rgba(0, 0, 0, 0.2);
-       box-shadow: inset 0 0.0875em 0.0875em 0 rgba(0, 0, 0, 0.07);
-       background-color: #f8fbfd;
-       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #f1f7fb), color-stop(100%, #ffffff));
-       background-image: -webkit-linear-gradient(top, #f1f7fb 0, #ffffff 100%);
-       background-image:    -moz-linear-gradient(top, #f1f7fb 0, #ffffff 100%);
-       background-image:         linear-gradient(to bottom, #f1f7fb 0, #ffffff 100%);
-       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#fff1f7fb', endColorstr='#ffffffff' )";
-}
-.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-tool-active.oo-ui-widget-enabled + .oo-ui-tool-active.oo-ui-widget-enabled {
-       border-left-color: rgba(0, 0, 0, 0.1);
-}
-.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-disabled > .oo-ui-tool-link:focus {
-       outline: 0;
-}
-.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-disabled > .oo-ui-tool-link .oo-ui-tool-title {
-       color: #cccccc;
-}
-.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-disabled > .oo-ui-tool-link .oo-ui-iconElement-icon {
-       opacity: 0.2;
-}
-.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-enabled:hover > .oo-ui-tool-link .oo-ui-iconElement-icon {
-       opacity: 1;
-}
-.oo-ui-barToolGroup.oo-ui-widget-disabled > .oo-ui-toolGroup-tools > .oo-ui-tool:focus {
-       outline: 0;
-}
-.oo-ui-barToolGroup.oo-ui-widget-disabled > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link:focus {
-       outline: 0;
-}
-.oo-ui-barToolGroup.oo-ui-widget-disabled > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link .oo-ui-tool-title {
-       color: #cccccc;
-}
-.oo-ui-barToolGroup.oo-ui-widget-disabled > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link .oo-ui-iconElement-icon {
-       opacity: 0.2;
-}
-.oo-ui-popupToolGroup {
-       position: relative;
-       height: 2.5em;
-       min-width: 2.5em;
-}
-.oo-ui-popupToolGroup-handle {
-       display: block;
-       cursor: pointer;
-}
-.oo-ui-popupToolGroup-handle .oo-ui-indicatorElement-indicator,
-.oo-ui-popupToolGroup-handle .oo-ui-iconElement-icon {
-       position: absolute;
-}
-.oo-ui-popupToolGroup.oo-ui-widget-disabled .oo-ui-popupToolGroup-handle {
-       outline: 0;
-       cursor: default;
-}
-.oo-ui-popupToolGroup .oo-ui-toolGroup-tools {
-       display: none;
-       position: absolute;
-       z-index: 4;
-}
-.oo-ui-popupToolGroup-active.oo-ui-widget-enabled > .oo-ui-toolGroup-tools {
-       display: block;
-}
-.oo-ui-popupToolGroup-left > .oo-ui-toolGroup-tools {
-       left: 0;
-}
-.oo-ui-popupToolGroup-right > .oo-ui-toolGroup-tools {
-       right: 0;
-}
-.oo-ui-popupToolGroup .oo-ui-tool-link {
-       display: table;
-       width: 100%;
-       vertical-align: middle;
-       white-space: nowrap;
-}
-.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-iconElement-icon,
-.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-accel,
-.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-title {
-       display: table-cell;
-       vertical-align: middle;
-}
-.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-accel {
-       text-align: right;
-}
-.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-accel:not(:empty) {
-       padding-left: 3em;
-}
-.oo-ui-toolbar-narrow .oo-ui-popupToolGroup {
-       min-width: 1.875em;
-}
-.oo-ui-popupToolGroup.oo-ui-iconElement {
-       min-width: 3.125em;
-}
-.oo-ui-toolbar-narrow .oo-ui-popupToolGroup.oo-ui-iconElement {
-       min-width: 2.5em;
-}
-.oo-ui-popupToolGroup.oo-ui-indicatorElement.oo-ui-iconElement {
-       min-width: 4.375em;
-}
-.oo-ui-toolbar-narrow .oo-ui-popupToolGroup.oo-ui-indicatorElement.oo-ui-iconElement {
-       min-width: 3.75em;
-}
-.oo-ui-popupToolGroup.oo-ui-labelElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
-       line-height: 2.6em;
-       margin: 0 1em;
-}
-.oo-ui-toolbar-narrow .oo-ui-popupToolGroup.oo-ui-labelElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
-       margin: 0 0.5em;
-}
-.oo-ui-popupToolGroup.oo-ui-labelElement.oo-ui-iconElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
-       margin-left: 3em;
-}
-.oo-ui-toolbar-narrow .oo-ui-popupToolGroup.oo-ui-labelElement.oo-ui-iconElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
-       margin-left: 2.5em;
-}
-.oo-ui-popupToolGroup.oo-ui-labelElement.oo-ui-indicatorElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
-       margin-right: 2.25em;
-}
-.oo-ui-toolbar-narrow .oo-ui-popupToolGroup.oo-ui-labelElement.oo-ui-indicatorElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
-       margin-right: 1.75em;
-}
-.oo-ui-popupToolGroup-handle .oo-ui-indicatorElement-indicator {
-       width: 0.9375em;
-       height: 0.9375em;
-       margin: 0.78125em;
-       top: 0;
-       right: 0;
-}
-.oo-ui-toolbar-narrow .oo-ui-popupToolGroup-handle .oo-ui-indicatorElement-indicator {
-       right: -0.3125em;
-}
-.oo-ui-popupToolGroup-handle .oo-ui-iconElement-icon {
-       width: 1.875em;
-       height: 1.875em;
-       margin: 0.3125em;
-       top: 0;
-       left: 0.3125em;
-}
-.oo-ui-toolbar-narrow .oo-ui-popupToolGroup-handle .oo-ui-iconElement-icon {
-       left: 0;
-}
-.oo-ui-popupToolGroup-header {
-       line-height: 2.6em;
-       margin: 0 0.6em;
-       font-weight: bold;
-}
-.oo-ui-popupToolGroup-active.oo-ui-widget-enabled {
-       border-bottom-left-radius: 0;
-       border-bottom-right-radius: 0;
-       box-shadow: inset 0 0.0875em 0.0875em 0 rgba(0, 0, 0, 0.07);
-       background-color: #f8fbfd;
-       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #f1f7fb), color-stop(100%, #ffffff));
-       background-image: -webkit-linear-gradient(top, #f1f7fb 0, #ffffff 100%);
-       background-image:    -moz-linear-gradient(top, #f1f7fb 0, #ffffff 100%);
-       background-image:         linear-gradient(to bottom, #f1f7fb 0, #ffffff 100%);
-       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#fff1f7fb', endColorstr='#ffffffff' )";
-}
-.oo-ui-popupToolGroup .oo-ui-toolGroup-tools {
-       top: 2.5em;
-       margin: 0 -1px;
-       border: 1px solid #cccccc;
-       background-color: white;
-       box-shadow: 0 0.3125em 1.25em rgba(0, 0, 0, 0.25);
-}
-.oo-ui-popupToolGroup .oo-ui-tool-link {
-       padding: 0.3125em 0 0.3125em 0.3125em;
-}
-.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-iconElement-icon {
-       height: 1.875em;
-       width: 1.875em;
-       min-width: 1.875em;
-}
-.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-title {
-       padding-left: 0.5em;
-}
-.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-accel,
-.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-title {
-       line-height: 2em;
-}
-.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-accel {
-       color: #888888;
-}
-.oo-ui-listToolGroup .oo-ui-tool {
-       display: block;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-listToolGroup .oo-ui-tool-link {
-       cursor: pointer;
-}
-.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link {
-       cursor: default;
-}
-.oo-ui-listToolGroup .oo-ui-toolGroup-tools {
-       padding: 0.3125em;
-}
-.oo-ui-listToolGroup.oo-ui-popupToolGroup-active {
-       border-color: rgba(0, 0, 0, 0.2);
-}
-.oo-ui-listToolGroup .oo-ui-tool {
-       border: 1px solid transparent;
-       margin: -1px 0;
-       padding: 0 0.625em 0 0;
-}
-.oo-ui-listToolGroup .oo-ui-tool-active.oo-ui-widget-enabled {
-       border-color: rgba(0, 0, 0, 0.1);
-       box-shadow: inset 0 0.0875em 0.0875em 0 rgba(0, 0, 0, 0.07);
-       background-color: #f8fbfd;
-       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #f1f7fb), color-stop(100%, #ffffff));
-       background-image: -webkit-linear-gradient(top, #f1f7fb 0, #ffffff 100%);
-       background-image:    -moz-linear-gradient(top, #f1f7fb 0, #ffffff 100%);
-       background-image:         linear-gradient(to bottom, #f1f7fb 0, #ffffff 100%);
-       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#fff1f7fb', endColorstr='#ffffffff' )";
-}
-.oo-ui-listToolGroup .oo-ui-tool-active.oo-ui-widget-enabled + .oo-ui-tool-active.oo-ui-widget-enabled {
-       border-top-color: rgba(0, 0, 0, 0.1);
-}
-.oo-ui-listToolGroup .oo-ui-tool-active.oo-ui-widget-enabled:hover {
-       border-color: rgba(0, 0, 0, 0.2);
-}
-.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-enabled:hover {
-       border-color: rgba(0, 0, 0, 0.2);
-}
-.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-enabled:hover .oo-ui-tool-link .oo-ui-iconElement-icon {
-       opacity: 1;
-}
-.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link .oo-ui-tool-title {
-       color: #cccccc;
-}
-.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link .oo-ui-tool-accel {
-       color: #dddddd;
-}
-.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link .oo-ui-iconElement-icon {
-       opacity: 0.2;
-}
-.oo-ui-listToolGroup.oo-ui-widget-disabled {
-       color: #cccccc;
-}
-.oo-ui-listToolGroup.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator,
-.oo-ui-listToolGroup.oo-ui-widget-disabled .oo-ui-iconElement-icon {
-       opacity: 0.2;
-}
-.oo-ui-menuToolGroup {
-       border-color: rgba(0, 0, 0, 0.1);
-}
-.oo-ui-menuToolGroup .oo-ui-tool {
-       display: block;
-}
-.oo-ui-menuToolGroup .oo-ui-tool-link {
-       cursor: pointer;
-}
-.oo-ui-menuToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link {
-       cursor: default;
-}
-.oo-ui-menuToolGroup .oo-ui-popupToolGroup-handle {
-       min-width: 10em;
-}
-.oo-ui-toolbar-narrow .oo-ui-menuToolGroup .oo-ui-popupToolGroup-handle {
-       min-width: 8.125em;
-}
-.oo-ui-menuToolGroup .oo-ui-toolGroup-tools {
-       padding: 0.3125em 0 0.3125em 0;
-}
-.oo-ui-menuToolGroup.oo-ui-widget-enabled:hover {
-       border-color: rgba(0, 0, 0, 0.2);
-}
-.oo-ui-menuToolGroup.oo-ui-popupToolGroup-active {
-       border-color: rgba(0, 0, 0, 0.25);
-}
-.oo-ui-menuToolGroup .oo-ui-tool {
-       padding: 0 1.25em 0 0.3125em;
-}
-.oo-ui-menuToolGroup .oo-ui-tool-link .oo-ui-iconElement-icon {
-       background-image: none;
-}
-.oo-ui-menuToolGroup .oo-ui-tool-active .oo-ui-tool-link .oo-ui-iconElement-icon {
-       background-image: url("themes/apex/images/icons/check.png");
-       background-image: -webkit-linear-gradient(transparent, transparent), /* @embed */ url("themes/apex/images/icons/check.svg");
-       background-image:         linear-gradient(transparent, transparent), /* @embed */ url("themes/apex/images/icons/check.svg");
-       background-image:      -o-linear-gradient(transparent, transparent), url("themes/apex/images/icons/check.png");
-       background-size: contain;
-       background-position: center center;
-       background-repeat: no-repeat;
-}
-.oo-ui-menuToolGroup .oo-ui-tool.oo-ui-widget-enabled:hover {
-       background-color: #e1f3ff;
-}
-.oo-ui-menuToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link .oo-ui-tool-title {
-       color: #cccccc;
-}
-.oo-ui-menuToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link .oo-ui-iconElement-icon {
-       opacity: 0.2;
-}
-.oo-ui-menuToolGroup.oo-ui-widget-disabled {
-       color: #cccccc;
-       border-color: rgba(0, 0, 0, 0.05);
-}
-.oo-ui-menuToolGroup.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator,
-.oo-ui-menuToolGroup.oo-ui-widget-disabled .oo-ui-iconElement-icon {
-       opacity: 0.2;
-}
-.oo-ui-toolbar {
-       clear: both;
-}
-.oo-ui-toolbar-bar {
-       line-height: 1em;
-       position: relative;
-}
-.oo-ui-toolbar-actions {
-       float: right;
-}
-.oo-ui-toolbar-actions .oo-ui-toolbar {
-       display: inline-block;
-}
-.oo-ui-toolbar-tools {
-       display: inline;
-       white-space: nowrap;
-}
-.oo-ui-toolbar-narrow .oo-ui-toolbar-tools {
-       white-space: normal;
-}
-.oo-ui-toolbar-tools .oo-ui-tool {
-       white-space: normal;
-}
-.oo-ui-toolbar-tools,
-.oo-ui-toolbar-actions,
-.oo-ui-toolbar-shadow {
-       -webkit-touch-callout: none;
-       -webkit-user-select: none;
-          -moz-user-select: none;
-           -ms-user-select: none;
-               user-select: none;
-}
-.oo-ui-toolbar-actions .oo-ui-popupWidget {
-       -webkit-touch-callout: default;
-       -webkit-user-select: all;
-          -moz-user-select: all;
-           -ms-user-select: all;
-               user-select: all;
-}
-.oo-ui-toolbar-shadow {
-       background-position: left top;
-       background-repeat: repeat-x;
-       position: absolute;
-       width: 100%;
-       pointer-events: none;
-}
-.oo-ui-toolbar-bar {
-       border-bottom: 1px solid #cccccc;
-       background-color: #f8fbfd;
-       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #ffffff), color-stop(100%, #f1f7fb));
-       background-image: -webkit-linear-gradient(top, #ffffff 0, #f1f7fb 100%);
-       background-image:    -moz-linear-gradient(top, #ffffff 0, #f1f7fb 100%);
-       background-image:         linear-gradient(to bottom, #ffffff 0, #f1f7fb 100%);
-       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffffffff', endColorstr='#fff1f7fb' )";
-}
-.oo-ui-toolbar-bar .oo-ui-toolbar-bar {
-       border: none;
-       background: none;
-}
-.oo-ui-toolbar-actions > .oo-ui-buttonElement-framed,
-.oo-ui-toolbar-actions > .oo-ui-buttonElement-framed:last-child {
-       margin-top: 0.4em;
-       margin-bottom: 0.4em;
-       margin-right: 0.5em;
-}
-.oo-ui-toolbar-actions > .oo-ui-buttonElement-frameless.oo-ui-labelElement,
-.oo-ui-toolbar-actions > .oo-ui-buttonElement-frameless:last-child.oo-ui-labelElement {
-       margin: 0;
-}
-.oo-ui-toolbar-actions > .oo-ui-buttonElement-frameless.oo-ui-labelElement > .oo-ui-buttonElement-button,
-.oo-ui-toolbar-actions > .oo-ui-buttonElement-frameless:last-child.oo-ui-labelElement > .oo-ui-buttonElement-button {
-       margin: 0;
-       padding: 0 0.3125em;
-}
-.oo-ui-toolbar-actions > .oo-ui-buttonElement-frameless.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label,
-.oo-ui-toolbar-actions > .oo-ui-buttonElement-frameless:last-child.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       margin: 0 1em;
-       line-height: 3.40625em;
-}
-.oo-ui-toolbar-shadow {
-       background-image: /* @embed */ url(themes/apex/images/toolbar-shadow.png);
-       bottom: -9px;
-       height: 9px;
-       opacity: 0.5;
-       -webkit-transition: opacity 500ms ease;
-          -moz-transition: opacity 500ms ease;
-               transition: opacity 500ms ease;
-}
-
-/*!
- * OOjs UI v0.15.2
- * https://www.mediawiki.org/wiki/OOjs_UI
- *
- * Copyright 2011–2016 OOjs UI Team and other contributors.
- * Released under the MIT license
- * http://oojs.mit-license.org
- *
- * Date: 2016-02-02T22:07:06Z
- */
-@-webkit-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@-moz-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@-ms-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@-o-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-.oo-ui-actionWidget.oo-ui-pendingElement-pending {
-       background-image: /* @embed */ url(themes/apex/images/textures/pending.gif);
-}
-.oo-ui-window {
-       background-color: transparent;
-       background-image: none;
-}
-.oo-ui-window-frame {
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-window-content:focus {
-       outline: none;
-}
-.oo-ui-window-head,
-.oo-ui-window-foot {
-       -webkit-touch-callout: none;
-       -webkit-user-select: none;
-          -moz-user-select: none;
-           -ms-user-select: none;
-               user-select: none;
-}
-.oo-ui-window-body {
-       margin: 0;
-       padding: 0;
-       background: none;
-}
-.oo-ui-window-overlay {
-       position: absolute;
-       top: 0;
-       /* @noflip */
-       left: 0;
-}
-.oo-ui-dialog-content > .oo-ui-window-head,
-.oo-ui-dialog-content > .oo-ui-window-body,
-.oo-ui-dialog-content > .oo-ui-window-foot {
-       position: absolute;
-       left: 0;
-       right: 0;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-dialog-content > .oo-ui-window-head {
-       overflow: hidden;
-       z-index: 1;
-       top: 0;
-}
-.oo-ui-dialog-content > .oo-ui-window-body {
-       overflow: auto;
-       z-index: 2;
-       top: 0;
-       bottom: 0;
-}
-.oo-ui-dialog-content > .oo-ui-window-foot {
-       overflow: hidden;
-       z-index: 1;
-       bottom: 0;
-}
-.oo-ui-dialog-content > .oo-ui-window-body {
-       box-shadow: 0 0 0.66em rgba(0, 0, 0, 0.25);
-}
-.oo-ui-messageDialog-actions-horizontal {
-       display: table;
-       table-layout: fixed;
-       width: 100%;
-}
-.oo-ui-messageDialog-actions-horizontal .oo-ui-actionWidget {
-       display: table-cell;
-       width: 1%;
-}
-.oo-ui-messageDialog-actions-vertical {
-       display: block;
-}
-.oo-ui-messageDialog-actions-vertical .oo-ui-actionWidget {
-       display: block;
-       overflow: hidden;
-       text-overflow: ellipsis;
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget {
-       position: relative;
-       text-align: center;
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget .oo-ui-buttonElement-button {
-       display: block;
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget .oo-ui-labelElement-label {
-       position: relative;
-       top: auto;
-       bottom: auto;
-       display: inline;
-       white-space: nowrap;
-}
-.oo-ui-messageDialog-content .oo-ui-window-body {
-       box-shadow: 0 0 0.33em rgba(0, 0, 0, 0.33);
-}
-.oo-ui-messageDialog-title,
-.oo-ui-messageDialog-message {
-       display: block;
-       text-align: center;
-}
-.oo-ui-messageDialog-title.oo-ui-labelElement,
-.oo-ui-messageDialog-message.oo-ui-labelElement {
-       padding-top: 0.5em;
-}
-.oo-ui-messageDialog-title {
-       font-size: 1.5em;
-       line-height: 1em;
-       color: #000000;
-}
-.oo-ui-messageDialog-message {
-       font-size: 0.9em;
-       line-height: 1.25em;
-       color: #666666;
-}
-.oo-ui-messageDialog-message-verbose {
-       font-size: 1.1em;
-       line-height: 1.5em;
-       text-align: left;
-}
-.oo-ui-messageDialog-actions-horizontal .oo-ui-actionWidget {
-       border-right: 1px solid #e5e5e5;
-       margin: 0;
-}
-.oo-ui-messageDialog-actions-horizontal .oo-ui-actionWidget:last-child {
-       border-right-width: 0;
-}
-.oo-ui-messageDialog-actions-vertical .oo-ui-actionWidget {
-       border-bottom: 1px solid #e5e5e5;
-       margin: 0;
-}
-.oo-ui-messageDialog-actions-vertical .oo-ui-actionWidget:last-child {
-       border-bottom-width: 0;
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget {
-       height: 3.4em;
-       margin-right: 0;
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-labelElement .oo-ui-labelElement-label {
-       text-align: center;
-       line-height: 3.4em;
-       padding: 0 2em;
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget:hover {
-       background-color: rgba(0, 0, 0, 0.05);
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget:active {
-       background-color: rgba(0, 0, 0, 0.1);
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-progressive:hover {
-       background-color: rgba(8, 126, 204, 0.05);
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-progressive:active {
-       background-color: rgba(8, 126, 204, 0.1);
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-progressive .oo-ui-labelElement-label {
-       font-weight: bold;
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-constructive:hover {
-       background-color: rgba(118, 171, 54, 0.05);
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-constructive:active {
-       background-color: rgba(118, 171, 54, 0.1);
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-destructive:hover {
-       background-color: rgba(212, 83, 83, 0.05);
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-destructive:active {
-       background-color: rgba(212, 83, 83, 0.1);
-}
-.oo-ui-processDialog-location {
-       overflow: hidden;
-       text-overflow: ellipsis;
-       white-space: nowrap;
-}
-.oo-ui-processDialog-title {
-       display: inline;
-       padding: 0;
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget,
-.oo-ui-processDialog-actions-other .oo-ui-actionWidget {
-       white-space: nowrap;
-}
-.oo-ui-processDialog-actions-safe,
-.oo-ui-processDialog-actions-primary {
-       position: absolute;
-       top: 0;
-       bottom: 0;
-}
-.oo-ui-processDialog-actions-safe {
-       left: 0;
-}
-.oo-ui-processDialog-actions-primary {
-       right: 0;
-}
-.oo-ui-processDialog-errors {
-       position: absolute;
-       top: 0;
-       left: 0;
-       right: 0;
-       bottom: 0;
-       z-index: 2;
-       overflow-x: hidden;
-       overflow-y: auto;
-}
-.oo-ui-processDialog-content .oo-ui-window-head {
-       height: 3.4em;
-}
-.oo-ui-processDialog-content .oo-ui-window-body {
-       top: 3.4em;
-       box-shadow: 0 0 0.33em rgba(0, 0, 0, 0.33);
-}
-.oo-ui-processDialog-navigation {
-       position: relative;
-       height: 3.4em;
-       padding: 0 1em;
-}
-.oo-ui-processDialog-location {
-       padding: 0.75em 0;
-       height: 1.875em;
-       cursor: default;
-       text-align: center;
-}
-.oo-ui-processDialog-title {
-       font-weight: bold;
-       line-height: 1.875em;
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget .oo-ui-buttonElement-button,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget .oo-ui-buttonElement-button,
-.oo-ui-processDialog-actions-other .oo-ui-actionWidget .oo-ui-buttonElement-button {
-       min-width: 1.875em;
-       min-height: 1.875em;
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget .oo-ui-labelElement-label,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget .oo-ui-labelElement-label,
-.oo-ui-processDialog-actions-other .oo-ui-actionWidget .oo-ui-labelElement-label {
-       line-height: 1.875em;
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-iconElement .oo-ui-iconElement-icon,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-iconElement .oo-ui-iconElement-icon,
-.oo-ui-processDialog-actions-other .oo-ui-actionWidget.oo-ui-iconElement .oo-ui-iconElement-icon {
-       margin-top: -0.125em;
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-framed,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-framed,
-.oo-ui-processDialog-actions-other .oo-ui-actionWidget.oo-ui-buttonElement-framed {
-       margin: 0.75em;
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-framed .oo-ui-buttonElement-button,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-framed .oo-ui-buttonElement-button,
-.oo-ui-processDialog-actions-other .oo-ui-actionWidget.oo-ui-buttonElement-framed .oo-ui-buttonElement-button {
-       padding: 0 1em;
-       vertical-align: middle;
-       margin: -1px;
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless,
-.oo-ui-processDialog-actions-other .oo-ui-actionWidget.oo-ui-buttonElement-frameless {
-       margin: 0;
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless .oo-ui-buttonElement-button,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless .oo-ui-buttonElement-button,
-.oo-ui-processDialog-actions-other .oo-ui-actionWidget.oo-ui-buttonElement-frameless .oo-ui-buttonElement-button {
-       padding: 0.75em 1em;
-       vertical-align: middle;
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget:hover,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget:hover {
-       background-color: rgba(0, 0, 0, 0.05);
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget:active,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget:active {
-       background-color: rgba(0, 0, 0, 0.1);
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-flaggedElement-progressive:hover,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-flaggedElement-progressive:hover {
-       background-color: rgba(8, 126, 204, 0.05);
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-flaggedElement-progressive:active,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-flaggedElement-progressive:active {
-       background-color: rgba(8, 126, 204, 0.1);
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-flaggedElement-progressive .oo-ui-labelElement-label,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-flaggedElement-progressive .oo-ui-labelElement-label {
-       font-weight: bold;
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-flaggedElement-constructive:hover,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-flaggedElement-constructive:hover {
-       background-color: rgba(118, 171, 54, 0.05);
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-flaggedElement-constructive:active,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-flaggedElement-constructive:active {
-       background-color: rgba(118, 171, 54, 0.1);
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-flaggedElement-destructive:hover,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-flaggedElement-destructive:hover {
-       background-color: rgba(212, 83, 83, 0.05);
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-flaggedElement-destructive:active,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-flaggedElement-destructive:active {
-       background-color: rgba(212, 83, 83, 0.1);
-}
-.oo-ui-processDialog-actions-other .oo-ui-actionWidget.oo-ui-buttonElement {
-       margin-right: 0;
-}
-.oo-ui-processDialog > .oo-ui-window-frame {
-       min-height: 5em;
-}
-.oo-ui-processDialog-errors {
-       background-color: rgba(255, 255, 255, 0.9);
-       padding: 3em 3em 1.5em 3em;
-       text-align: center;
-}
-.oo-ui-processDialog-errors .oo-ui-buttonWidget {
-       margin: 2em 1em 2em 1em;
-}
-.oo-ui-processDialog-errors-title {
-       font-size: 1.5em;
-       color: #000000;
-       margin-bottom: 2em;
-}
-.oo-ui-processDialog-error {
-       text-align: left;
-       margin: 1em;
-       padding: 1em;
-       border: 1px solid #ff9e9e;
-       background-color: #fff7f7;
-       border-radius: 0.25em;
-}
-.oo-ui-windowManager-modal > .oo-ui-dialog {
-       position: fixed;
-       width: 0;
-       height: 0;
-       overflow: hidden;
-}
-.oo-ui-windowManager-modal > .oo-ui-dialog.oo-ui-window-active {
-       width: auto;
-       height: auto;
-       top: 0;
-       right: 0;
-       bottom: 0;
-       left: 0;
-       padding: 1em;
-}
-.oo-ui-windowManager-modal > .oo-ui-dialog.oo-ui-window-setup > .oo-ui-window-frame {
-       position: absolute;
-       right: 0;
-       left: 0;
-       margin: auto;
-       overflow: hidden;
-       max-width: 100%;
-       max-height: 100%;
-}
-.oo-ui-windowManager-fullscreen > .oo-ui-dialog > .oo-ui-window-frame {
-       width: 100%;
-       height: 100%;
-       top: 0;
-       bottom: 0;
-}
-.oo-ui-windowManager-modal > .oo-ui-dialog {
-       background-color: rgba(255, 255, 255, 0.5);
-       opacity: 0;
-       -webkit-transition: opacity 250ms ease;
-          -moz-transition: opacity 250ms ease;
-               transition: opacity 250ms ease;
-}
-.oo-ui-windowManager-modal > .oo-ui-dialog > .oo-ui-window-frame {
-       background-color: #ffffff;
-       opacity: 0;
-       -webkit-transform: scale(0.5);
-          -moz-transform: scale(0.5);
-           -ms-transform: scale(0.5);
-               transform: scale(0.5);
-       -webkit-transition: all 250ms ease;
-          -moz-transition: all 250ms ease;
-               transition: all 250ms ease;
-}
-.oo-ui-windowManager-modal > .oo-ui-dialog.oo-ui-window-setup {
-       opacity: 1;
-}
-.oo-ui-windowManager-modal > .oo-ui-dialog.oo-ui-window-ready > .oo-ui-window-frame {
-       opacity: 1;
-       -webkit-transform: scale(1);
-          -moz-transform: scale(1);
-           -ms-transform: scale(1);
-               transform: scale(1);
-}
-.oo-ui-windowManager-modal.oo-ui-windowManager-floating > .oo-ui-dialog > .oo-ui-window-frame {
-       top: 1em;
-       bottom: 1em;
-       border: 1px solid #cccccc;
-       border-radius: 0.5em;
-       box-shadow: 0 0.2em 1em rgba(0, 0, 0, 0.3);
-}
diff --git a/resources/lib/oojs-ui/oojs-ui-core-apex.css b/resources/lib/oojs-ui/oojs-ui-core-apex.css
new file mode 100644 (file)
index 0000000..168ab71
--- /dev/null
@@ -0,0 +1,1128 @@
+/*!
+ * OOjs UI v0.15.2
+ * https://www.mediawiki.org/wiki/OOjs_UI
+ *
+ * Copyright 2011–2016 OOjs UI Team and other contributors.
+ * Released under the MIT license
+ * http://oojs.mit-license.org
+ *
+ * Date: 2016-02-02T22:07:06Z
+ */
+@-webkit-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@-moz-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@-ms-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@-o-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+.oo-ui-element-hidden {
+       display: none !important;
+}
+.oo-ui-buttonElement > .oo-ui-buttonElement-button {
+       cursor: pointer;
+       display: inline-block;
+       vertical-align: middle;
+       font: inherit;
+       white-space: nowrap;
+       -webkit-touch-callout: none;
+       -webkit-user-select: none;
+          -moz-user-select: none;
+           -ms-user-select: none;
+               user-select: none;
+}
+.oo-ui-buttonElement > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon,
+.oo-ui-buttonElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
+       display: none;
+}
+.oo-ui-buttonElement.oo-ui-widget-disabled > .oo-ui-buttonElement-button {
+       cursor: default;
+}
+.oo-ui-buttonElement.oo-ui-indicatorElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator,
+.oo-ui-buttonElement.oo-ui-iconElement > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
+       display: inline-block;
+       vertical-align: middle;
+}
+.oo-ui-buttonElement-frameless {
+       display: inline-block;
+       position: relative;
+}
+.oo-ui-buttonElement-frameless.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       display: inline-block;
+       vertical-align: middle;
+}
+.oo-ui-buttonElement-framed > .oo-ui-buttonElement-button {
+       display: inline-block;
+       vertical-align: top;
+       text-align: center;
+}
+.oo-ui-buttonElement-framed.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       display: inline-block;
+       vertical-align: middle;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-disabled > .oo-ui-buttonElement-button,
+.oo-ui-buttonElement-framed.oo-ui-widget-disabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button,
+.oo-ui-buttonElement-framed.oo-ui-widget-disabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
+       cursor: default;
+}
+.oo-ui-buttonElement > .oo-ui-buttonElement-button {
+       color: #333333;
+}
+.oo-ui-buttonElement.oo-ui-iconElement > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
+       margin-left: 0;
+}
+.oo-ui-buttonElement.oo-ui-indicatorElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
+       width: 0.9375em;
+       height: 0.9375em;
+       margin: 0.46875em;
+}
+.oo-ui-buttonElement.oo-ui-iconElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
+       margin-left: 0.46875em;
+}
+.oo-ui-buttonElement.oo-ui-iconElement > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
+       width: 1.875em;
+       height: 1.875em;
+}
+.oo-ui-buttonElement-frameless > .oo-ui-buttonElement-button:hover,
+.oo-ui-buttonElement-frameless > .oo-ui-buttonElement-button:focus {
+       outline: none;
+}
+.oo-ui-buttonElement-frameless > .oo-ui-buttonElement-button:hover > .oo-ui-iconElement-icon,
+.oo-ui-buttonElement-frameless > .oo-ui-buttonElement-button:focus > .oo-ui-iconElement-icon {
+       opacity: 1;
+}
+.oo-ui-buttonElement-frameless > .oo-ui-buttonElement-button:hover > .oo-ui-labelElement-label,
+.oo-ui-buttonElement-frameless > .oo-ui-buttonElement-button:focus > .oo-ui-labelElement-label {
+       color: #000000;
+}
+.oo-ui-buttonElement-frameless > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       color: #333333;
+}
+.oo-ui-buttonElement-frameless.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       margin-left: 0.25em;
+}
+.oo-ui-buttonElement-frameless > input.oo-ui-buttonElement-button {
+       padding-left: 0.25em;
+       color: #333333;
+}
+.oo-ui-buttonElement-frameless > input.oo-ui-buttonElement-button:hover,
+.oo-ui-buttonElement-frameless > input.oo-ui-buttonElement-button:focus {
+       color: #000000;
+}
+.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       color: #087ecc;
+}
+.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       color: #76ab36;
+}
+.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-destructive > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       color: #d45353;
+}
+.oo-ui-buttonElement-frameless.oo-ui-widget-disabled > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
+       opacity: 0.2;
+}
+.oo-ui-buttonElement-frameless.oo-ui-widget-disabled > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       color: #cccccc;
+}
+.oo-ui-buttonElement-framed > .oo-ui-buttonElement-button {
+       padding: 0.2em 0.8em;
+       border-radius: 0.3em;
+       text-shadow: 0 1px 1px rgba(255, 255, 255, 0.5);
+       border: 1px #c9c9c9 solid;
+       -webkit-transition: border-color 100ms ease;
+          -moz-transition: border-color 100ms ease;
+               transition: border-color 100ms ease;
+       background-color: #eeeeee;
+       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #ffffff), color-stop(100%, #dddddd));
+       background-image: -webkit-linear-gradient(top, #ffffff 0, #dddddd 100%);
+       background-image:    -moz-linear-gradient(top, #ffffff 0, #dddddd 100%);
+       background-image:         linear-gradient(to bottom, #ffffff 0, #dddddd 100%);
+       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffffffff', endColorstr='#ffdddddd' )";
+}
+.oo-ui-buttonElement-framed > .oo-ui-buttonElement-button:hover,
+.oo-ui-buttonElement-framed > .oo-ui-buttonElement-button:focus {
+       border-color: #aaaaaa;
+       outline: none;
+}
+.oo-ui-buttonElement-framed > input.oo-ui-buttonElement-button,
+.oo-ui-buttonElement-framed.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       line-height: 1.875em;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active,
+.oo-ui-buttonElement-framed.oo-ui-buttonElement-active > .oo-ui-buttonElement-button,
+.oo-ui-buttonElement-framed.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
+       box-shadow: inset 0 1px 4px 0 rgba(0, 0, 0, 0.07);
+       color: black;
+       border-color: #c9c9c9;
+       background-color: #eeeeee;
+       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #dddddd), color-stop(100%, #ffffff));
+       background-image: -webkit-linear-gradient(top, #dddddd 0, #ffffff 100%);
+       background-image:    -moz-linear-gradient(top, #dddddd 0, #ffffff 100%);
+       background-image:         linear-gradient(to bottom, #dddddd 0, #ffffff 100%);
+       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffdddddd', endColorstr='#ffffffff' )";
+}
+.oo-ui-buttonElement-framed.oo-ui-iconElement > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
+       margin-left: -0.5em;
+       margin-right: -0.5em;
+}
+.oo-ui-buttonElement-framed.oo-ui-iconElement.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
+       margin-right: 0.3em;
+}
+.oo-ui-buttonElement-framed.oo-ui-indicatorElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
+       margin-left: -0.005em;
+       margin-right: -0.005em;
+}
+.oo-ui-buttonElement-framed.oo-ui-indicatorElement.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator,
+.oo-ui-buttonElement-framed.oo-ui-indicatorElement.oo-ui-iconElement:not( .oo-ui-labelElement ) > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
+       margin-left: 0.46875em;
+       margin-right: -0.275em;
+}
+.oo-ui-buttonElement-framed.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button {
+       border: 1px solid #a6cee1;
+       background-color: #cde7f4;
+       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #eaf4fa), color-stop(100%, #b0d9ee));
+       background-image: -webkit-linear-gradient(top, #eaf4fa 0, #b0d9ee 100%);
+       background-image:    -moz-linear-gradient(top, #eaf4fa 0, #b0d9ee 100%);
+       background-image:         linear-gradient(to bottom, #eaf4fa 0, #b0d9ee 100%);
+       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffeaf4fa', endColorstr='#ffb0d9ee' )";
+}
+.oo-ui-buttonElement-framed.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button:hover,
+.oo-ui-buttonElement-framed.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button:focus {
+       border-color: #9dc2d4;
+}
+.oo-ui-buttonElement-framed.oo-ui-flaggedElement-progressive.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active,
+.oo-ui-buttonElement-framed.oo-ui-flaggedElement-progressive.oo-ui-buttonElement-active > .oo-ui-buttonElement-button,
+.oo-ui-buttonElement-framed.oo-ui-flaggedElement-progressive.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
+       border: 1px solid #a6cee1;
+       background-color: #cde7f4;
+       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #b0d9ee), color-stop(100%, #eaf4fa));
+       background-image: -webkit-linear-gradient(top, #b0d9ee 0, #eaf4fa 100%);
+       background-image:    -moz-linear-gradient(top, #b0d9ee 0, #eaf4fa 100%);
+       background-image:         linear-gradient(to bottom, #b0d9ee 0, #eaf4fa 100%);
+       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffb0d9ee', endColorstr='#ffeaf4fa' )";
+}
+.oo-ui-buttonElement-framed.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button {
+       border: 1px solid #b8d892;
+       background-color: #daf0bd;
+       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #f0fbe1), color-stop(100%, #c3e59a));
+       background-image: -webkit-linear-gradient(top, #f0fbe1 0, #c3e59a 100%);
+       background-image:    -moz-linear-gradient(top, #f0fbe1 0, #c3e59a 100%);
+       background-image:         linear-gradient(to bottom, #f0fbe1 0, #c3e59a 100%);
+       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#fff0fbe1', endColorstr='#ffc3e59a' )";
+}
+.oo-ui-buttonElement-framed.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button:hover,
+.oo-ui-buttonElement-framed.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button:focus {
+       border-color: #adcb89;
+}
+.oo-ui-buttonElement-framed.oo-ui-flaggedElement-constructive.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active,
+.oo-ui-buttonElement-framed.oo-ui-flaggedElement-constructive.oo-ui-buttonElement-active > .oo-ui-buttonElement-button,
+.oo-ui-buttonElement-framed.oo-ui-flaggedElement-constructive.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
+       border: 1px solid #b8d892;
+       background-color: #daf0bd;
+       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #c3e59a), color-stop(100%, #f0fbe1));
+       background-image: -webkit-linear-gradient(top, #c3e59a 0, #f0fbe1 100%);
+       background-image:    -moz-linear-gradient(top, #c3e59a 0, #f0fbe1 100%);
+       background-image:         linear-gradient(to bottom, #c3e59a 0, #f0fbe1 100%);
+       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffc3e59a', endColorstr='#fff0fbe1' )";
+}
+.oo-ui-buttonElement-framed.oo-ui-flaggedElement-destructive > .oo-ui-buttonElement-button {
+       color: #d45353;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-disabled > .oo-ui-buttonElement-button,
+.oo-ui-buttonElement-framed.oo-ui-widget-disabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button,
+.oo-ui-buttonElement-framed.oo-ui-widget-disabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
+       opacity: 0.5;
+       -webkit-transform: translate3d(0, 0, 0);
+       box-shadow: none;
+       color: #333333;
+       background: #eeeeee;
+       border-color: #cccccc;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-disabled > .oo-ui-buttonElement-button:hover,
+.oo-ui-buttonElement-framed.oo-ui-widget-disabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button:hover,
+.oo-ui-buttonElement-framed.oo-ui-widget-disabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button:hover,
+.oo-ui-buttonElement-framed.oo-ui-widget-disabled > .oo-ui-buttonElement-button:focus,
+.oo-ui-buttonElement-framed.oo-ui-widget-disabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button:focus,
+.oo-ui-buttonElement-framed.oo-ui-widget-disabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button:focus {
+       border-color: #cccccc;
+       box-shadow: none;
+}
+.oo-ui-clippableElement-clippable {
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-iconElement .oo-ui-iconElement-icon,
+.oo-ui-iconElement.oo-ui-iconElement-icon {
+       background-size: contain;
+       background-position: center center;
+       background-repeat: no-repeat;
+}
+.oo-ui-iconElement .oo-ui-iconElement-icon,
+.oo-ui-iconElement.oo-ui-iconElement-icon {
+       opacity: 0.8;
+}
+.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator,
+.oo-ui-indicatorElement.oo-ui-indicatorElement-indicator {
+       background-size: contain;
+       background-position: center center;
+       background-repeat: no-repeat;
+}
+.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator,
+.oo-ui-indicatorElement.oo-ui-indicatorElement-indicator {
+       opacity: 0.8;
+}
+.oo-ui-pendingElement-pending {
+       background-image: /* @embed */ url(themes/apex/images/textures/pending.gif);
+}
+.oo-ui-fieldLayout {
+       display: block;
+       margin-bottom: 1em;
+}
+.oo-ui-fieldLayout:before,
+.oo-ui-fieldLayout:after {
+       content: " ";
+       display: table;
+}
+.oo-ui-fieldLayout:after {
+       clear: both;
+}
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-left > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label,
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-right > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label,
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-left > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field,
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-right > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field {
+       display: block;
+       float: left;
+}
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-right > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
+       text-align: right;
+}
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline > .oo-ui-fieldLayout-body {
+       display: table;
+}
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label,
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field {
+       display: table-cell;
+       vertical-align: middle;
+}
+.oo-ui-fieldLayout.oo-ui-labelElement.oo-ui-fieldLayout-align-top > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
+       display: inline-block;
+}
+.oo-ui-fieldLayout > .oo-ui-fieldLayout-help {
+       float: right;
+}
+.oo-ui-fieldLayout > .oo-ui-fieldLayout-help > .oo-ui-popupWidget > .oo-ui-popupWidget-popup {
+       z-index: 1;
+}
+.oo-ui-fieldLayout > .oo-ui-fieldLayout-help .oo-ui-fieldLayout-help-content {
+       padding: 0.5em 0.75em;
+       line-height: 1.5em;
+}
+.oo-ui-fieldLayout:last-child {
+       margin-bottom: 0;
+}
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-left.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label,
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-right.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
+       padding-top: 0.5em;
+       margin-right: 5%;
+       width: 35%;
+}
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-left > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field,
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-right > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field {
+       width: 60%;
+}
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline {
+       margin-bottom: 1.25em;
+}
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
+       padding: 0.25em 0.25em 0.25em 0.5em;
+}
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-top.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
+       padding: 0.5em 0;
+}
+.oo-ui-fieldLayout > .oo-ui-popupButtonWidget {
+       margin-right: 0;
+       margin-top: 0.25em;
+}
+.oo-ui-fieldLayout > .oo-ui-popupButtonWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-fieldLayout-disabled > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
+       color: #cccccc;
+}
+.oo-ui-fieldLayout-messages {
+       list-style: none none;
+       margin: 0;
+       padding: 0;
+       margin-top: 0.25em;
+       margin-left: 0.25em;
+}
+.oo-ui-fieldLayout-messages > li {
+       margin: 0;
+       padding: 0;
+}
+.oo-ui-fieldLayout-messages .oo-ui-iconWidget {
+       display: none;
+}
+.oo-ui-fieldLayout-messages .oo-ui-fieldLayout-messages-error {
+       color: #d45353;
+}
+.oo-ui-fieldLayout-messages .oo-ui-labelWidget {
+       padding: 0;
+       line-height: 1.875em;
+       vertical-align: middle;
+}
+.oo-ui-actionFieldLayout-input,
+.oo-ui-actionFieldLayout-button {
+       display: table-cell;
+       vertical-align: middle;
+}
+.oo-ui-actionFieldLayout-input {
+       padding-right: 1em;
+}
+.oo-ui-actionFieldLayout-button {
+       width: 1%;
+       white-space: nowrap;
+}
+.oo-ui-fieldsetLayout {
+       position: relative;
+       margin: 0;
+       padding: 0;
+       border: none;
+}
+.oo-ui-fieldsetLayout.oo-ui-iconElement > .oo-ui-iconElement-icon {
+       display: block;
+       position: absolute;
+}
+.oo-ui-fieldsetLayout.oo-ui-labelElement > .oo-ui-labelElement-label {
+       display: inline-block;
+}
+.oo-ui-fieldsetLayout > .oo-ui-fieldsetLayout-help {
+       float: right;
+}
+.oo-ui-fieldsetLayout > .oo-ui-fieldsetLayout-help > .oo-ui-popupWidget > .oo-ui-popupWidget-popup {
+       z-index: 1;
+}
+.oo-ui-fieldsetLayout > .oo-ui-fieldsetLayout-help .oo-ui-fieldsetLayout-help-content {
+       padding: 0.5em 0.75em;
+       line-height: 1.5em;
+}
+.oo-ui-fieldsetLayout + .oo-ui-fieldsetLayout,
+.oo-ui-fieldsetLayout + .oo-ui-formLayout {
+       margin-top: 2em;
+}
+.oo-ui-fieldsetLayout > .oo-ui-labelElement-label {
+       font-size: 1.1em;
+       margin-bottom: 0.5em;
+       padding: 0.25em 0;
+       font-weight: bold;
+}
+.oo-ui-fieldsetLayout.oo-ui-iconElement > .oo-ui-labelElement-label {
+       padding-left: 2em;
+       line-height: 1.8em;
+}
+.oo-ui-fieldsetLayout.oo-ui-iconElement > .oo-ui-iconElement-icon {
+       left: 0;
+       top: 0.25em;
+       width: 1.875em;
+       height: 1.875em;
+}
+.oo-ui-fieldsetLayout > .oo-ui-popupButtonWidget {
+       margin-right: 0;
+}
+.oo-ui-fieldsetLayout > .oo-ui-popupButtonWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-formLayout + .oo-ui-fieldsetLayout,
+.oo-ui-formLayout + .oo-ui-formLayout {
+       margin-top: 2em;
+}
+.oo-ui-panelLayout {
+       position: relative;
+}
+.oo-ui-panelLayout-scrollable {
+       overflow-y: auto;
+}
+.oo-ui-panelLayout-expanded {
+       position: absolute;
+       top: 0;
+       left: 0;
+       right: 0;
+       bottom: 0;
+}
+.oo-ui-panelLayout-padded {
+       padding: 1.25em;
+}
+.oo-ui-panelLayout-framed {
+       border-radius: 0.5em;
+       box-shadow: 0 0.25em 1em rgba(0, 0, 0, 0.25);
+}
+.oo-ui-panelLayout-padded.oo-ui-panelLayout-framed {
+       margin: 1em 0;
+}
+.oo-ui-horizontalLayout > .oo-ui-widget {
+       display: inline-block;
+       vertical-align: middle;
+}
+.oo-ui-horizontalLayout > .oo-ui-layout {
+       display: inline-block;
+}
+.oo-ui-horizontalLayout > .oo-ui-layout,
+.oo-ui-horizontalLayout > .oo-ui-widget {
+       margin-right: 0.5em;
+}
+.oo-ui-horizontalLayout > .oo-ui-layout:last-child,
+.oo-ui-horizontalLayout > .oo-ui-widget:last-child {
+       margin-right: 0;
+}
+.oo-ui-horizontalLayout > .oo-ui-layout {
+       margin-bottom: 0;
+}
+.oo-ui-optionWidget {
+       position: relative;
+       display: block;
+       padding: 0.25em 0.5em;
+       border: none;
+}
+.oo-ui-optionWidget.oo-ui-widget-enabled {
+       cursor: pointer;
+}
+.oo-ui-optionWidget.oo-ui-labelElement .oo-ui-labelElement-label {
+       display: block;
+       white-space: nowrap;
+       text-overflow: ellipsis;
+       overflow: hidden;
+}
+.oo-ui-optionWidget-highlighted {
+       background-color: #e1f3ff;
+}
+.oo-ui-optionWidget .oo-ui-labelElement-label {
+       line-height: 1.5em;
+}
+.oo-ui-selectWidget-depressed .oo-ui-optionWidget-selected {
+       background-color: #a7dcff;
+}
+.oo-ui-selectWidget-pressed .oo-ui-optionWidget-pressed,
+.oo-ui-selectWidget-pressed .oo-ui-optionWidget-pressed.oo-ui-optionWidget-highlighted,
+.oo-ui-selectWidget-pressed .oo-ui-optionWidget-pressed.oo-ui-optionWidget-highlighted.oo-ui-optionWidget-selected {
+       background-color: #a7dcff;
+}
+.oo-ui-optionWidget.oo-ui-widget-disabled {
+       color: #cccccc;
+}
+.oo-ui-decoratedOptionWidget {
+       padding: 0.5em 2em 0.5em 3em;
+}
+.oo-ui-decoratedOptionWidget .oo-ui-iconElement-icon,
+.oo-ui-decoratedOptionWidget .oo-ui-indicatorElement-indicator {
+       position: absolute;
+}
+.oo-ui-decoratedOptionWidget.oo-ui-iconElement .oo-ui-iconElement-icon,
+.oo-ui-decoratedOptionWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
+       top: 0;
+       height: 100%;
+}
+.oo-ui-decoratedOptionWidget.oo-ui-iconElement .oo-ui-iconElement-icon {
+       width: 1.875em;
+       left: 0.5em;
+}
+.oo-ui-decoratedOptionWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
+       width: 0.9375em;
+       right: 0.5em;
+}
+.oo-ui-decoratedOptionWidget.oo-ui-widget-disabled .oo-ui-iconElement-icon,
+.oo-ui-decoratedOptionWidget.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator {
+       opacity: 0.2;
+}
+.oo-ui-radioSelectWidget {
+       padding: 0.75em 0 0.5em 0;
+}
+.oo-ui-radioOptionWidget {
+       cursor: default;
+       padding: 0;
+       background-color: transparent;
+}
+.oo-ui-radioOptionWidget .oo-ui-radioInputWidget,
+.oo-ui-radioOptionWidget.oo-ui-labelElement .oo-ui-labelElement-label {
+       display: inline-block;
+       vertical-align: middle;
+}
+.oo-ui-radioOptionWidget.oo-ui-optionWidget-selected,
+.oo-ui-radioOptionWidget.oo-ui-optionWidget-pressed,
+.oo-ui-radioOptionWidget.oo-ui-optionWidget-highlighted {
+       background-color: transparent;
+}
+.oo-ui-radioOptionWidget.oo-ui-labelElement .oo-ui-labelElement-label {
+       padding-left: 0.5em;
+}
+.oo-ui-radioOptionWidget .oo-ui-radioInputWidget {
+       margin-right: 0;
+}
+.oo-ui-labelWidget {
+       display: inline-block;
+       padding: 0.5em 0;
+}
+.oo-ui-iconWidget {
+       display: inline-block;
+       vertical-align: middle;
+       line-height: 2.5em;
+       height: 1.875em;
+       width: 1.875em;
+}
+.oo-ui-iconWidget.oo-ui-widget-disabled {
+       opacity: 0.2;
+}
+.oo-ui-indicatorWidget {
+       display: inline-block;
+       vertical-align: middle;
+       line-height: 2.5em;
+       height: 0.9375em;
+       width: 0.9375em;
+       margin: 0.46875em;
+}
+.oo-ui-indicatorWidget.oo-ui-widget-disabled {
+       opacity: 0.2;
+}
+.oo-ui-buttonWidget {
+       display: inline-block;
+       vertical-align: middle;
+       margin-right: 0.5em;
+}
+.oo-ui-buttonWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-buttonGroupWidget {
+       display: inline-block;
+       white-space: nowrap;
+       border-radius: 0.3em;
+       margin-right: 0.5em;
+}
+.oo-ui-buttonGroupWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-buttonGroupWidget .oo-ui-buttonElement {
+       margin-right: 0;
+}
+.oo-ui-buttonGroupWidget .oo-ui-buttonElement:last-child {
+       margin-right: 0;
+}
+.oo-ui-buttonGroupWidget .oo-ui-buttonElement-framed .oo-ui-buttonElement-button {
+       border-radius: 0;
+       margin-left: -1px;
+}
+.oo-ui-buttonGroupWidget .oo-ui-buttonElement-framed:first-child .oo-ui-buttonElement-button {
+       border-bottom-left-radius: 0.3em;
+       border-top-left-radius: 0.3em;
+       margin-left: 0;
+}
+.oo-ui-buttonGroupWidget .oo-ui-buttonElement-framed:last-child .oo-ui-buttonElement-button {
+       border-bottom-right-radius: 0.3em;
+       border-top-right-radius: 0.3em;
+}
+.oo-ui-popupWidget {
+       position: absolute;
+       /* @noflip */
+       left: 0;
+}
+.oo-ui-popupWidget-popup {
+       position: relative;
+       overflow: hidden;
+       z-index: 1;
+}
+.oo-ui-popupWidget-anchor {
+       display: none;
+       z-index: 1;
+}
+.oo-ui-popupWidget-anchored .oo-ui-popupWidget-anchor {
+       display: block;
+       position: absolute;
+       top: 0;
+       /* @noflip */
+       left: 0;
+       background-repeat: no-repeat;
+}
+.oo-ui-popupWidget-head {
+       -webkit-touch-callout: none;
+       -webkit-user-select: none;
+          -moz-user-select: none;
+           -ms-user-select: none;
+               user-select: none;
+}
+.oo-ui-popupWidget-head > .oo-ui-buttonWidget {
+       float: right;
+}
+.oo-ui-popupWidget-head > .oo-ui-labelElement-label {
+       float: left;
+       cursor: default;
+}
+.oo-ui-popupWidget-body {
+       clear: both;
+       overflow: hidden;
+}
+.oo-ui-popupWidget-popup {
+       background-color: #ffffff;
+       border: 1px solid #cccccc;
+       border-radius: 0.25em;
+       box-shadow: 0 0.15em 0.5em 0 rgba(0, 0, 0, 0.2);
+}
+.oo-ui-popupWidget-anchored .oo-ui-popupWidget-popup {
+       margin-top: 6px;
+}
+.oo-ui-popupWidget-anchored .oo-ui-popupWidget-anchor:before,
+.oo-ui-popupWidget-anchored .oo-ui-popupWidget-anchor:after {
+       content: "";
+       position: absolute;
+       width: 0;
+       height: 0;
+       border-style: solid;
+       border-color: transparent;
+       border-top: 0;
+}
+.oo-ui-popupWidget-anchored .oo-ui-popupWidget-anchor:before {
+       bottom: -7px;
+       left: -6px;
+       border-bottom-color: #aaaaaa;
+       border-width: 7px;
+}
+.oo-ui-popupWidget-anchored .oo-ui-popupWidget-anchor:after {
+       bottom: -7px;
+       left: -5px;
+       border-bottom-color: #ffffff;
+       border-width: 6px;
+}
+.oo-ui-popupWidget-transitioning .oo-ui-popupWidget-popup {
+       -webkit-transition: width 100ms ease, height 100ms ease, left 100ms ease;
+          -moz-transition: width 100ms ease, height 100ms ease, left 100ms ease;
+               transition: width 100ms ease, height 100ms ease, left 100ms ease;
+}
+.oo-ui-popupWidget-head {
+       height: 2.5em;
+}
+.oo-ui-popupWidget-head > .oo-ui-buttonWidget {
+       margin: 0.25em;
+}
+.oo-ui-popupWidget-head > .oo-ui-labelElement-label {
+       margin: 0.75em 1em;
+}
+.oo-ui-popupWidget-body-padded {
+       padding: 0 1em;
+}
+.oo-ui-popupButtonWidget {
+       position: relative;
+}
+.oo-ui-popupButtonWidget .oo-ui-popupWidget {
+       position: absolute;
+       cursor: auto;
+}
+.oo-ui-popupButtonWidget.oo-ui-buttonElement-frameless > .oo-ui-popupWidget {
+       /* @noflip */
+       left: 1em;
+}
+.oo-ui-popupButtonWidget.oo-ui-buttonElement-framed > .oo-ui-popupWidget {
+       /* @noflip */
+       left: 1.25em;
+}
+.oo-ui-inputWidget {
+       margin-right: 0.5em;
+}
+.oo-ui-inputWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-buttonInputWidget {
+       display: inline-block;
+       vertical-align: middle;
+}
+.oo-ui-buttonInputWidget > button,
+.oo-ui-buttonInputWidget > input {
+       border: 0;
+       padding: 0;
+       background-color: transparent;
+}
+.oo-ui-dropdownInputWidget {
+       position: relative;
+       vertical-align: middle;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+       width: 100%;
+       max-width: 50em;
+}
+.oo-ui-dropdownInputWidget select {
+       display: inline-block;
+       width: 100%;
+       resize: none;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-dropdownInputWidget select {
+       background-color: #ffffff;
+       height: 2.5em;
+       padding: 0.5em;
+       font-size: inherit;
+       font-family: inherit;
+       border: 1px solid rgba(0, 0, 0, 0.1);
+       border-radius: 0.25em;
+}
+.oo-ui-dropdownInputWidget.oo-ui-widget-enabled select:hover,
+.oo-ui-dropdownInputWidget.oo-ui-widget-enabled select:focus {
+       border-color: rgba(0, 0, 0, 0.2);
+       outline: none;
+}
+.oo-ui-dropdownInputWidget.oo-ui-widget-disabled select {
+       color: #cccccc;
+       border-color: #dddddd;
+       background-color: #f3f3f3;
+}
+.oo-ui-radioSelectInputWidget .oo-ui-fieldLayout {
+       margin-bottom: 0;
+}
+.oo-ui-textInputWidget {
+       position: relative;
+       vertical-align: middle;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+       width: 100%;
+       max-width: 50em;
+}
+.oo-ui-textInputWidget input,
+.oo-ui-textInputWidget textarea {
+       display: inline-block;
+       width: 100%;
+       resize: none;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-textInputWidget textarea {
+       overflow: auto;
+}
+.oo-ui-textInputWidget input[type="search"] {
+       -webkit-appearance: none;
+}
+.oo-ui-textInputWidget input[type="search"]::-ms-clear {
+       display: none;
+}
+.oo-ui-textInputWidget input[type="search"]::-ms-reveal {
+       display: none;
+}
+.oo-ui-textInputWidget input[type="search"]::-webkit-search-decoration,
+.oo-ui-textInputWidget input[type="search"]::-webkit-search-cancel-button,
+.oo-ui-textInputWidget input[type="search"]::-webkit-search-results-button,
+.oo-ui-textInputWidget input[type="search"]::-webkit-search-results-decoration {
+       display: none;
+}
+.oo-ui-textInputWidget > .oo-ui-iconElement-icon,
+.oo-ui-textInputWidget > .oo-ui-indicatorElement-indicator,
+.oo-ui-textInputWidget > .oo-ui-labelElement-label {
+       display: none;
+}
+.oo-ui-textInputWidget.oo-ui-iconElement > .oo-ui-iconElement-icon,
+.oo-ui-textInputWidget.oo-ui-indicatorElement > .oo-ui-indicatorElement-indicator {
+       display: block;
+       position: absolute;
+       top: 0;
+       height: 100%;
+       -webkit-touch-callout: none;
+       -webkit-user-select: none;
+          -moz-user-select: none;
+           -ms-user-select: none;
+               user-select: none;
+}
+.oo-ui-textInputWidget.oo-ui-widget-enabled > .oo-ui-iconElement-icon,
+.oo-ui-textInputWidget.oo-ui-widget-enabled > .oo-ui-indicatorElement-indicator {
+       cursor: text;
+}
+.oo-ui-textInputWidget.oo-ui-widget-enabled.oo-ui-textInputWidget-type-search > .oo-ui-indicatorElement-indicator {
+       cursor: pointer;
+}
+.oo-ui-textInputWidget.oo-ui-labelElement > .oo-ui-labelElement-label {
+       display: block;
+}
+.oo-ui-textInputWidget > .oo-ui-iconElement-icon {
+       left: 0;
+}
+.oo-ui-textInputWidget > .oo-ui-indicatorElement-indicator {
+       right: 0;
+}
+.oo-ui-textInputWidget > .oo-ui-labelElement-label {
+       position: absolute;
+       top: 0;
+}
+.oo-ui-textInputWidget-labelPosition-after > .oo-ui-labelElement-label {
+       right: 0;
+}
+.oo-ui-textInputWidget-labelPosition-before > .oo-ui-labelElement-label {
+       left: 0;
+}
+.oo-ui-textInputWidget input,
+.oo-ui-textInputWidget textarea {
+       padding: 0.5em;
+       line-height: 1.275em;
+       font-size: inherit;
+       font-family: inherit;
+       background-color: #ffffff;
+       color: black;
+       border: 1px solid #cccccc;
+       box-shadow: 0 0 0 white, inset 0 0.1em 0.2em #dddddd;
+       border-radius: 0.25em;
+       -webkit-transition: border-color 250ms ease, box-shadow 250ms ease;
+          -moz-transition: border-color 250ms ease, box-shadow 250ms ease;
+               transition: border-color 250ms ease, box-shadow 250ms ease;
+}
+.oo-ui-textInputWidget input.oo-ui-pendingElement-pending,
+.oo-ui-textInputWidget textarea.oo-ui-pendingElement-pending {
+       background-color: transparent;
+}
+.oo-ui-textInputWidget.oo-ui-widget-enabled input:focus,
+.oo-ui-textInputWidget.oo-ui-widget-enabled textarea:focus {
+       outline: none;
+       border-color: #a7dcff;
+       box-shadow: 0 0 0.3em #a7dcff, 0 0 0 white;
+}
+.oo-ui-textInputWidget.oo-ui-widget-enabled input[readonly],
+.oo-ui-textInputWidget.oo-ui-widget-enabled textarea[readonly] {
+       color: #777777;
+}
+.oo-ui-textInputWidget.oo-ui-widget-enabled.oo-ui-flaggedElement-invalid input,
+.oo-ui-textInputWidget.oo-ui-widget-enabled.oo-ui-flaggedElement-invalid textarea {
+       background-color: #ffdddd;
+}
+.oo-ui-textInputWidget.oo-ui-widget-disabled input,
+.oo-ui-textInputWidget.oo-ui-widget-disabled textarea {
+       color: #cccccc;
+       text-shadow: 0 1px 1px #ffffff;
+       border-color: #dddddd;
+       background-color: #f3f3f3;
+}
+.oo-ui-textInputWidget.oo-ui-widget-disabled .oo-ui-iconElement-icon,
+.oo-ui-textInputWidget.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator {
+       opacity: 0.2;
+}
+.oo-ui-textInputWidget.oo-ui-widget-disabled .oo-ui-labelElement-label {
+       color: #dddddd;
+       text-shadow: 0 1px 1px #ffffff;
+}
+.oo-ui-textInputWidget.oo-ui-iconElement input,
+.oo-ui-textInputWidget.oo-ui-iconElement textarea {
+       padding-left: 2.475em;
+}
+.oo-ui-textInputWidget.oo-ui-iconElement .oo-ui-iconElement-icon {
+       width: 1.875em;
+       max-height: 2.375em;
+       margin-left: 0.3em;
+}
+.oo-ui-textInputWidget.oo-ui-indicatorElement input,
+.oo-ui-textInputWidget.oo-ui-indicatorElement textarea {
+       padding-right: 2.4875em;
+}
+.oo-ui-textInputWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
+       width: 0.9375em;
+       max-height: 2.375em;
+       margin-right: 0.775em;
+}
+.oo-ui-textInputWidget > .oo-ui-labelElement-label {
+       padding: 0.4em;
+       line-height: 1.5em;
+       color: #888888;
+}
+.oo-ui-textInputWidget-labelPosition-after.oo-ui-indicatorElement > .oo-ui-labelElement-label {
+       margin-right: 2.0875em;
+}
+.oo-ui-textInputWidget-labelPosition-before.oo-ui-iconElement > .oo-ui-labelElement-label {
+       margin-left: 2.075em;
+}
+.oo-ui-menuSelectWidget {
+       position: absolute;
+       background-color: #ffffff;
+       margin-top: -1px;
+       border: 1px solid #cccccc;
+       border-radius: 0 0 0.25em 0.25em;
+       box-shadow: 0 0.15em 1em 0 rgba(0, 0, 0, 0.2);
+}
+.oo-ui-menuSelectWidget input {
+       position: absolute;
+       width: 0;
+       height: 0;
+       overflow: hidden;
+       opacity: 0;
+}
+.oo-ui-menuOptionWidget {
+       position: relative;
+}
+.oo-ui-menuOptionWidget .oo-ui-iconElement-icon {
+       display: none;
+}
+.oo-ui-menuOptionWidget.oo-ui-optionWidget-selected {
+       background-color: transparent;
+}
+.oo-ui-menuOptionWidget.oo-ui-optionWidget-selected .oo-ui-iconElement-icon {
+       display: block;
+}
+.oo-ui-menuOptionWidget.oo-ui-optionWidget-selected {
+       background-color: transparent;
+}
+.oo-ui-menuOptionWidget.oo-ui-optionWidget-highlighted,
+.oo-ui-menuOptionWidget.oo-ui-optionWidget-highlighted.oo-ui-optionWidget-selected {
+       background-color: #e1f3ff;
+}
+.oo-ui-menuSectionOptionWidget {
+       cursor: default;
+       padding: 0.33em 0.75em;
+       color: #888888;
+}
+.oo-ui-dropdownWidget {
+       display: inline-block;
+       position: relative;
+       width: 100%;
+       max-width: 50em;
+       background-color: #ffffff;
+       margin-right: 0.5em;
+}
+.oo-ui-dropdownWidget-handle {
+       width: 100%;
+       display: inline-block;
+       white-space: nowrap;
+       overflow: hidden;
+       text-overflow: ellipsis;
+       -webkit-touch-callout: none;
+       -webkit-user-select: none;
+          -moz-user-select: none;
+           -ms-user-select: none;
+               user-select: none;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-dropdownWidget-handle .oo-ui-indicatorElement-indicator,
+.oo-ui-dropdownWidget-handle .oo-ui-iconElement-icon {
+       position: absolute;
+}
+.oo-ui-dropdownWidget > .oo-ui-menuSelectWidget {
+       z-index: 1;
+       width: 100%;
+}
+.oo-ui-dropdownWidget.oo-ui-widget-enabled .oo-ui-dropdownWidget-handle {
+       cursor: pointer;
+}
+.oo-ui-dropdownWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-dropdownWidget-handle {
+       height: 2.5em;
+       border: 1px solid rgba(0, 0, 0, 0.1);
+       border-radius: 0.25em;
+}
+.oo-ui-dropdownWidget-handle:hover {
+       border-color: rgba(0, 0, 0, 0.2);
+}
+.oo-ui-dropdownWidget-handle .oo-ui-indicatorElement-indicator {
+       right: 0;
+}
+.oo-ui-dropdownWidget-handle .oo-ui-iconElement-icon {
+       left: 0.25em;
+}
+.oo-ui-dropdownWidget-handle .oo-ui-labelElement-label {
+       line-height: 2.5em;
+       margin: 0 0.5em;
+}
+.oo-ui-dropdownWidget-handle .oo-ui-indicatorElement-indicator {
+       top: 0;
+       width: 0.9375em;
+       height: 0.9375em;
+       margin: 0.775em;
+}
+.oo-ui-dropdownWidget-handle .oo-ui-iconElement-icon {
+       top: 0;
+       width: 1.875em;
+       height: 1.875em;
+       margin: 0.3em;
+}
+.oo-ui-dropdownWidget.oo-ui-widget-disabled .oo-ui-dropdownWidget-handle {
+       color: #cccccc;
+       text-shadow: 0 1px 1px #ffffff;
+       border-color: #dddddd;
+       background-color: #f3f3f3;
+}
+.oo-ui-dropdownWidget.oo-ui-widget-disabled .oo-ui-dropdownWidget-handle:focus {
+       outline: 0;
+}
+.oo-ui-dropdownWidget.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator {
+       opacity: 0.2;
+}
+.oo-ui-dropdownWidget.oo-ui-iconElement .oo-ui-dropdownWidget-handle .oo-ui-labelElement-label {
+       margin-left: 3em;
+}
+.oo-ui-dropdownWidget.oo-ui-indicatorElement .oo-ui-dropdownWidget-handle .oo-ui-labelElement-label {
+       margin-right: 2em;
+}
+.oo-ui-comboBoxInputWidget {
+       display: inline-block;
+       position: relative;
+       width: 100%;
+       max-width: 50em;
+       margin-right: 0.5em;
+}
+.oo-ui-comboBoxInputWidget > .oo-ui-menuSelectWidget {
+       z-index: 1;
+       width: 100%;
+}
+.oo-ui-comboBoxInputWidget.oo-ui-widget-enabled > .oo-ui-indicatorElement-indicator {
+       cursor: pointer;
+}
+.oo-ui-comboBoxInputWidget-php input::-webkit-calendar-picker-indicator {
+       opacity: 0 !important;
+       position: absolute;
+       right: 0;
+       top: 0;
+       height: 2.5em;
+       width: 2.5em;
+       padding: 0;
+}
+.oo-ui-comboBoxInputWidget-php > .oo-ui-indicatorElement-indicator {
+       pointer-events: none;
+}
+.oo-ui-comboBoxInputWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-comboBoxInputWidget.oo-ui-widget-disabled .oo-ui-textInputWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator,
+.oo-ui-comboBoxInputWidget-empty .oo-ui-textInputWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
+       cursor: default;
+       opacity: 0.2;
+}
+.oo-ui-comboBoxInputWidget > .oo-ui-selectWidget {
+       margin-top: -3px;
+}
diff --git a/resources/lib/oojs-ui/oojs-ui-core-mediawiki.css b/resources/lib/oojs-ui/oojs-ui-core-mediawiki.css
new file mode 100644 (file)
index 0000000..fe0d45b
--- /dev/null
@@ -0,0 +1,1380 @@
+/*!
+ * OOjs UI v0.15.2
+ * https://www.mediawiki.org/wiki/OOjs_UI
+ *
+ * Copyright 2011–2016 OOjs UI Team and other contributors.
+ * Released under the MIT license
+ * http://oojs.mit-license.org
+ *
+ * Date: 2016-02-02T22:07:06Z
+ */
+@-webkit-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@-moz-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+.oo-ui-element-hidden {
+       display: none !important;
+}
+.oo-ui-buttonElement > .oo-ui-buttonElement-button {
+       cursor: pointer;
+       display: inline-block;
+       vertical-align: middle;
+       font: inherit;
+       white-space: nowrap;
+       -webkit-touch-callout: none;
+       -webkit-user-select: none;
+          -moz-user-select: none;
+           -ms-user-select: none;
+               user-select: none;
+}
+.oo-ui-buttonElement > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon,
+.oo-ui-buttonElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
+       display: none;
+}
+.oo-ui-buttonElement.oo-ui-widget-disabled > .oo-ui-buttonElement-button {
+       cursor: default;
+}
+.oo-ui-buttonElement.oo-ui-indicatorElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator,
+.oo-ui-buttonElement.oo-ui-iconElement > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
+       display: inline-block;
+       vertical-align: middle;
+}
+.oo-ui-buttonElement-frameless {
+       display: inline-block;
+       position: relative;
+}
+.oo-ui-buttonElement-frameless.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       display: inline-block;
+       vertical-align: middle;
+}
+.oo-ui-buttonElement-framed > .oo-ui-buttonElement-button {
+       display: inline-block;
+       vertical-align: top;
+       text-align: center;
+}
+.oo-ui-buttonElement-framed.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       display: inline-block;
+       vertical-align: middle;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-disabled > .oo-ui-buttonElement-button,
+.oo-ui-buttonElement-framed.oo-ui-widget-disabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button,
+.oo-ui-buttonElement-framed.oo-ui-widget-disabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
+       cursor: default;
+}
+.oo-ui-buttonElement > .oo-ui-buttonElement-button {
+       font-weight: bold;
+       text-decoration: none;
+}
+.oo-ui-buttonElement.oo-ui-iconElement > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
+       margin-left: 0;
+}
+.oo-ui-buttonElement.oo-ui-indicatorElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
+       width: 0.9375em;
+       height: 0.9375em;
+}
+.oo-ui-buttonElement.oo-ui-iconElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
+       margin-left: 0.46875em;
+}
+.oo-ui-buttonElement.oo-ui-iconElement > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
+       width: 1.875em;
+       height: 1.875em;
+}
+.oo-ui-buttonElement-frameless > .oo-ui-buttonElement-button:focus {
+       box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(0, 0, 0, 0.2);
+       outline: none;
+}
+.oo-ui-buttonElement-frameless > .oo-ui-buttonElement-button .oo-ui-indicatorElement-indicator {
+       margin-right: 0;
+}
+.oo-ui-buttonElement-frameless.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       margin-left: 0.25em;
+       margin-right: 0.25em;
+}
+.oo-ui-buttonElement-frameless > input.oo-ui-buttonElement-button {
+       padding-left: 0.25em;
+       padding-right: 0.25em;
+       color: #333333;
+}
+.oo-ui-buttonElement-frameless.oo-ui-widget-enabled > input.oo-ui-buttonElement-button,
+.oo-ui-buttonElement-frameless.oo-ui-widget-enabled > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       color: #555555;
+}
+.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > input.oo-ui-buttonElement-button,
+.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       color: #444444;
+}
+.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button:hover > .oo-ui-labelElement-label,
+.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button:focus > .oo-ui-labelElement-label {
+       color: #2962cc;
+}
+.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       color: #347bff;
+}
+.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-progressive.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active > .oo-ui-labelElement-label,
+.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-progressive.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       color: #1f4999;
+       box-shadow: none;
+}
+.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button:hover > .oo-ui-labelElement-label,
+.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button:focus > .oo-ui-labelElement-label {
+       color: #008064;
+}
+.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       color: #00af89;
+}
+.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-constructive.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active > .oo-ui-labelElement-label,
+.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-constructive.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       color: #005946;
+       box-shadow: none;
+}
+.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-destructive > .oo-ui-buttonElement-button:hover > .oo-ui-labelElement-label,
+.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-destructive > .oo-ui-buttonElement-button:focus > .oo-ui-labelElement-label {
+       color: #8c130d;
+}
+.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-destructive > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       color: #d11d13;
+}
+.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-destructive.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active > .oo-ui-labelElement-label,
+.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-destructive.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       color: #73100a;
+       box-shadow: none;
+}
+.oo-ui-buttonElement-frameless.oo-ui-widget-disabled > .oo-ui-buttonElement-button {
+       color: #cccccc;
+}
+.oo-ui-buttonElement-frameless.oo-ui-widget-disabled > .oo-ui-buttonElement-button:focus {
+       box-shadow: none;
+}
+.oo-ui-buttonElement-frameless.oo-ui-widget-disabled > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon,
+.oo-ui-buttonElement-frameless.oo-ui-widget-disabled > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
+       opacity: 0.2;
+}
+.oo-ui-buttonElement-framed.oo-ui-iconElement.oo-ui-labelElement > .oo-ui-buttonElement-button,
+.oo-ui-buttonElement-framed.oo-ui-iconElement.oo-ui-indicatorElement > .oo-ui-buttonElement-button {
+       padding-left: 2.4em;
+}
+.oo-ui-buttonElement-framed > .oo-ui-buttonElement-button {
+       padding: 0.5em 1em;
+       min-height: 1.2em;
+       min-width: 1em;
+       border-radius: 2px;
+       position: relative;
+       -webkit-transition: background 100ms ease, color 100ms ease, border-color 100ms ease, box-shadow 100ms ease;
+          -moz-transition: background 100ms ease, color 100ms ease, border-color 100ms ease, box-shadow 100ms ease;
+               transition: background 100ms ease, color 100ms ease, border-color 100ms ease, box-shadow 100ms ease;
+}
+.oo-ui-buttonElement-framed > .oo-ui-buttonElement-button:hover,
+.oo-ui-buttonElement-framed > .oo-ui-buttonElement-button:focus {
+       outline: none;
+}
+.oo-ui-buttonElement-framed > input.oo-ui-buttonElement-button,
+.oo-ui-buttonElement-framed.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       line-height: 1.2em;
+       display: inline-block;
+}
+.oo-ui-buttonElement-framed.oo-ui-iconElement > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
+       position: absolute;
+       top: 0.2em;
+       left: 0.5625em;
+}
+.oo-ui-buttonElement-framed.oo-ui-iconElement.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       margin-left: 0.3em;
+}
+.oo-ui-buttonElement-framed.oo-ui-indicatorElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
+       display: inline-block;
+}
+.oo-ui-buttonElement-framed.oo-ui-indicatorElement.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator,
+.oo-ui-buttonElement-framed.oo-ui-indicatorElement.oo-ui-iconElement:not( .oo-ui-labelElement ) > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
+       margin-left: 0.46875em;
+       margin-right: -0.275em;
+}
+.oo-ui-buttonElement-framed.oo-ui-indicatorElement.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
+       position: relative;
+       left: 0.2em;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-disabled > .oo-ui-buttonElement-button {
+       background: #dddddd;
+       color: #ffffff;
+       border: 1px solid #dddddd;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled > .oo-ui-buttonElement-button {
+       color: #555555;
+       background-color: #ffffff;
+       border: 1px solid #cccccc;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled > .oo-ui-buttonElement-button:hover {
+       background-color: #ebebeb;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled > .oo-ui-buttonElement-button:focus {
+       box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2);
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active,
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
+       background-color: #d9d9d9;
+       border-color: #d9d9d9;
+       box-shadow: none;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button {
+       background-color: #999999;
+       color: #ffffff;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button {
+       color: #347bff;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button:hover {
+       background-color: rgba(52, 123, 255, 0.1);
+       border-color: rgba(31, 73, 153, 0.5);
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button:focus {
+       box-shadow: inset 0 0 0 1px #1f4999;
+       border-color: #1f4999;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-progressive.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active,
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-progressive.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
+       color: #1f4999;
+       border-color: #1f4999;
+       box-shadow: none;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-progressive.oo-ui-widget-enabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button {
+       background-color: #999999;
+       color: #ffffff;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button {
+       color: #00af89;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button:hover {
+       background-color: rgba(0, 171, 137, 0.1);
+       border-color: rgba(0, 89, 70, 0.5);
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button:focus {
+       box-shadow: inset 0 0 0 1px #005946;
+       border-color: #005946;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-constructive.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active,
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-constructive.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
+       color: #005946;
+       border-color: #005946;
+       box-shadow: none;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-constructive.oo-ui-widget-enabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button {
+       background-color: #999999;
+       color: #ffffff;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-destructive > .oo-ui-buttonElement-button {
+       color: #d11d13;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-destructive > .oo-ui-buttonElement-button:hover {
+       background-color: rgba(209, 29, 19, 0.1);
+       border-color: rgba(115, 16, 10, 0.5);
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-destructive > .oo-ui-buttonElement-button:focus {
+       box-shadow: inset 0 0 0 1px #73100a;
+       border-color: #73100a;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-destructive.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active,
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-destructive.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
+       color: #73100a;
+       border-color: #73100a;
+       box-shadow: none;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-destructive.oo-ui-widget-enabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button {
+       background-color: #999999;
+       color: #ffffff;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button {
+       color: #ffffff;
+       background-color: #347bff;
+       border-color: #347bff;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button:hover {
+       background: #2962cc;
+       border-color: #2962cc;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button:focus {
+       box-shadow: inset 0 0 0 1px #ffffff;
+       border-color: #347bff;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-progressive.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active,
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-progressive.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
+       color: #ffffff;
+       background-color: #1f4999;
+       border-color: #1f4999;
+       box-shadow: none;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-progressive.oo-ui-widget-enabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button {
+       background-color: #999999;
+       color: #ffffff;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button {
+       color: #ffffff;
+       background-color: #00af89;
+       border-color: #00af89;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button:hover {
+       background: #008064;
+       border-color: #008064;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button:focus {
+       box-shadow: inset 0 0 0 1px #ffffff;
+       border-color: #00af89;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-constructive.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active,
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-constructive.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
+       color: #ffffff;
+       background-color: #005946;
+       border-color: #005946;
+       box-shadow: none;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-constructive.oo-ui-widget-enabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button {
+       background-color: #999999;
+       color: #ffffff;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-destructive > .oo-ui-buttonElement-button {
+       color: #ffffff;
+       background-color: #d11d13;
+       border-color: #d11d13;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-destructive > .oo-ui-buttonElement-button:hover {
+       background: #8c130d;
+       border-color: #8c130d;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-destructive > .oo-ui-buttonElement-button:focus {
+       box-shadow: inset 0 0 0 1px #ffffff;
+       border-color: #d11d13;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-destructive.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active,
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-destructive.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
+       color: #ffffff;
+       background-color: #73100a;
+       border-color: #73100a;
+       box-shadow: none;
+}
+.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-destructive.oo-ui-widget-enabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button {
+       background-color: #999999;
+       color: #ffffff;
+}
+.oo-ui-clippableElement-clippable {
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-iconElement .oo-ui-iconElement-icon,
+.oo-ui-iconElement.oo-ui-iconElement-icon {
+       background-size: contain;
+       background-position: center center;
+       background-repeat: no-repeat;
+}
+.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator,
+.oo-ui-indicatorElement.oo-ui-indicatorElement-indicator {
+       background-size: contain;
+       background-position: center center;
+       background-repeat: no-repeat;
+}
+.oo-ui-pendingElement-pending {
+       background-image: /* @embed */ url(themes/mediawiki/images/textures/pending.gif);
+}
+.oo-ui-fieldLayout {
+       display: block;
+       margin-bottom: 1em;
+}
+.oo-ui-fieldLayout:before,
+.oo-ui-fieldLayout:after {
+       content: " ";
+       display: table;
+}
+.oo-ui-fieldLayout:after {
+       clear: both;
+}
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-left > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label,
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-right > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label,
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-left > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field,
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-right > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field {
+       display: block;
+       float: left;
+}
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-right > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
+       text-align: right;
+}
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline > .oo-ui-fieldLayout-body {
+       display: table;
+}
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label,
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field {
+       display: table-cell;
+       vertical-align: middle;
+}
+.oo-ui-fieldLayout.oo-ui-labelElement.oo-ui-fieldLayout-align-top > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
+       display: inline-block;
+}
+.oo-ui-fieldLayout > .oo-ui-fieldLayout-help {
+       float: right;
+}
+.oo-ui-fieldLayout > .oo-ui-fieldLayout-help > .oo-ui-popupWidget > .oo-ui-popupWidget-popup {
+       z-index: 1;
+}
+.oo-ui-fieldLayout > .oo-ui-fieldLayout-help .oo-ui-fieldLayout-help-content {
+       padding: 0.5em 0.75em;
+       line-height: 1.5em;
+}
+.oo-ui-fieldLayout:last-child {
+       margin-bottom: 0;
+}
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-left.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label,
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-right.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
+       padding-top: 0.5em;
+       margin-right: 5%;
+       width: 35%;
+}
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-left > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field,
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-right > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field {
+       width: 60%;
+}
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline {
+       margin-bottom: 1.25em;
+}
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
+       padding: 0.25em 0.25em 0.25em 1em;
+}
+.oo-ui-fieldLayout.oo-ui-fieldLayout-align-top.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
+       padding-top: 0.25em;
+       padding-bottom: 0.5em;
+}
+.oo-ui-fieldLayout > .oo-ui-popupButtonWidget {
+       margin-right: 0;
+}
+.oo-ui-fieldLayout > .oo-ui-popupButtonWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-fieldLayout-disabled > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
+       color: #cccccc;
+}
+.oo-ui-fieldLayout-messages {
+       list-style: none none;
+       margin: 0.25em 0 0 0.25em;
+       padding: 0;
+}
+.oo-ui-fieldLayout-messages > li {
+       margin: 0;
+       padding: 0;
+       display: table;
+}
+.oo-ui-fieldLayout-messages .oo-ui-iconWidget {
+       display: table-cell;
+       border-right: 0.5em solid transparent;
+}
+.oo-ui-fieldLayout-messages .oo-ui-labelWidget {
+       display: table-cell;
+       padding: 0;
+       line-height: 1.875em;
+       vertical-align: middle;
+}
+.oo-ui-actionFieldLayout-input,
+.oo-ui-actionFieldLayout-button {
+       display: table-cell;
+       vertical-align: middle;
+}
+.oo-ui-actionFieldLayout-input {
+       padding-right: 1em;
+}
+.oo-ui-actionFieldLayout-button {
+       width: 1%;
+       white-space: nowrap;
+}
+.oo-ui-fieldsetLayout {
+       position: relative;
+       margin: 0;
+       padding: 0;
+       border: 0;
+}
+.oo-ui-fieldsetLayout.oo-ui-iconElement > .oo-ui-iconElement-icon {
+       display: block;
+       position: absolute;
+}
+.oo-ui-fieldsetLayout.oo-ui-labelElement > .oo-ui-labelElement-label {
+       display: inline-block;
+}
+.oo-ui-fieldsetLayout > .oo-ui-fieldsetLayout-help {
+       float: right;
+}
+.oo-ui-fieldsetLayout > .oo-ui-fieldsetLayout-help > .oo-ui-popupWidget > .oo-ui-popupWidget-popup {
+       z-index: 1;
+}
+.oo-ui-fieldsetLayout > .oo-ui-fieldsetLayout-help .oo-ui-fieldsetLayout-help-content {
+       padding: 0.5em 0.75em;
+       line-height: 1.5em;
+}
+.oo-ui-fieldsetLayout + .oo-ui-fieldsetLayout,
+.oo-ui-fieldsetLayout + .oo-ui-formLayout {
+       margin-top: 2em;
+}
+.oo-ui-fieldsetLayout > .oo-ui-labelElement-label {
+       font-size: 1.1em;
+       margin-bottom: 0.5em;
+       padding: 0.25em 0;
+       font-weight: bold;
+}
+.oo-ui-fieldsetLayout.oo-ui-iconElement > .oo-ui-labelElement-label {
+       padding-left: 2em;
+       line-height: 1.8em;
+}
+.oo-ui-fieldsetLayout.oo-ui-iconElement > .oo-ui-iconElement-icon {
+       left: 0;
+       top: 0.25em;
+       width: 1.875em;
+       height: 1.875em;
+}
+.oo-ui-fieldsetLayout > .oo-ui-popupButtonWidget {
+       margin-right: 0;
+}
+.oo-ui-fieldsetLayout > .oo-ui-popupButtonWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-formLayout + .oo-ui-fieldsetLayout,
+.oo-ui-formLayout + .oo-ui-formLayout {
+       margin-top: 2em;
+}
+.oo-ui-panelLayout {
+       position: relative;
+}
+.oo-ui-panelLayout-scrollable {
+       overflow-y: auto;
+}
+.oo-ui-panelLayout-expanded {
+       position: absolute;
+       top: 0;
+       left: 0;
+       right: 0;
+       bottom: 0;
+}
+.oo-ui-panelLayout-padded {
+       padding: 1.25em;
+}
+.oo-ui-panelLayout-framed {
+       border: 1px solid #aaaaaa;
+       border-radius: 2px;
+       box-shadow: 0 0.15em 0 0 rgba(0, 0, 0, 0.15);
+}
+.oo-ui-panelLayout-padded.oo-ui-panelLayout-framed {
+       margin: 1em 0;
+}
+.oo-ui-horizontalLayout > .oo-ui-widget {
+       display: inline-block;
+       vertical-align: middle;
+}
+.oo-ui-horizontalLayout > .oo-ui-layout {
+       display: inline-block;
+}
+.oo-ui-horizontalLayout > .oo-ui-layout,
+.oo-ui-horizontalLayout > .oo-ui-widget {
+       margin-right: 0.5em;
+}
+.oo-ui-horizontalLayout > .oo-ui-layout:last-child,
+.oo-ui-horizontalLayout > .oo-ui-widget:last-child {
+       margin-right: 0;
+}
+.oo-ui-horizontalLayout > .oo-ui-layout {
+       margin-bottom: 0;
+}
+.oo-ui-optionWidget {
+       position: relative;
+       display: block;
+       padding: 0.25em 0.5em;
+       border: 0;
+}
+.oo-ui-optionWidget.oo-ui-widget-enabled {
+       cursor: pointer;
+}
+.oo-ui-optionWidget.oo-ui-labelElement .oo-ui-labelElement-label {
+       display: block;
+       white-space: nowrap;
+       text-overflow: ellipsis;
+       overflow: hidden;
+}
+.oo-ui-optionWidget-highlighted {
+       background-color: #eeeeee;
+}
+.oo-ui-optionWidget .oo-ui-labelElement-label {
+       line-height: 1.5em;
+}
+.oo-ui-selectWidget-depressed .oo-ui-optionWidget-selected,
+.oo-ui-selectWidget-pressed .oo-ui-optionWidget-pressed,
+.oo-ui-selectWidget-pressed .oo-ui-optionWidget-pressed.oo-ui-optionWidget-highlighted,
+.oo-ui-selectWidget-pressed .oo-ui-optionWidget-pressed.oo-ui-optionWidget-highlighted.oo-ui-optionWidget-selected {
+       background-color: #d0d0d0;
+}
+.oo-ui-optionWidget.oo-ui-widget-disabled {
+       color: #cccccc;
+}
+.oo-ui-decoratedOptionWidget {
+       padding: 0.5em 2em 0.5em 3em;
+}
+.oo-ui-decoratedOptionWidget .oo-ui-iconElement-icon,
+.oo-ui-decoratedOptionWidget .oo-ui-indicatorElement-indicator {
+       position: absolute;
+}
+.oo-ui-decoratedOptionWidget.oo-ui-iconElement .oo-ui-iconElement-icon,
+.oo-ui-decoratedOptionWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
+       top: 0;
+       height: 100%;
+}
+.oo-ui-decoratedOptionWidget.oo-ui-iconElement .oo-ui-iconElement-icon {
+       width: 1.875em;
+       left: 0.5em;
+}
+.oo-ui-decoratedOptionWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
+       width: 0.9375em;
+       right: 0.5em;
+}
+.oo-ui-decoratedOptionWidget.oo-ui-widget-disabled .oo-ui-iconElement-icon,
+.oo-ui-decoratedOptionWidget.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator {
+       opacity: 0.2;
+}
+.oo-ui-radioOptionWidget {
+       cursor: default;
+       padding: 0.25em 0;
+       background-color: transparent;
+}
+.oo-ui-radioOptionWidget .oo-ui-radioInputWidget,
+.oo-ui-radioOptionWidget.oo-ui-labelElement .oo-ui-labelElement-label {
+       display: inline-block;
+       vertical-align: middle;
+}
+.oo-ui-radioOptionWidget.oo-ui-optionWidget-selected,
+.oo-ui-radioOptionWidget.oo-ui-optionWidget-pressed,
+.oo-ui-radioOptionWidget.oo-ui-optionWidget-highlighted {
+       background-color: transparent;
+}
+.oo-ui-radioOptionWidget.oo-ui-labelElement .oo-ui-labelElement-label {
+       padding: 0.25em 0.25em 0.25em 1em;
+}
+.oo-ui-radioOptionWidget .oo-ui-radioInputWidget {
+       margin-right: 0;
+}
+.oo-ui-labelWidget {
+       display: inline-block;
+}
+.oo-ui-iconWidget {
+       display: inline-block;
+       vertical-align: middle;
+       line-height: 2.5em;
+       width: 1.875em;
+       height: 1.875em;
+}
+.oo-ui-iconWidget.oo-ui-widget-disabled {
+       opacity: 0.2;
+}
+.oo-ui-indicatorWidget {
+       display: inline-block;
+       vertical-align: middle;
+       line-height: 2.5em;
+       width: 0.9375em;
+       height: 0.9375em;
+       margin: 0.46875em;
+}
+.oo-ui-indicatorWidget.oo-ui-widget-disabled {
+       opacity: 0.2;
+}
+.oo-ui-buttonWidget {
+       display: inline-block;
+       vertical-align: middle;
+       margin-right: 0.5em;
+}
+.oo-ui-buttonWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-buttonGroupWidget {
+       display: inline-block;
+       white-space: nowrap;
+       border-radius: 2px;
+       margin-right: 0.5em;
+}
+.oo-ui-buttonGroupWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-buttonGroupWidget .oo-ui-buttonElement {
+       margin-right: 0;
+}
+.oo-ui-buttonGroupWidget .oo-ui-buttonElement:last-child {
+       margin-right: 0;
+}
+.oo-ui-buttonGroupWidget .oo-ui-buttonElement-framed .oo-ui-buttonElement-button {
+       border-radius: 0;
+       margin-left: -1px;
+}
+.oo-ui-buttonGroupWidget .oo-ui-buttonElement-framed:first-child .oo-ui-buttonElement-button {
+       border-bottom-left-radius: 2px;
+       border-top-left-radius: 2px;
+       margin-left: 0;
+}
+.oo-ui-buttonGroupWidget .oo-ui-buttonElement-framed:last-child .oo-ui-buttonElement-button {
+       border-bottom-right-radius: 2px;
+       border-top-right-radius: 2px;
+}
+.oo-ui-popupWidget {
+       position: absolute;
+       /* @noflip */
+       left: 0;
+}
+.oo-ui-popupWidget-popup {
+       position: relative;
+       overflow: hidden;
+       z-index: 1;
+}
+.oo-ui-popupWidget-anchor {
+       display: none;
+       z-index: 1;
+}
+.oo-ui-popupWidget-anchored .oo-ui-popupWidget-anchor {
+       display: block;
+       position: absolute;
+       top: 0;
+       /* @noflip */
+       left: 0;
+       background-repeat: no-repeat;
+}
+.oo-ui-popupWidget-head {
+       -webkit-touch-callout: none;
+       -webkit-user-select: none;
+          -moz-user-select: none;
+           -ms-user-select: none;
+               user-select: none;
+}
+.oo-ui-popupWidget-head > .oo-ui-buttonWidget {
+       float: right;
+}
+.oo-ui-popupWidget-head > .oo-ui-labelElement-label {
+       float: left;
+       cursor: default;
+}
+.oo-ui-popupWidget-body {
+       clear: both;
+       overflow: hidden;
+}
+.oo-ui-popupWidget-popup {
+       background-color: #ffffff;
+       border: 1px solid #aaaaaa;
+       border-radius: 2px;
+       box-shadow: 0 0.15em 0 0 rgba(0, 0, 0, 0.15);
+}
+.oo-ui-popupWidget-anchored .oo-ui-popupWidget-popup {
+       margin-top: 9px;
+}
+.oo-ui-popupWidget-anchored .oo-ui-popupWidget-anchor:before,
+.oo-ui-popupWidget-anchored .oo-ui-popupWidget-anchor:after {
+       content: "";
+       position: absolute;
+       width: 0;
+       height: 0;
+       border-style: solid;
+       border-color: transparent;
+       border-top: 0;
+}
+.oo-ui-popupWidget-anchored .oo-ui-popupWidget-anchor:before {
+       bottom: -10px;
+       left: -9px;
+       border-bottom-color: #888888;
+       border-width: 10px;
+}
+.oo-ui-popupWidget-anchored .oo-ui-popupWidget-anchor:after {
+       bottom: -10px;
+       left: -8px;
+       border-bottom-color: #ffffff;
+       border-width: 9px;
+}
+.oo-ui-popupWidget-transitioning .oo-ui-popupWidget-popup {
+       -webkit-transition: width 100ms ease, height 100ms ease, left 100ms ease;
+          -moz-transition: width 100ms ease, height 100ms ease, left 100ms ease;
+               transition: width 100ms ease, height 100ms ease, left 100ms ease;
+}
+.oo-ui-popupWidget-head {
+       height: 2.5em;
+}
+.oo-ui-popupWidget-head > .oo-ui-buttonWidget {
+       margin: 0.25em;
+}
+.oo-ui-popupWidget-head > .oo-ui-labelElement-label {
+       margin: 0.75em 1em;
+}
+.oo-ui-popupWidget-body-padded {
+       padding: 0 1em;
+}
+.oo-ui-popupButtonWidget {
+       position: relative;
+}
+.oo-ui-popupButtonWidget .oo-ui-popupWidget {
+       position: absolute;
+       cursor: auto;
+}
+.oo-ui-popupButtonWidget.oo-ui-buttonElement-frameless > .oo-ui-popupWidget {
+       /* @noflip */
+       left: 1em;
+}
+.oo-ui-popupButtonWidget.oo-ui-buttonElement-framed > .oo-ui-popupWidget {
+       /* @noflip */
+       left: 1.75em;
+}
+.oo-ui-inputWidget {
+       margin-right: 0.5em;
+}
+.oo-ui-inputWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-buttonInputWidget {
+       display: inline-block;
+       vertical-align: middle;
+}
+.oo-ui-buttonInputWidget > button,
+.oo-ui-buttonInputWidget > input {
+       border: 0;
+       padding: 0;
+       background-color: transparent;
+}
+.oo-ui-checkboxInputWidget {
+       position: relative;
+       line-height: 1.6em;
+       white-space: nowrap;
+}
+.oo-ui-checkboxInputWidget * {
+       font: inherit;
+       vertical-align: middle;
+}
+.oo-ui-checkboxInputWidget input[type="checkbox"] {
+       opacity: 0;
+       z-index: 1;
+       position: relative;
+       cursor: pointer;
+       margin: 0;
+       width: 1.6em;
+       height: 1.6em;
+       max-width: none;
+}
+.oo-ui-checkboxInputWidget input[type="checkbox"] + span {
+       -webkit-transition: background-size 200ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
+          -moz-transition: background-size 200ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
+               transition: background-size 200ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+       position: absolute;
+       left: 0;
+       border-radius: 2px;
+       width: 1.6em;
+       height: 1.6em;
+       background-color: white;
+       border: 1px solid #777777;
+       background-image: url("themes/mediawiki/images/icons/check-constructive.png");
+       background-image: -webkit-linear-gradient(transparent, transparent), /* @embed */ url("themes/mediawiki/images/icons/check-constructive.svg");
+       background-image:         linear-gradient(transparent, transparent), /* @embed */ url("themes/mediawiki/images/icons/check-constructive.svg");
+       background-image:      -o-linear-gradient(transparent, transparent), url("themes/mediawiki/images/icons/check-constructive.png");
+       background-repeat: no-repeat;
+       background-position: center center;
+       background-origin: border-box;
+       background-size: 0 0;
+}
+.oo-ui-checkboxInputWidget input[type="checkbox"]:checked + span {
+       background-size: 100% 100%;
+}
+.oo-ui-checkboxInputWidget input[type="checkbox"]:active + span {
+       background-color: #cccccc;
+       border-color: #cccccc;
+}
+.oo-ui-checkboxInputWidget input[type="checkbox"]:focus + span {
+       border-width: 2px;
+}
+.oo-ui-checkboxInputWidget input[type="checkbox"]:focus:hover + span,
+.oo-ui-checkboxInputWidget input[type="checkbox"]:hover + span {
+       border-bottom-width: 3px;
+}
+.oo-ui-checkboxInputWidget input[type="checkbox"]:disabled {
+       cursor: default;
+}
+.oo-ui-checkboxInputWidget input[type="checkbox"]:disabled + span {
+       background-color: #dddddd;
+       border-color: #dddddd;
+}
+.oo-ui-checkboxInputWidget input[type="checkbox"]:disabled:checked + span {
+       background-image: url("themes/mediawiki/images/icons/check-invert.png");
+       background-image: -webkit-linear-gradient(transparent, transparent), /* @embed */ url("themes/mediawiki/images/icons/check-invert.svg");
+       background-image:         linear-gradient(transparent, transparent), /* @embed */ url("themes/mediawiki/images/icons/check-invert.svg");
+       background-image:      -o-linear-gradient(transparent, transparent), url("themes/mediawiki/images/icons/check-invert.png");
+}
+.oo-ui-dropdownInputWidget {
+       position: relative;
+       vertical-align: middle;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+       width: 100%;
+       max-width: 50em;
+}
+.oo-ui-dropdownInputWidget select {
+       display: inline-block;
+       width: 100%;
+       resize: none;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-dropdownInputWidget select {
+       background-color: #ffffff;
+       height: 2.275em;
+       font-size: inherit;
+       font-family: inherit;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+       border: 1px solid #cccccc;
+       border-radius: 2px;
+       padding-left: 1em;
+       vertical-align: middle;
+}
+.oo-ui-dropdownInputWidget.oo-ui-widget-enabled select:hover,
+.oo-ui-dropdownInputWidget.oo-ui-widget-enabled select:focus {
+       border-color: #aaaaaa;
+       outline: none;
+}
+.oo-ui-dropdownInputWidget.oo-ui-widget-disabled select {
+       color: #cccccc;
+       border-color: #dddddd;
+       background-color: #f3f3f3;
+}
+.oo-ui-radioInputWidget {
+       position: relative;
+       line-height: 1.6em;
+       white-space: nowrap;
+}
+.oo-ui-radioInputWidget * {
+       font: inherit;
+       vertical-align: middle;
+}
+.oo-ui-radioInputWidget input[type="radio"] {
+       opacity: 0;
+       z-index: 1;
+       position: relative;
+       cursor: pointer;
+       margin: 0;
+       width: 1.6em;
+       height: 1.6em;
+       max-width: none;
+}
+.oo-ui-radioInputWidget input[type="radio"] + span {
+       -webkit-transition: background-size 200ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
+          -moz-transition: background-size 200ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
+               transition: background-size 200ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+       position: absolute;
+       left: 0;
+       border-radius: 100%;
+       width: 1.6em;
+       height: 1.6em;
+       background: white;
+       border: 1px solid #777777;
+       background-image: url("themes/mediawiki/images/icons/circle-constructive.png");
+       background-image: -webkit-linear-gradient(transparent, transparent), /* @embed */ url("themes/mediawiki/images/icons/circle-constructive.svg");
+       background-image:         linear-gradient(transparent, transparent), /* @embed */ url("themes/mediawiki/images/icons/circle-constructive.svg");
+       background-image:      -o-linear-gradient(transparent, transparent), url("themes/mediawiki/images/icons/circle-constructive.png");
+       background-repeat: no-repeat;
+       background-position: center center;
+       background-origin: border-box;
+       background-size: 0 0;
+}
+.oo-ui-radioInputWidget input[type="radio"]:checked + span {
+       background-size: 100% 100%;
+}
+.oo-ui-radioInputWidget input[type="radio"]:active + span {
+       background-color: #cccccc;
+       border-color: #cccccc;
+}
+.oo-ui-radioInputWidget input[type="radio"]:focus + span {
+       border-width: 2px;
+}
+.oo-ui-radioInputWidget input[type="radio"]:focus:hover + span,
+.oo-ui-radioInputWidget input[type="radio"]:hover + span {
+       border-bottom-width: 3px;
+}
+.oo-ui-radioInputWidget input[type="radio"]:disabled {
+       cursor: default;
+}
+.oo-ui-radioInputWidget input[type="radio"]:disabled + span {
+       background-color: #dddddd;
+       border-color: #dddddd;
+}
+.oo-ui-radioInputWidget input[type="radio"]:disabled:checked + span {
+       background-image: url("themes/mediawiki/images/icons/circle-invert.png");
+       background-image: -webkit-linear-gradient(transparent, transparent), /* @embed */ url("themes/mediawiki/images/icons/circle-invert.svg");
+       background-image:         linear-gradient(transparent, transparent), /* @embed */ url("themes/mediawiki/images/icons/circle-invert.svg");
+       background-image:      -o-linear-gradient(transparent, transparent), url("themes/mediawiki/images/icons/circle-invert.png");
+}
+.oo-ui-radioSelectInputWidget .oo-ui-fieldLayout {
+       margin-bottom: 0;
+}
+.oo-ui-textInputWidget {
+       position: relative;
+       vertical-align: middle;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+       width: 100%;
+       max-width: 50em;
+}
+.oo-ui-textInputWidget input,
+.oo-ui-textInputWidget textarea {
+       display: inline-block;
+       width: 100%;
+       resize: none;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-textInputWidget textarea {
+       overflow: auto;
+}
+.oo-ui-textInputWidget input[type="search"] {
+       -webkit-appearance: none;
+}
+.oo-ui-textInputWidget input[type="search"]::-ms-clear {
+       display: none;
+}
+.oo-ui-textInputWidget input[type="search"]::-ms-reveal {
+       display: none;
+}
+.oo-ui-textInputWidget input[type="search"]::-webkit-search-decoration,
+.oo-ui-textInputWidget input[type="search"]::-webkit-search-cancel-button,
+.oo-ui-textInputWidget input[type="search"]::-webkit-search-results-button,
+.oo-ui-textInputWidget input[type="search"]::-webkit-search-results-decoration {
+       display: none;
+}
+.oo-ui-textInputWidget > .oo-ui-iconElement-icon,
+.oo-ui-textInputWidget > .oo-ui-indicatorElement-indicator,
+.oo-ui-textInputWidget > .oo-ui-labelElement-label {
+       display: none;
+}
+.oo-ui-textInputWidget.oo-ui-iconElement > .oo-ui-iconElement-icon,
+.oo-ui-textInputWidget.oo-ui-indicatorElement > .oo-ui-indicatorElement-indicator {
+       display: block;
+       position: absolute;
+       top: 0;
+       height: 100%;
+       -webkit-touch-callout: none;
+       -webkit-user-select: none;
+          -moz-user-select: none;
+           -ms-user-select: none;
+               user-select: none;
+}
+.oo-ui-textInputWidget.oo-ui-widget-enabled > .oo-ui-iconElement-icon,
+.oo-ui-textInputWidget.oo-ui-widget-enabled > .oo-ui-indicatorElement-indicator {
+       cursor: text;
+}
+.oo-ui-textInputWidget.oo-ui-widget-enabled.oo-ui-textInputWidget-type-search > .oo-ui-indicatorElement-indicator {
+       cursor: pointer;
+}
+.oo-ui-textInputWidget.oo-ui-labelElement > .oo-ui-labelElement-label {
+       display: block;
+}
+.oo-ui-textInputWidget > .oo-ui-iconElement-icon {
+       left: 0;
+}
+.oo-ui-textInputWidget > .oo-ui-indicatorElement-indicator {
+       right: 0;
+}
+.oo-ui-textInputWidget > .oo-ui-labelElement-label {
+       position: absolute;
+       top: 0;
+}
+.oo-ui-textInputWidget-labelPosition-after > .oo-ui-labelElement-label {
+       right: 0;
+}
+.oo-ui-textInputWidget-labelPosition-before > .oo-ui-labelElement-label {
+       left: 0;
+}
+.oo-ui-textInputWidget input,
+.oo-ui-textInputWidget textarea {
+       padding: 0.5em;
+       line-height: 1.275em;
+       margin: 0;
+       font-size: inherit;
+       font-family: inherit;
+       background-color: #ffffff;
+       color: #000000;
+       border: 1px solid #cccccc;
+       border-radius: 2px;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-textInputWidget input.oo-ui-pendingElement-pending,
+.oo-ui-textInputWidget textarea.oo-ui-pendingElement-pending {
+       background-color: transparent;
+}
+.oo-ui-textInputWidget.oo-ui-widget-enabled input,
+.oo-ui-textInputWidget.oo-ui-widget-enabled textarea {
+       box-shadow: inset 0 0 0 0.1em #ffffff;
+       -webkit-transition: border 200ms cubic-bezier(0.39, 0.575, 0.565, 1), box-shadow 200ms cubic-bezier(0.39, 0.575, 0.565, 1);
+          -moz-transition: border 200ms cubic-bezier(0.39, 0.575, 0.565, 1), box-shadow 200ms cubic-bezier(0.39, 0.575, 0.565, 1);
+               transition: border 200ms cubic-bezier(0.39, 0.575, 0.565, 1), box-shadow 200ms cubic-bezier(0.39, 0.575, 0.565, 1);
+}
+.oo-ui-textInputWidget.oo-ui-widget-enabled input:focus,
+.oo-ui-textInputWidget.oo-ui-widget-enabled textarea:focus {
+       outline: none;
+       border-color: #347bff;
+       box-shadow: inset 0 0 0 0.1em #347bff;
+}
+.oo-ui-textInputWidget.oo-ui-widget-enabled input[readonly],
+.oo-ui-textInputWidget.oo-ui-widget-enabled textarea[readonly] {
+       color: #777777;
+       text-shadow: 0 1px 1px #ffffff;
+}
+.oo-ui-textInputWidget.oo-ui-widget-enabled input[readonly]:focus,
+.oo-ui-textInputWidget.oo-ui-widget-enabled textarea[readonly]:focus {
+       border-color: #cccccc;
+       box-shadow: inset 0 0 0 0.1em #cccccc;
+}
+.oo-ui-textInputWidget.oo-ui-widget-enabled.oo-ui-flaggedElement-invalid input,
+.oo-ui-textInputWidget.oo-ui-widget-enabled.oo-ui-flaggedElement-invalid textarea {
+       border-color: #ff0000;
+}
+.oo-ui-textInputWidget.oo-ui-widget-enabled.oo-ui-flaggedElement-invalid input:focus,
+.oo-ui-textInputWidget.oo-ui-widget-enabled.oo-ui-flaggedElement-invalid textarea:focus {
+       border-color: #ff0000;
+       box-shadow: inset 0 0 0 0.1em #ff0000;
+}
+.oo-ui-textInputWidget.oo-ui-widget-disabled input,
+.oo-ui-textInputWidget.oo-ui-widget-disabled textarea {
+       color: #cccccc;
+       text-shadow: 0 1px 1px #ffffff;
+       border-color: #dddddd;
+       background-color: #f3f3f3;
+}
+.oo-ui-textInputWidget.oo-ui-widget-disabled .oo-ui-iconElement-icon,
+.oo-ui-textInputWidget.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator {
+       opacity: 0.2;
+}
+.oo-ui-textInputWidget.oo-ui-widget-disabled .oo-ui-labelElement-label {
+       color: #dddddd;
+       text-shadow: 0 1px 1px #ffffff;
+}
+.oo-ui-textInputWidget.oo-ui-iconElement input,
+.oo-ui-textInputWidget.oo-ui-iconElement textarea {
+       padding-left: 2.875em;
+}
+.oo-ui-textInputWidget.oo-ui-iconElement .oo-ui-iconElement-icon {
+       left: 0;
+       width: 1.875em;
+       max-height: 2.375em;
+       margin-left: 0.5em;
+       height: 100%;
+       background-position: right center;
+}
+.oo-ui-textInputWidget.oo-ui-indicatorElement input,
+.oo-ui-textInputWidget.oo-ui-indicatorElement textarea {
+       padding-right: 2.4875em;
+}
+.oo-ui-textInputWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
+       width: 0.9375em;
+       max-height: 2.375em;
+       margin: 0 0.775em;
+       height: 100%;
+}
+.oo-ui-textInputWidget > .oo-ui-labelElement-label {
+       padding: 0.4em;
+       line-height: 1.5em;
+       color: #888888;
+}
+.oo-ui-textInputWidget-labelPosition-after.oo-ui-indicatorElement > .oo-ui-labelElement-label {
+       margin-right: 2.0875em;
+}
+.oo-ui-textInputWidget-labelPosition-before.oo-ui-iconElement > .oo-ui-labelElement-label {
+       margin-left: 2.475em;
+}
+.oo-ui-menuSelectWidget {
+       position: absolute;
+       background-color: #ffffff;
+       margin-top: -1px;
+       border: 1px solid #aaaaaa;
+       border-radius: 0 0 2px 2px;
+       box-shadow: 0 0.15em 0 0 rgba(0, 0, 0, 0.15);
+}
+.oo-ui-menuSelectWidget input {
+       position: absolute;
+       width: 0;
+       height: 0;
+       overflow: hidden;
+       opacity: 0;
+}
+.oo-ui-menuOptionWidget {
+       position: relative;
+       padding: 0.5em 1em;
+}
+.oo-ui-menuOptionWidget .oo-ui-iconElement-icon {
+       display: none;
+}
+.oo-ui-menuOptionWidget.oo-ui-optionWidget-selected {
+       background-color: transparent;
+}
+.oo-ui-menuOptionWidget.oo-ui-optionWidget-selected .oo-ui-iconElement-icon {
+       display: block;
+}
+.oo-ui-menuOptionWidget.oo-ui-optionWidget-selected {
+       background-color: #d8e6fe;
+       color: rgba(0, 0, 0, 0.8);
+}
+.oo-ui-menuOptionWidget.oo-ui-optionWidget-selected .oo-ui-iconElement-icon {
+       display: none;
+}
+.oo-ui-menuOptionWidget.oo-ui-optionWidget-highlighted {
+       background-color: #eeeeee;
+       color: #000000;
+}
+.oo-ui-menuOptionWidget.oo-ui-optionWidget-selected.oo-ui-menuOptionWidget.oo-ui-optionWidget-highlighted {
+       background-color: #d8e6fe;
+}
+.oo-ui-menuSectionOptionWidget {
+       cursor: default;
+       padding: 0.33em 0.75em;
+       color: #888888;
+}
+.oo-ui-dropdownWidget {
+       display: inline-block;
+       position: relative;
+       width: 100%;
+       max-width: 50em;
+       background-color: #ffffff;
+       margin-right: 0.5em;
+}
+.oo-ui-dropdownWidget-handle {
+       width: 100%;
+       display: inline-block;
+       white-space: nowrap;
+       overflow: hidden;
+       text-overflow: ellipsis;
+       -webkit-touch-callout: none;
+       -webkit-user-select: none;
+          -moz-user-select: none;
+           -ms-user-select: none;
+               user-select: none;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-dropdownWidget-handle .oo-ui-indicatorElement-indicator,
+.oo-ui-dropdownWidget-handle .oo-ui-iconElement-icon {
+       position: absolute;
+}
+.oo-ui-dropdownWidget > .oo-ui-menuSelectWidget {
+       z-index: 1;
+       width: 100%;
+}
+.oo-ui-dropdownWidget.oo-ui-widget-enabled .oo-ui-dropdownWidget-handle {
+       cursor: pointer;
+}
+.oo-ui-dropdownWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-dropdownWidget-handle {
+       padding: 0.5em 0;
+       height: 2.275em;
+       line-height: 1.275;
+       border: 1px solid #cccccc;
+       border-radius: 2px;
+}
+.oo-ui-dropdownWidget-handle .oo-ui-indicatorElement-indicator {
+       right: 0;
+}
+.oo-ui-dropdownWidget-handle .oo-ui-iconElement-icon {
+       left: 0.25em;
+}
+.oo-ui-dropdownWidget-handle .oo-ui-labelElement-label {
+       line-height: 1.275em;
+       margin: 0 1em;
+}
+.oo-ui-dropdownWidget-handle .oo-ui-indicatorElement-indicator {
+       top: 0;
+       width: 0.9375em;
+       height: 0.9375em;
+       margin: 0.775em;
+}
+.oo-ui-dropdownWidget-handle .oo-ui-iconElement-icon {
+       top: 0;
+       width: 1.875em;
+       height: 1.875em;
+       margin: 0.3em;
+}
+.oo-ui-dropdownWidget:hover .oo-ui-dropdownWidget-handle {
+       border-color: #aaaaaa;
+}
+.oo-ui-dropdownWidget.oo-ui-widget-disabled .oo-ui-dropdownWidget-handle {
+       color: #cccccc;
+       text-shadow: 0 1px 1px #ffffff;
+       border-color: #dddddd;
+       background-color: #f3f3f3;
+}
+.oo-ui-dropdownWidget.oo-ui-widget-disabled .oo-ui-dropdownWidget-handle:focus {
+       outline: 0;
+}
+.oo-ui-dropdownWidget.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator {
+       opacity: 0.2;
+}
+.oo-ui-dropdownWidget.oo-ui-iconElement .oo-ui-dropdownWidget-handle .oo-ui-labelElement-label {
+       margin-left: 3em;
+}
+.oo-ui-dropdownWidget.oo-ui-indicatorElement .oo-ui-dropdownWidget-handle .oo-ui-labelElement-label {
+       margin-right: 2em;
+}
+.oo-ui-dropdownWidget .oo-ui-selectWidget {
+       border-top-color: #ffffff;
+}
+.oo-ui-comboBoxInputWidget {
+       display: inline-block;
+       position: relative;
+       width: 100%;
+       max-width: 50em;
+       margin-right: 0.5em;
+}
+.oo-ui-comboBoxInputWidget > .oo-ui-menuSelectWidget {
+       z-index: 1;
+       width: 100%;
+}
+.oo-ui-comboBoxInputWidget.oo-ui-widget-enabled > .oo-ui-indicatorElement-indicator {
+       cursor: pointer;
+}
+.oo-ui-comboBoxInputWidget-php input::-webkit-calendar-picker-indicator {
+       opacity: 0 !important;
+       position: absolute;
+       right: 0;
+       top: 0;
+       height: 2.5em;
+       width: 2.5em;
+       padding: 0;
+}
+.oo-ui-comboBoxInputWidget-php > .oo-ui-indicatorElement-indicator {
+       pointer-events: none;
+}
+.oo-ui-comboBoxInputWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-comboBoxInputWidget input,
+.oo-ui-comboBoxInputWidget textarea {
+       height: 2.35em;
+}
diff --git a/resources/lib/oojs-ui/oojs-ui-core.js b/resources/lib/oojs-ui/oojs-ui-core.js
new file mode 100644 (file)
index 0000000..2d5ed3a
--- /dev/null
@@ -0,0 +1,9368 @@
+/*!
+ * OOjs UI v0.15.2
+ * https://www.mediawiki.org/wiki/OOjs_UI
+ *
+ * Copyright 2011–2016 OOjs UI Team and other contributors.
+ * Released under the MIT license
+ * http://oojs.mit-license.org
+ *
+ * Date: 2016-02-02T22:07:00Z
+ */
+( function ( OO ) {
+
+'use strict';
+
+/**
+ * Namespace for all classes, static methods and static properties.
+ *
+ * @class
+ * @singleton
+ */
+OO.ui = {};
+
+OO.ui.bind = $.proxy;
+
+/**
+ * @property {Object}
+ */
+OO.ui.Keys = {
+       UNDEFINED: 0,
+       BACKSPACE: 8,
+       DELETE: 46,
+       LEFT: 37,
+       RIGHT: 39,
+       UP: 38,
+       DOWN: 40,
+       ENTER: 13,
+       END: 35,
+       HOME: 36,
+       TAB: 9,
+       PAGEUP: 33,
+       PAGEDOWN: 34,
+       ESCAPE: 27,
+       SHIFT: 16,
+       SPACE: 32
+};
+
+/**
+ * Constants for MouseEvent.which
+ *
+ * @property {Object}
+ */
+OO.ui.MouseButtons = {
+       LEFT: 1,
+       MIDDLE: 2,
+       RIGHT: 3
+};
+
+/**
+ * @property {Number}
+ */
+OO.ui.elementId = 0;
+
+/**
+ * Generate a unique ID for element
+ *
+ * @return {String} [id]
+ */
+OO.ui.generateElementId = function () {
+       OO.ui.elementId += 1;
+       return 'oojsui-' + OO.ui.elementId;
+};
+
+/**
+ * Check if an element is focusable.
+ * Inspired from :focusable in jQueryUI v1.11.4 - 2015-04-14
+ *
+ * @param {jQuery} element Element to test
+ * @return {boolean}
+ */
+OO.ui.isFocusableElement = function ( $element ) {
+       var nodeName,
+               element = $element[ 0 ];
+
+       // Anything disabled is not focusable
+       if ( element.disabled ) {
+               return false;
+       }
+
+       // Check if the element is visible
+       if ( !(
+               // This is quicker than calling $element.is( ':visible' )
+               $.expr.filters.visible( element ) &&
+               // Check that all parents are visible
+               !$element.parents().addBack().filter( function () {
+                       return $.css( this, 'visibility' ) === 'hidden';
+               } ).length
+       ) ) {
+               return false;
+       }
+
+       // Check if the element is ContentEditable, which is the string 'true'
+       if ( element.contentEditable === 'true' ) {
+               return true;
+       }
+
+       // Anything with a non-negative numeric tabIndex is focusable.
+       // Use .prop to avoid browser bugs
+       if ( $element.prop( 'tabIndex' ) >= 0 ) {
+               return true;
+       }
+
+       // Some element types are naturally focusable
+       // (indexOf is much faster than regex in Chrome and about the
+       // same in FF: https://jsperf.com/regex-vs-indexof-array2)
+       nodeName = element.nodeName.toLowerCase();
+       if ( [ 'input', 'select', 'textarea', 'button', 'object' ].indexOf( nodeName ) !== -1 ) {
+               return true;
+       }
+
+       // Links and areas are focusable if they have an href
+       if ( ( nodeName === 'a' || nodeName === 'area' ) && $element.attr( 'href' ) !== undefined ) {
+               return true;
+       }
+
+       return false;
+};
+
+/**
+ * Find a focusable child
+ *
+ * @param {jQuery} $container Container to search in
+ * @param {boolean} [backwards] Search backwards
+ * @return {jQuery} Focusable child, an empty jQuery object if none found
+ */
+OO.ui.findFocusable = function ( $container, backwards ) {
+       var $focusable = $( [] ),
+               // $focusableCandidates is a superset of things that
+               // could get matched by isFocusableElement
+               $focusableCandidates = $container
+                       .find( 'input, select, textarea, button, object, a, area, [contenteditable], [tabindex]' );
+
+       if ( backwards ) {
+               $focusableCandidates = Array.prototype.reverse.call( $focusableCandidates );
+       }
+
+       $focusableCandidates.each( function () {
+               var $this = $( this );
+               if ( OO.ui.isFocusableElement( $this ) ) {
+                       $focusable = $this;
+                       return false;
+               }
+       } );
+       return $focusable;
+};
+
+/**
+ * Get the user's language and any fallback languages.
+ *
+ * These language codes are used to localize user interface elements in the user's language.
+ *
+ * In environments that provide a localization system, this function should be overridden to
+ * return the user's language(s). The default implementation returns English (en) only.
+ *
+ * @return {string[]} Language codes, in descending order of priority
+ */
+OO.ui.getUserLanguages = function () {
+       return [ 'en' ];
+};
+
+/**
+ * Get a value in an object keyed by language code.
+ *
+ * @param {Object.<string,Mixed>} obj Object keyed by language code
+ * @param {string|null} [lang] Language code, if omitted or null defaults to any user language
+ * @param {string} [fallback] Fallback code, used if no matching language can be found
+ * @return {Mixed} Local value
+ */
+OO.ui.getLocalValue = function ( obj, lang, fallback ) {
+       var i, len, langs;
+
+       // Requested language
+       if ( obj[ lang ] ) {
+               return obj[ lang ];
+       }
+       // Known user language
+       langs = OO.ui.getUserLanguages();
+       for ( i = 0, len = langs.length; i < len; i++ ) {
+               lang = langs[ i ];
+               if ( obj[ lang ] ) {
+                       return obj[ lang ];
+               }
+       }
+       // Fallback language
+       if ( obj[ fallback ] ) {
+               return obj[ fallback ];
+       }
+       // First existing language
+       for ( lang in obj ) {
+               return obj[ lang ];
+       }
+
+       return undefined;
+};
+
+/**
+ * Check if a node is contained within another node
+ *
+ * Similar to jQuery#contains except a list of containers can be supplied
+ * and a boolean argument allows you to include the container in the match list
+ *
+ * @param {HTMLElement|HTMLElement[]} containers Container node(s) to search in
+ * @param {HTMLElement} contained Node to find
+ * @param {boolean} [matchContainers] Include the container(s) in the list of nodes to match, otherwise only match descendants
+ * @return {boolean} The node is in the list of target nodes
+ */
+OO.ui.contains = function ( containers, contained, matchContainers ) {
+       var i;
+       if ( !Array.isArray( containers ) ) {
+               containers = [ containers ];
+       }
+       for ( i = containers.length - 1; i >= 0; i-- ) {
+               if ( ( matchContainers && contained === containers[ i ] ) || $.contains( containers[ i ], contained ) ) {
+                       return true;
+               }
+       }
+       return false;
+};
+
+/**
+ * Return a function, that, as long as it continues to be invoked, will not
+ * be triggered. The function will be called after it stops being called for
+ * N milliseconds. If `immediate` is passed, trigger the function on the
+ * leading edge, instead of the trailing.
+ *
+ * Ported from: http://underscorejs.org/underscore.js
+ *
+ * @param {Function} func
+ * @param {number} wait
+ * @param {boolean} immediate
+ * @return {Function}
+ */
+OO.ui.debounce = function ( func, wait, immediate ) {
+       var timeout;
+       return function () {
+               var context = this,
+                       args = arguments,
+                       later = function () {
+                               timeout = null;
+                               if ( !immediate ) {
+                                       func.apply( context, args );
+                               }
+                       };
+               if ( immediate && !timeout ) {
+                       func.apply( context, args );
+               }
+               clearTimeout( timeout );
+               timeout = setTimeout( later, wait );
+       };
+};
+
+/**
+ * Proxy for `node.addEventListener( eventName, handler, true )`.
+ *
+ * @param {HTMLElement} node
+ * @param {string} eventName
+ * @param {Function} handler
+ * @deprecated
+ */
+OO.ui.addCaptureEventListener = function ( node, eventName, handler ) {
+       node.addEventListener( eventName, handler, true );
+};
+
+/**
+ * Proxy for `node.removeEventListener( eventName, handler, true )`.
+ *
+ * @param {HTMLElement} node
+ * @param {string} eventName
+ * @param {Function} handler
+ * @deprecated
+ */
+OO.ui.removeCaptureEventListener = function ( node, eventName, handler ) {
+       node.removeEventListener( eventName, handler, true );
+};
+
+/**
+ * Reconstitute a JavaScript object corresponding to a widget created by
+ * the PHP implementation.
+ *
+ * This is an alias for `OO.ui.Element.static.infuse()`.
+ *
+ * @param {string|HTMLElement|jQuery} idOrNode
+ *   A DOM id (if a string) or node for the widget to infuse.
+ * @return {OO.ui.Element}
+ *   The `OO.ui.Element` corresponding to this (infusable) document node.
+ */
+OO.ui.infuse = function ( idOrNode ) {
+       return OO.ui.Element.static.infuse( idOrNode );
+};
+
+( function () {
+       /**
+        * Message store for the default implementation of OO.ui.msg
+        *
+        * Environments that provide a localization system should not use this, but should override
+        * OO.ui.msg altogether.
+        *
+        * @private
+        */
+       var messages = {
+               // Tool tip for a button that moves items in a list down one place
+               'ooui-outline-control-move-down': 'Move item down',
+               // Tool tip for a button that moves items in a list up one place
+               'ooui-outline-control-move-up': 'Move item up',
+               // Tool tip for a button that removes items from a list
+               'ooui-outline-control-remove': 'Remove item',
+               // Label for the toolbar group that contains a list of all other available tools
+               'ooui-toolbar-more': 'More',
+               // Label for the fake tool that expands the full list of tools in a toolbar group
+               'ooui-toolgroup-expand': 'More',
+               // Label for the fake tool that collapses the full list of tools in a toolbar group
+               'ooui-toolgroup-collapse': 'Fewer',
+               // Default label for the accept button of a confirmation dialog
+               'ooui-dialog-message-accept': 'OK',
+               // Default label for the reject button of a confirmation dialog
+               'ooui-dialog-message-reject': 'Cancel',
+               // Title for process dialog error description
+               'ooui-dialog-process-error': 'Something went wrong',
+               // Label for process dialog dismiss error button, visible when describing errors
+               'ooui-dialog-process-dismiss': 'Dismiss',
+               // Label for process dialog retry action button, visible when describing only recoverable errors
+               'ooui-dialog-process-retry': 'Try again',
+               // Label for process dialog retry action button, visible when describing only warnings
+               'ooui-dialog-process-continue': 'Continue',
+               // Label for the file selection widget's select file button
+               'ooui-selectfile-button-select': 'Select a file',
+               // Label for the file selection widget if file selection is not supported
+               'ooui-selectfile-not-supported': 'File selection is not supported',
+               // Label for the file selection widget when no file is currently selected
+               'ooui-selectfile-placeholder': 'No file is selected',
+               // Label for the file selection widget's drop target
+               'ooui-selectfile-dragdrop-placeholder': 'Drop file here'
+       };
+
+       /**
+        * Get a localized message.
+        *
+        * In environments that provide a localization system, this function should be overridden to
+        * return the message translated in the user's language. The default implementation always returns
+        * English messages.
+        *
+        * After the message key, message parameters may optionally be passed. In the default implementation,
+        * any occurrences of $1 are replaced with the first parameter, $2 with the second parameter, etc.
+        * Alternative implementations of OO.ui.msg may use any substitution system they like, as long as
+        * they support unnamed, ordered message parameters.
+        *
+        * @param {string} key Message key
+        * @param {Mixed...} [params] Message parameters
+        * @return {string} Translated message with parameters substituted
+        */
+       OO.ui.msg = function ( key ) {
+               var message = messages[ key ],
+                       params = Array.prototype.slice.call( arguments, 1 );
+               if ( typeof message === 'string' ) {
+                       // Perform $1 substitution
+                       message = message.replace( /\$(\d+)/g, function ( unused, n ) {
+                               var i = parseInt( n, 10 );
+                               return params[ i - 1 ] !== undefined ? params[ i - 1 ] : '$' + n;
+                       } );
+               } else {
+                       // Return placeholder if message not found
+                       message = '[' + key + ']';
+               }
+               return message;
+       };
+} )();
+
+/**
+ * Package a message and arguments for deferred resolution.
+ *
+ * Use this when you are statically specifying a message and the message may not yet be present.
+ *
+ * @param {string} key Message key
+ * @param {Mixed...} [params] Message parameters
+ * @return {Function} Function that returns the resolved message when executed
+ */
+OO.ui.deferMsg = function () {
+       var args = arguments;
+       return function () {
+               return OO.ui.msg.apply( OO.ui, args );
+       };
+};
+
+/**
+ * Resolve a message.
+ *
+ * If the message is a function it will be executed, otherwise it will pass through directly.
+ *
+ * @param {Function|string} msg Deferred message, or message text
+ * @return {string} Resolved message
+ */
+OO.ui.resolveMsg = function ( msg ) {
+       if ( $.isFunction( msg ) ) {
+               return msg();
+       }
+       return msg;
+};
+
+/**
+ * @param {string} url
+ * @return {boolean}
+ */
+OO.ui.isSafeUrl = function ( url ) {
+       // Keep this function in sync with php/Tag.php
+       var i, protocolWhitelist;
+
+       function stringStartsWith( haystack, needle ) {
+               return haystack.substr( 0, needle.length ) === needle;
+       }
+
+       protocolWhitelist = [
+               'bitcoin', 'ftp', 'ftps', 'geo', 'git', 'gopher', 'http', 'https', 'irc', 'ircs',
+               'magnet', 'mailto', 'mms', 'news', 'nntp', 'redis', 'sftp', 'sip', 'sips', 'sms', 'ssh',
+               'svn', 'tel', 'telnet', 'urn', 'worldwind', 'xmpp'
+       ];
+
+       if ( url === '' ) {
+               return true;
+       }
+
+       for ( i = 0; i < protocolWhitelist.length; i++ ) {
+               if ( stringStartsWith( url, protocolWhitelist[ i ] + ':' ) ) {
+                       return true;
+               }
+       }
+
+       // This matches '//' too
+       if ( stringStartsWith( url, '/' ) || stringStartsWith( url, './' ) ) {
+               return true;
+       }
+       if ( stringStartsWith( url, '?' ) || stringStartsWith( url, '#' ) ) {
+               return true;
+       }
+
+       return false;
+};
+
+/*!
+ * Mixin namespace.
+ */
+
+/**
+ * Namespace for OOjs UI mixins.
+ *
+ * Mixins are named according to the type of object they are intended to
+ * be mixed in to.  For example, OO.ui.mixin.GroupElement is intended to be
+ * mixed in to an instance of OO.ui.Element, and OO.ui.mixin.GroupWidget
+ * is intended to be mixed in to an instance of OO.ui.Widget.
+ *
+ * @class
+ * @singleton
+ */
+OO.ui.mixin = {};
+
+/**
+ * Each Element represents a rendering in the DOM—a button or an icon, for example, or anything
+ * that is visible to a user. Unlike {@link OO.ui.Widget widgets}, plain elements usually do not have events
+ * connected to them and can't be interacted with.
+ *
+ * @abstract
+ * @class
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {string[]} [classes] The names of the CSS classes to apply to the element. CSS styles are added
+ *  to the top level (e.g., the outermost div) of the element. See the [OOjs UI documentation on MediaWiki][2]
+ *  for an example.
+ *  [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches#cssExample
+ * @cfg {string} [id] The HTML id attribute used in the rendered tag.
+ * @cfg {string} [text] Text to insert
+ * @cfg {Array} [content] An array of content elements to append (after #text).
+ *  Strings will be html-escaped; use an OO.ui.HtmlSnippet to append raw HTML.
+ *  Instances of OO.ui.Element will have their $element appended.
+ * @cfg {jQuery} [$content] Content elements to append (after #text).
+ * @cfg {jQuery} [$element] Wrapper element. Defaults to a new element with #getTagName.
+ * @cfg {Mixed} [data] Custom data of any type or combination of types (e.g., string, number, array, object).
+ *  Data can also be specified with the #setData method.
+ */
+OO.ui.Element = function OoUiElement( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Properties
+       this.$ = $;
+       this.visible = true;
+       this.data = config.data;
+       this.$element = config.$element ||
+               $( document.createElement( this.getTagName() ) );
+       this.elementGroup = null;
+       this.debouncedUpdateThemeClassesHandler = OO.ui.debounce( this.debouncedUpdateThemeClasses );
+
+       // Initialization
+       if ( Array.isArray( config.classes ) ) {
+               this.$element.addClass( config.classes.join( ' ' ) );
+       }
+       if ( config.id ) {
+               this.$element.attr( 'id', config.id );
+       }
+       if ( config.text ) {
+               this.$element.text( config.text );
+       }
+       if ( config.content ) {
+               // The `content` property treats plain strings as text; use an
+               // HtmlSnippet to append HTML content.  `OO.ui.Element`s get their
+               // appropriate $element appended.
+               this.$element.append( config.content.map( function ( v ) {
+                       if ( typeof v === 'string' ) {
+                               // Escape string so it is properly represented in HTML.
+                               return document.createTextNode( v );
+                       } else if ( v instanceof OO.ui.HtmlSnippet ) {
+                               // Bypass escaping.
+                               return v.toString();
+                       } else if ( v instanceof OO.ui.Element ) {
+                               return v.$element;
+                       }
+                       return v;
+               } ) );
+       }
+       if ( config.$content ) {
+               // The `$content` property treats plain strings as HTML.
+               this.$element.append( config.$content );
+       }
+};
+
+/* Setup */
+
+OO.initClass( OO.ui.Element );
+
+/* Static Properties */
+
+/**
+ * The name of the HTML tag used by the element.
+ *
+ * The static value may be ignored if the #getTagName method is overridden.
+ *
+ * @static
+ * @inheritable
+ * @property {string}
+ */
+OO.ui.Element.static.tagName = 'div';
+
+/* Static Methods */
+
+/**
+ * Reconstitute a JavaScript object corresponding to a widget created
+ * by the PHP implementation.
+ *
+ * @param {string|HTMLElement|jQuery} idOrNode
+ *   A DOM id (if a string) or node for the widget to infuse.
+ * @return {OO.ui.Element}
+ *   The `OO.ui.Element` corresponding to this (infusable) document node.
+ *   For `Tag` objects emitted on the HTML side (used occasionally for content)
+ *   the value returned is a newly-created Element wrapping around the existing
+ *   DOM node.
+ */
+OO.ui.Element.static.infuse = function ( idOrNode ) {
+       var obj = OO.ui.Element.static.unsafeInfuse( idOrNode, false );
+       // Verify that the type matches up.
+       // FIXME: uncomment after T89721 is fixed (see T90929)
+       /*
+       if ( !( obj instanceof this['class'] ) ) {
+               throw new Error( 'Infusion type mismatch!' );
+       }
+       */
+       return obj;
+};
+
+/**
+ * Implementation helper for `infuse`; skips the type check and has an
+ * extra property so that only the top-level invocation touches the DOM.
+ * @private
+ * @param {string|HTMLElement|jQuery} idOrNode
+ * @param {jQuery.Promise|boolean} domPromise A promise that will be resolved
+ *     when the top-level widget of this infusion is inserted into DOM,
+ *     replacing the original node; or false for top-level invocation.
+ * @return {OO.ui.Element}
+ */
+OO.ui.Element.static.unsafeInfuse = function ( idOrNode, domPromise ) {
+       // look for a cached result of a previous infusion.
+       var id, $elem, data, cls, parts, parent, obj, top, state;
+       if ( typeof idOrNode === 'string' ) {
+               id = idOrNode;
+               $elem = $( document.getElementById( id ) );
+       } else {
+               $elem = $( idOrNode );
+               id = $elem.attr( 'id' );
+       }
+       if ( !$elem.length ) {
+               throw new Error( 'Widget not found: ' + id );
+       }
+       data = $elem.data( 'ooui-infused' ) || $elem[ 0 ].oouiInfused;
+       if ( data ) {
+               // cached!
+               if ( data === true ) {
+                       throw new Error( 'Circular dependency! ' + id );
+               }
+               return data;
+       }
+       data = $elem.attr( 'data-ooui' );
+       if ( !data ) {
+               throw new Error( 'No infusion data found: ' + id );
+       }
+       try {
+               data = $.parseJSON( data );
+       } catch ( _ ) {
+               data = null;
+       }
+       if ( !( data && data._ ) ) {
+               throw new Error( 'No valid infusion data found: ' + id );
+       }
+       if ( data._ === 'Tag' ) {
+               // Special case: this is a raw Tag; wrap existing node, don't rebuild.
+               return new OO.ui.Element( { $element: $elem } );
+       }
+       parts = data._.split( '.' );
+       cls = OO.getProp.apply( OO, [ window ].concat( parts ) );
+       if ( cls === undefined ) {
+               // The PHP output might be old and not including the "OO.ui" prefix
+               // TODO: Remove this back-compat after next major release
+               cls = OO.getProp.apply( OO, [ OO.ui ].concat( parts ) );
+               if ( cls === undefined ) {
+                       throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
+               }
+       }
+
+       // Verify that we're creating an OO.ui.Element instance
+       parent = cls.parent;
+
+       while ( parent !== undefined ) {
+               if ( parent === OO.ui.Element ) {
+                       // Safe
+                       break;
+               }
+
+               parent = parent.parent;
+       }
+
+       if ( parent !== OO.ui.Element ) {
+               throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
+       }
+
+       if ( domPromise === false ) {
+               top = $.Deferred();
+               domPromise = top.promise();
+       }
+       $elem.data( 'ooui-infused', true ); // prevent loops
+       data.id = id; // implicit
+       data = OO.copy( data, null, function deserialize( value ) {
+               if ( OO.isPlainObject( value ) ) {
+                       if ( value.tag ) {
+                               return OO.ui.Element.static.unsafeInfuse( value.tag, domPromise );
+                       }
+                       if ( value.html ) {
+                               return new OO.ui.HtmlSnippet( value.html );
+                       }
+               }
+       } );
+       // allow widgets to reuse parts of the DOM
+       data = cls.static.reusePreInfuseDOM( $elem[ 0 ], data );
+       // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
+       state = cls.static.gatherPreInfuseState( $elem[ 0 ], data );
+       // rebuild widget
+       // jscs:disable requireCapitalizedConstructors
+       obj = new cls( data );
+       // jscs:enable requireCapitalizedConstructors
+       // now replace old DOM with this new DOM.
+       if ( top ) {
+               // An efficient constructor might be able to reuse the entire DOM tree of the original element,
+               // so only mutate the DOM if we need to.
+               if ( $elem[ 0 ] !== obj.$element[ 0 ] ) {
+                       $elem.replaceWith( obj.$element );
+                       // This element is now gone from the DOM, but if anyone is holding a reference to it,
+                       // let's allow them to OO.ui.infuse() it and do what they expect (T105828).
+                       // Do not use jQuery.data(), as using it on detached nodes leaks memory in 1.x line by design.
+                       $elem[ 0 ].oouiInfused = obj;
+               }
+               top.resolve();
+       }
+       obj.$element.data( 'ooui-infused', obj );
+       // set the 'data-ooui' attribute so we can identify infused widgets
+       obj.$element.attr( 'data-ooui', '' );
+       // restore dynamic state after the new element is inserted into DOM
+       domPromise.done( obj.restorePreInfuseState.bind( obj, state ) );
+       return obj;
+};
+
+/**
+ * Pick out parts of `node`'s DOM to be reused when infusing a widget.
+ *
+ * This method **must not** make any changes to the DOM, only find interesting pieces and add them
+ * to `config` (which should then be returned). Actual DOM juggling should then be done by the
+ * constructor, which will be given the enhanced config.
+ *
+ * @protected
+ * @param {HTMLElement} node
+ * @param {Object} config
+ * @return {Object}
+ */
+OO.ui.Element.static.reusePreInfuseDOM = function ( node, config ) {
+       return config;
+};
+
+/**
+ * Gather the dynamic state (focus, value of form inputs, scroll position, etc.) of a HTML DOM node
+ * (and its children) that represent an Element of the same class and the given configuration,
+ * generated by the PHP implementation.
+ *
+ * This method is called just before `node` is detached from the DOM. The return value of this
+ * function will be passed to #restorePreInfuseState after the newly created widget's #$element
+ * is inserted into DOM to replace `node`.
+ *
+ * @protected
+ * @param {HTMLElement} node
+ * @param {Object} config
+ * @return {Object}
+ */
+OO.ui.Element.static.gatherPreInfuseState = function () {
+       return {};
+};
+
+/**
+ * Get a jQuery function within a specific document.
+ *
+ * @static
+ * @param {jQuery|HTMLElement|HTMLDocument|Window} context Context to bind the function to
+ * @param {jQuery} [$iframe] HTML iframe element that contains the document, omit if document is
+ *   not in an iframe
+ * @return {Function} Bound jQuery function
+ */
+OO.ui.Element.static.getJQuery = function ( context, $iframe ) {
+       function wrapper( selector ) {
+               return $( selector, wrapper.context );
+       }
+
+       wrapper.context = this.getDocument( context );
+
+       if ( $iframe ) {
+               wrapper.$iframe = $iframe;
+       }
+
+       return wrapper;
+};
+
+/**
+ * Get the document of an element.
+ *
+ * @static
+ * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Object to get the document for
+ * @return {HTMLDocument|null} Document object
+ */
+OO.ui.Element.static.getDocument = function ( obj ) {
+       // jQuery - selections created "offscreen" won't have a context, so .context isn't reliable
+       return ( obj[ 0 ] && obj[ 0 ].ownerDocument ) ||
+               // Empty jQuery selections might have a context
+               obj.context ||
+               // HTMLElement
+               obj.ownerDocument ||
+               // Window
+               obj.document ||
+               // HTMLDocument
+               ( obj.nodeType === 9 && obj ) ||
+               null;
+};
+
+/**
+ * Get the window of an element or document.
+ *
+ * @static
+ * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the window for
+ * @return {Window} Window object
+ */
+OO.ui.Element.static.getWindow = function ( obj ) {
+       var doc = this.getDocument( obj );
+       return doc.defaultView;
+};
+
+/**
+ * Get the direction of an element or document.
+ *
+ * @static
+ * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the direction for
+ * @return {string} Text direction, either 'ltr' or 'rtl'
+ */
+OO.ui.Element.static.getDir = function ( obj ) {
+       var isDoc, isWin;
+
+       if ( obj instanceof jQuery ) {
+               obj = obj[ 0 ];
+       }
+       isDoc = obj.nodeType === 9;
+       isWin = obj.document !== undefined;
+       if ( isDoc || isWin ) {
+               if ( isWin ) {
+                       obj = obj.document;
+               }
+               obj = obj.body;
+       }
+       return $( obj ).css( 'direction' );
+};
+
+/**
+ * Get the offset between two frames.
+ *
+ * TODO: Make this function not use recursion.
+ *
+ * @static
+ * @param {Window} from Window of the child frame
+ * @param {Window} [to=window] Window of the parent frame
+ * @param {Object} [offset] Offset to start with, used internally
+ * @return {Object} Offset object, containing left and top properties
+ */
+OO.ui.Element.static.getFrameOffset = function ( from, to, offset ) {
+       var i, len, frames, frame, rect;
+
+       if ( !to ) {
+               to = window;
+       }
+       if ( !offset ) {
+               offset = { top: 0, left: 0 };
+       }
+       if ( from.parent === from ) {
+               return offset;
+       }
+
+       // Get iframe element
+       frames = from.parent.document.getElementsByTagName( 'iframe' );
+       for ( i = 0, len = frames.length; i < len; i++ ) {
+               if ( frames[ i ].contentWindow === from ) {
+                       frame = frames[ i ];
+                       break;
+               }
+       }
+
+       // Recursively accumulate offset values
+       if ( frame ) {
+               rect = frame.getBoundingClientRect();
+               offset.left += rect.left;
+               offset.top += rect.top;
+               if ( from !== to ) {
+                       this.getFrameOffset( from.parent, offset );
+               }
+       }
+       return offset;
+};
+
+/**
+ * Get the offset between two elements.
+ *
+ * The two elements may be in a different frame, but in that case the frame $element is in must
+ * be contained in the frame $anchor is in.
+ *
+ * @static
+ * @param {jQuery} $element Element whose position to get
+ * @param {jQuery} $anchor Element to get $element's position relative to
+ * @return {Object} Translated position coordinates, containing top and left properties
+ */
+OO.ui.Element.static.getRelativePosition = function ( $element, $anchor ) {
+       var iframe, iframePos,
+               pos = $element.offset(),
+               anchorPos = $anchor.offset(),
+               elementDocument = this.getDocument( $element ),
+               anchorDocument = this.getDocument( $anchor );
+
+       // If $element isn't in the same document as $anchor, traverse up
+       while ( elementDocument !== anchorDocument ) {
+               iframe = elementDocument.defaultView.frameElement;
+               if ( !iframe ) {
+                       throw new Error( '$element frame is not contained in $anchor frame' );
+               }
+               iframePos = $( iframe ).offset();
+               pos.left += iframePos.left;
+               pos.top += iframePos.top;
+               elementDocument = iframe.ownerDocument;
+       }
+       pos.left -= anchorPos.left;
+       pos.top -= anchorPos.top;
+       return pos;
+};
+
+/**
+ * Get element border sizes.
+ *
+ * @static
+ * @param {HTMLElement} el Element to measure
+ * @return {Object} Dimensions object with `top`, `left`, `bottom` and `right` properties
+ */
+OO.ui.Element.static.getBorders = function ( el ) {
+       var doc = el.ownerDocument,
+               win = doc.defaultView,
+               style = win.getComputedStyle( el, null ),
+               $el = $( el ),
+               top = parseFloat( style ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0,
+               left = parseFloat( style ? style.borderLeftWidth : $el.css( 'borderLeftWidth' ) ) || 0,
+               bottom = parseFloat( style ? style.borderBottomWidth : $el.css( 'borderBottomWidth' ) ) || 0,
+               right = parseFloat( style ? style.borderRightWidth : $el.css( 'borderRightWidth' ) ) || 0;
+
+       return {
+               top: top,
+               left: left,
+               bottom: bottom,
+               right: right
+       };
+};
+
+/**
+ * Get dimensions of an element or window.
+ *
+ * @static
+ * @param {HTMLElement|Window} el Element to measure
+ * @return {Object} Dimensions object with `borders`, `scroll`, `scrollbar` and `rect` properties
+ */
+OO.ui.Element.static.getDimensions = function ( el ) {
+       var $el, $win,
+               doc = el.ownerDocument || el.document,
+               win = doc.defaultView;
+
+       if ( win === el || el === doc.documentElement ) {
+               $win = $( win );
+               return {
+                       borders: { top: 0, left: 0, bottom: 0, right: 0 },
+                       scroll: {
+                               top: $win.scrollTop(),
+                               left: $win.scrollLeft()
+                       },
+                       scrollbar: { right: 0, bottom: 0 },
+                       rect: {
+                               top: 0,
+                               left: 0,
+                               bottom: $win.innerHeight(),
+                               right: $win.innerWidth()
+                       }
+               };
+       } else {
+               $el = $( el );
+               return {
+                       borders: this.getBorders( el ),
+                       scroll: {
+                               top: $el.scrollTop(),
+                               left: $el.scrollLeft()
+                       },
+                       scrollbar: {
+                               right: $el.innerWidth() - el.clientWidth,
+                               bottom: $el.innerHeight() - el.clientHeight
+                       },
+                       rect: el.getBoundingClientRect()
+               };
+       }
+};
+
+/**
+ * Get scrollable object parent
+ *
+ * documentElement can't be used to get or set the scrollTop
+ * property on Blink. Changing and testing its value lets us
+ * use 'body' or 'documentElement' based on what is working.
+ *
+ * https://code.google.com/p/chromium/issues/detail?id=303131
+ *
+ * @static
+ * @param {HTMLElement} el Element to find scrollable parent for
+ * @return {HTMLElement} Scrollable parent
+ */
+OO.ui.Element.static.getRootScrollableElement = function ( el ) {
+       var scrollTop, body;
+
+       if ( OO.ui.scrollableElement === undefined ) {
+               body = el.ownerDocument.body;
+               scrollTop = body.scrollTop;
+               body.scrollTop = 1;
+
+               if ( body.scrollTop === 1 ) {
+                       body.scrollTop = scrollTop;
+                       OO.ui.scrollableElement = 'body';
+               } else {
+                       OO.ui.scrollableElement = 'documentElement';
+               }
+       }
+
+       return el.ownerDocument[ OO.ui.scrollableElement ];
+};
+
+/**
+ * Get closest scrollable container.
+ *
+ * Traverses up until either a scrollable element or the root is reached, in which case the window
+ * will be returned.
+ *
+ * @static
+ * @param {HTMLElement} el Element to find scrollable container for
+ * @param {string} [dimension] Dimension of scrolling to look for; `x`, `y` or omit for either
+ * @return {HTMLElement} Closest scrollable container
+ */
+OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) {
+       var i, val,
+               // props = [ 'overflow' ] doesn't work due to https://bugzilla.mozilla.org/show_bug.cgi?id=889091
+               props = [ 'overflow-x', 'overflow-y' ],
+               $parent = $( el ).parent();
+
+       if ( dimension === 'x' || dimension === 'y' ) {
+               props = [ 'overflow-' + dimension ];
+       }
+
+       while ( $parent.length ) {
+               if ( $parent[ 0 ] === this.getRootScrollableElement( el ) ) {
+                       return $parent[ 0 ];
+               }
+               i = props.length;
+               while ( i-- ) {
+                       val = $parent.css( props[ i ] );
+                       if ( val === 'auto' || val === 'scroll' ) {
+                               return $parent[ 0 ];
+                       }
+               }
+               $parent = $parent.parent();
+       }
+       return this.getDocument( el ).body;
+};
+
+/**
+ * Scroll element into view.
+ *
+ * @static
+ * @param {HTMLElement} el Element to scroll into view
+ * @param {Object} [config] Configuration options
+ * @param {string} [config.duration] jQuery animation duration value
+ * @param {string} [config.direction] Scroll in only one direction, e.g. 'x' or 'y', omit
+ *  to scroll in both directions
+ * @param {Function} [config.complete] Function to call when scrolling completes
+ */
+OO.ui.Element.static.scrollIntoView = function ( el, config ) {
+       var rel, anim, callback, sc, $sc, eld, scd, $win;
+
+       // Configuration initialization
+       config = config || {};
+
+       anim = {};
+       callback = typeof config.complete === 'function' && config.complete;
+       sc = this.getClosestScrollableContainer( el, config.direction );
+       $sc = $( sc );
+       eld = this.getDimensions( el );
+       scd = this.getDimensions( sc );
+       $win = $( this.getWindow( el ) );
+
+       // Compute the distances between the edges of el and the edges of the scroll viewport
+       if ( $sc.is( 'html, body' ) ) {
+               // If the scrollable container is the root, this is easy
+               rel = {
+                       top: eld.rect.top,
+                       bottom: $win.innerHeight() - eld.rect.bottom,
+                       left: eld.rect.left,
+                       right: $win.innerWidth() - eld.rect.right
+               };
+       } else {
+               // Otherwise, we have to subtract el's coordinates from sc's coordinates
+               rel = {
+                       top: eld.rect.top - ( scd.rect.top + scd.borders.top ),
+                       bottom: scd.rect.bottom - scd.borders.bottom - scd.scrollbar.bottom - eld.rect.bottom,
+                       left: eld.rect.left - ( scd.rect.left + scd.borders.left ),
+                       right: scd.rect.right - scd.borders.right - scd.scrollbar.right - eld.rect.right
+               };
+       }
+
+       if ( !config.direction || config.direction === 'y' ) {
+               if ( rel.top < 0 ) {
+                       anim.scrollTop = scd.scroll.top + rel.top;
+               } else if ( rel.top > 0 && rel.bottom < 0 ) {
+                       anim.scrollTop = scd.scroll.top + Math.min( rel.top, -rel.bottom );
+               }
+       }
+       if ( !config.direction || config.direction === 'x' ) {
+               if ( rel.left < 0 ) {
+                       anim.scrollLeft = scd.scroll.left + rel.left;
+               } else if ( rel.left > 0 && rel.right < 0 ) {
+                       anim.scrollLeft = scd.scroll.left + Math.min( rel.left, -rel.right );
+               }
+       }
+       if ( !$.isEmptyObject( anim ) ) {
+               $sc.stop( true ).animate( anim, config.duration || 'fast' );
+               if ( callback ) {
+                       $sc.queue( function ( next ) {
+                               callback();
+                               next();
+                       } );
+               }
+       } else {
+               if ( callback ) {
+                       callback();
+               }
+       }
+};
+
+/**
+ * Force the browser to reconsider whether it really needs to render scrollbars inside the element
+ * and reserve space for them, because it probably doesn't.
+ *
+ * Workaround primarily for <https://code.google.com/p/chromium/issues/detail?id=387290>, but also
+ * similar bugs in other browsers. "Just" forcing a reflow is not sufficient in all cases, we need
+ * to first actually detach (or hide, but detaching is simpler) all children, *then* force a reflow,
+ * and then reattach (or show) them back.
+ *
+ * @static
+ * @param {HTMLElement} el Element to reconsider the scrollbars on
+ */
+OO.ui.Element.static.reconsiderScrollbars = function ( el ) {
+       var i, len, scrollLeft, scrollTop, nodes = [];
+       // Save scroll position
+       scrollLeft = el.scrollLeft;
+       scrollTop = el.scrollTop;
+       // Detach all children
+       while ( el.firstChild ) {
+               nodes.push( el.firstChild );
+               el.removeChild( el.firstChild );
+       }
+       // Force reflow
+       void el.offsetHeight;
+       // Reattach all children
+       for ( i = 0, len = nodes.length; i < len; i++ ) {
+               el.appendChild( nodes[ i ] );
+       }
+       // Restore scroll position (no-op if scrollbars disappeared)
+       el.scrollLeft = scrollLeft;
+       el.scrollTop = scrollTop;
+};
+
+/* Methods */
+
+/**
+ * Toggle visibility of an element.
+ *
+ * @param {boolean} [show] Make element visible, omit to toggle visibility
+ * @fires visible
+ * @chainable
+ */
+OO.ui.Element.prototype.toggle = function ( show ) {
+       show = show === undefined ? !this.visible : !!show;
+
+       if ( show !== this.isVisible() ) {
+               this.visible = show;
+               this.$element.toggleClass( 'oo-ui-element-hidden', !this.visible );
+               this.emit( 'toggle', show );
+       }
+
+       return this;
+};
+
+/**
+ * Check if element is visible.
+ *
+ * @return {boolean} element is visible
+ */
+OO.ui.Element.prototype.isVisible = function () {
+       return this.visible;
+};
+
+/**
+ * Get element data.
+ *
+ * @return {Mixed} Element data
+ */
+OO.ui.Element.prototype.getData = function () {
+       return this.data;
+};
+
+/**
+ * Set element data.
+ *
+ * @param {Mixed} Element data
+ * @chainable
+ */
+OO.ui.Element.prototype.setData = function ( data ) {
+       this.data = data;
+       return this;
+};
+
+/**
+ * Check if element supports one or more methods.
+ *
+ * @param {string|string[]} methods Method or list of methods to check
+ * @return {boolean} All methods are supported
+ */
+OO.ui.Element.prototype.supports = function ( methods ) {
+       var i, len,
+               support = 0;
+
+       methods = Array.isArray( methods ) ? methods : [ methods ];
+       for ( i = 0, len = methods.length; i < len; i++ ) {
+               if ( $.isFunction( this[ methods[ i ] ] ) ) {
+                       support++;
+               }
+       }
+
+       return methods.length === support;
+};
+
+/**
+ * Update the theme-provided classes.
+ *
+ * @localdoc This is called in element mixins and widget classes any time state changes.
+ *   Updating is debounced, minimizing overhead of changing multiple attributes and
+ *   guaranteeing that theme updates do not occur within an element's constructor
+ */
+OO.ui.Element.prototype.updateThemeClasses = function () {
+       this.debouncedUpdateThemeClassesHandler();
+};
+
+/**
+ * @private
+ * @localdoc This method is called directly from the QUnit tests instead of #updateThemeClasses, to
+ *   make them synchronous.
+ */
+OO.ui.Element.prototype.debouncedUpdateThemeClasses = function () {
+       OO.ui.theme.updateElementClasses( this );
+};
+
+/**
+ * Get the HTML tag name.
+ *
+ * Override this method to base the result on instance information.
+ *
+ * @return {string} HTML tag name
+ */
+OO.ui.Element.prototype.getTagName = function () {
+       return this.constructor.static.tagName;
+};
+
+/**
+ * Check if the element is attached to the DOM
+ * @return {boolean} The element is attached to the DOM
+ */
+OO.ui.Element.prototype.isElementAttached = function () {
+       return $.contains( this.getElementDocument(), this.$element[ 0 ] );
+};
+
+/**
+ * Get the DOM document.
+ *
+ * @return {HTMLDocument} Document object
+ */
+OO.ui.Element.prototype.getElementDocument = function () {
+       // Don't cache this in other ways either because subclasses could can change this.$element
+       return OO.ui.Element.static.getDocument( this.$element );
+};
+
+/**
+ * Get the DOM window.
+ *
+ * @return {Window} Window object
+ */
+OO.ui.Element.prototype.getElementWindow = function () {
+       return OO.ui.Element.static.getWindow( this.$element );
+};
+
+/**
+ * Get closest scrollable container.
+ */
+OO.ui.Element.prototype.getClosestScrollableElementContainer = function () {
+       return OO.ui.Element.static.getClosestScrollableContainer( this.$element[ 0 ] );
+};
+
+/**
+ * Get group element is in.
+ *
+ * @return {OO.ui.mixin.GroupElement|null} Group element, null if none
+ */
+OO.ui.Element.prototype.getElementGroup = function () {
+       return this.elementGroup;
+};
+
+/**
+ * Set group element is in.
+ *
+ * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
+ * @chainable
+ */
+OO.ui.Element.prototype.setElementGroup = function ( group ) {
+       this.elementGroup = group;
+       return this;
+};
+
+/**
+ * Scroll element into view.
+ *
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.Element.prototype.scrollElementIntoView = function ( config ) {
+       return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config );
+};
+
+/**
+ * Restore the pre-infusion dynamic state for this widget.
+ *
+ * This method is called after #$element has been inserted into DOM. The parameter is the return
+ * value of #gatherPreInfuseState.
+ *
+ * @protected
+ * @param {Object} state
+ */
+OO.ui.Element.prototype.restorePreInfuseState = function () {
+};
+
+/**
+ * Wraps an HTML snippet for use with configuration values which default
+ * to strings.  This bypasses the default html-escaping done to string
+ * values.
+ *
+ * @class
+ *
+ * @constructor
+ * @param {string} [content] HTML content
+ */
+OO.ui.HtmlSnippet = function OoUiHtmlSnippet( content ) {
+       // Properties
+       this.content = content;
+};
+
+/* Setup */
+
+OO.initClass( OO.ui.HtmlSnippet );
+
+/* Methods */
+
+/**
+ * Render into HTML.
+ *
+ * @return {string} Unchanged HTML snippet.
+ */
+OO.ui.HtmlSnippet.prototype.toString = function () {
+       return this.content;
+};
+
+/**
+ * Layouts are containers for elements and are used to arrange other widgets of arbitrary type in a way
+ * that is centrally controlled and can be updated dynamically. Layouts can be, and usually are, combined.
+ * See {@link OO.ui.FieldsetLayout FieldsetLayout}, {@link OO.ui.FieldLayout FieldLayout}, {@link OO.ui.FormLayout FormLayout},
+ * {@link OO.ui.PanelLayout PanelLayout}, {@link OO.ui.StackLayout StackLayout}, {@link OO.ui.PageLayout PageLayout},
+ * {@link OO.ui.HorizontalLayout HorizontalLayout}, and {@link OO.ui.BookletLayout BookletLayout} for more information and examples.
+ *
+ * @abstract
+ * @class
+ * @extends OO.ui.Element
+ * @mixins OO.EventEmitter
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.Layout = function OoUiLayout( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.Layout.parent.call( this, config );
+
+       // Mixin constructors
+       OO.EventEmitter.call( this );
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-layout' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.Layout, OO.ui.Element );
+OO.mixinClass( OO.ui.Layout, OO.EventEmitter );
+
+/**
+ * Widgets are compositions of one or more OOjs UI elements that users can both view
+ * and interact with. All widgets can be configured and modified via a standard API,
+ * and their state can change dynamically according to a model.
+ *
+ * @abstract
+ * @class
+ * @extends OO.ui.Element
+ * @mixins OO.EventEmitter
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {boolean} [disabled=false] Disable the widget. Disabled widgets cannot be used and their
+ *  appearance reflects this state.
+ */
+OO.ui.Widget = function OoUiWidget( config ) {
+       // Initialize config
+       config = $.extend( { disabled: false }, config );
+
+       // Parent constructor
+       OO.ui.Widget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.EventEmitter.call( this );
+
+       // Properties
+       this.disabled = null;
+       this.wasDisabled = null;
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-widget' );
+       this.setDisabled( !!config.disabled );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.Widget, OO.ui.Element );
+OO.mixinClass( OO.ui.Widget, OO.EventEmitter );
+
+/* Static Properties */
+
+/**
+ * Whether this widget will behave reasonably when wrapped in a HTML `<label>`. If this is true,
+ * wrappers such as OO.ui.FieldLayout may use a `<label>` instead of implementing own label click
+ * handling.
+ *
+ * @static
+ * @inheritable
+ * @property {boolean}
+ */
+OO.ui.Widget.static.supportsSimpleLabel = false;
+
+/* Events */
+
+/**
+ * @event disable
+ *
+ * A 'disable' event is emitted when the disabled state of the widget changes
+ * (i.e. on disable **and** enable).
+ *
+ * @param {boolean} disabled Widget is disabled
+ */
+
+/**
+ * @event toggle
+ *
+ * A 'toggle' event is emitted when the visibility of the widget changes.
+ *
+ * @param {boolean} visible Widget is visible
+ */
+
+/* Methods */
+
+/**
+ * Check if the widget is disabled.
+ *
+ * @return {boolean} Widget is disabled
+ */
+OO.ui.Widget.prototype.isDisabled = function () {
+       return this.disabled;
+};
+
+/**
+ * Set the 'disabled' state of the widget.
+ *
+ * When a widget is disabled, it cannot be used and its appearance is updated to reflect this state.
+ *
+ * @param {boolean} disabled Disable widget
+ * @chainable
+ */
+OO.ui.Widget.prototype.setDisabled = function ( disabled ) {
+       var isDisabled;
+
+       this.disabled = !!disabled;
+       isDisabled = this.isDisabled();
+       if ( isDisabled !== this.wasDisabled ) {
+               this.$element.toggleClass( 'oo-ui-widget-disabled', isDisabled );
+               this.$element.toggleClass( 'oo-ui-widget-enabled', !isDisabled );
+               this.$element.attr( 'aria-disabled', isDisabled.toString() );
+               this.emit( 'disable', isDisabled );
+               this.updateThemeClasses();
+       }
+       this.wasDisabled = isDisabled;
+
+       return this;
+};
+
+/**
+ * Update the disabled state, in case of changes in parent widget.
+ *
+ * @chainable
+ */
+OO.ui.Widget.prototype.updateDisabled = function () {
+       this.setDisabled( this.disabled );
+       return this;
+};
+
+/**
+ * Theme logic.
+ *
+ * @abstract
+ * @class
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.Theme = function OoUiTheme( config ) {
+       // Configuration initialization
+       config = config || {};
+};
+
+/* Setup */
+
+OO.initClass( OO.ui.Theme );
+
+/* Methods */
+
+/**
+ * Get a list of classes to be applied to a widget.
+ *
+ * The 'on' and 'off' lists combined MUST contain keys for all classes the theme adds or removes,
+ * otherwise state transitions will not work properly.
+ *
+ * @param {OO.ui.Element} element Element for which to get classes
+ * @return {Object.<string,string[]>} Categorized class names with `on` and `off` lists
+ */
+OO.ui.Theme.prototype.getElementClasses = function () {
+       return { on: [], off: [] };
+};
+
+/**
+ * Update CSS classes provided by the theme.
+ *
+ * For elements with theme logic hooks, this should be called any time there's a state change.
+ *
+ * @param {OO.ui.Element} element Element for which to update classes
+ * @return {Object.<string,string[]>} Categorized class names with `on` and `off` lists
+ */
+OO.ui.Theme.prototype.updateElementClasses = function ( element ) {
+       var $elements = $( [] ),
+               classes = this.getElementClasses( element );
+
+       if ( element.$icon ) {
+               $elements = $elements.add( element.$icon );
+       }
+       if ( element.$indicator ) {
+               $elements = $elements.add( element.$indicator );
+       }
+
+       $elements
+               .removeClass( classes.off.join( ' ' ) )
+               .addClass( classes.on.join( ' ' ) );
+};
+
+/**
+ * The TabIndexedElement class is an attribute mixin used to add additional functionality to an
+ * element created by another class. The mixin provides a ‘tabIndex’ property, which specifies the
+ * order in which users will navigate through the focusable elements via the "tab" key.
+ *
+ *     @example
+ *     // TabIndexedElement is mixed into the ButtonWidget class
+ *     // to provide a tabIndex property.
+ *     var button1 = new OO.ui.ButtonWidget( {
+ *         label: 'fourth',
+ *         tabIndex: 4
+ *     } );
+ *     var button2 = new OO.ui.ButtonWidget( {
+ *         label: 'second',
+ *         tabIndex: 2
+ *     } );
+ *     var button3 = new OO.ui.ButtonWidget( {
+ *         label: 'third',
+ *         tabIndex: 3
+ *     } );
+ *     var button4 = new OO.ui.ButtonWidget( {
+ *         label: 'first',
+ *         tabIndex: 1
+ *     } );
+ *     $( 'body' ).append( button1.$element, button2.$element, button3.$element, button4.$element );
+ *
+ * @abstract
+ * @class
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {jQuery} [$tabIndexed] The element that should use the tabindex functionality. By default,
+ *  the functionality is applied to the element created by the class ($element). If a different element is specified, the tabindex
+ *  functionality will be applied to it instead.
+ * @cfg {number|null} [tabIndex=0] Number that specifies the element’s position in the tab-navigation
+ *  order (e.g., 1 for the first focusable element). Use 0 to use the default navigation order; use -1
+ *  to remove the element from the tab-navigation flow.
+ */
+OO.ui.mixin.TabIndexedElement = function OoUiMixinTabIndexedElement( config ) {
+       // Configuration initialization
+       config = $.extend( { tabIndex: 0 }, config );
+
+       // Properties
+       this.$tabIndexed = null;
+       this.tabIndex = null;
+
+       // Events
+       this.connect( this, { disable: 'onTabIndexedElementDisable' } );
+
+       // Initialization
+       this.setTabIndex( config.tabIndex );
+       this.setTabIndexedElement( config.$tabIndexed || this.$element );
+};
+
+/* Setup */
+
+OO.initClass( OO.ui.mixin.TabIndexedElement );
+
+/* Methods */
+
+/**
+ * Set the element that should use the tabindex functionality.
+ *
+ * This method is used to retarget a tabindex mixin so that its functionality applies
+ * to the specified element. If an element is currently using the functionality, the mixin’s
+ * effect on that element is removed before the new element is set up.
+ *
+ * @param {jQuery} $tabIndexed Element that should use the tabindex functionality
+ * @chainable
+ */
+OO.ui.mixin.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 value of the tabindex.
+ *
+ * @param {number|null} tabIndex Tabindex value, or `null` for no tabindex
+ * @chainable
+ */
+OO.ui.mixin.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) {
+       tabIndex = typeof tabIndex === 'number' ? tabIndex : null;
+
+       if ( this.tabIndex !== tabIndex ) {
+               this.tabIndex = tabIndex;
+               this.updateTabIndex();
+       }
+
+       return this;
+};
+
+/**
+ * Update the `tabindex` attribute, in case of changes to tab index or
+ * disabled state.
+ *
+ * @private
+ * @chainable
+ */
+OO.ui.mixin.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,
+                               // Support: ChromeVox and NVDA
+                               // These do not seem to inherit aria-disabled from parent elements
+                               'aria-disabled': this.isDisabled().toString()
+                       } );
+               } else {
+                       this.$tabIndexed.removeAttr( 'tabindex aria-disabled' );
+               }
+       }
+       return this;
+};
+
+/**
+ * Handle disable events.
+ *
+ * @private
+ * @param {boolean} disabled Element is disabled
+ */
+OO.ui.mixin.TabIndexedElement.prototype.onTabIndexedElementDisable = function () {
+       this.updateTabIndex();
+};
+
+/**
+ * Get the value of the tabindex.
+ *
+ * @return {number|null} Tabindex value
+ */
+OO.ui.mixin.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] The button element created by the class.
+ *  If this configuration is omitted, the button element will use a generated `<a>`.
+ * @cfg {boolean} [framed=true] Render the button with a frame
+ */
+OO.ui.mixin.ButtonElement = function OoUiMixinButtonElement( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Properties
+       this.$button = null;
+       this.framed = 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.setButtonElement( config.$button || $( '<a>' ) );
+};
+
+/* Setup */
+
+OO.initClass( OO.ui.mixin.ButtonElement );
+
+/* Static Properties */
+
+/**
+ * Cancel mouse down events.
+ *
+ * This property is usually set to `true` to prevent the focus from changing when the button is clicked.
+ * Classes such as {@link OO.ui.mixin.DraggableElement DraggableElement} and {@link OO.ui.ButtonOptionWidget ButtonOptionWidget}
+ * use a value of `false` so that dragging behavior is possible and mousedown events can be handled by a
+ * parent widget.
+ *
+ * @static
+ * @inheritable
+ * @property {boolean}
+ */
+OO.ui.mixin.ButtonElement.static.cancelButtonMouseDownEvents = true;
+
+/* Events */
+
+/**
+ * A 'click' event is emitted when the button element is clicked.
+ *
+ * @event click
+ */
+
+/* Methods */
+
+/**
+ * Set the button element.
+ *
+ * This method is used to retarget a button mixin so that its functionality applies to
+ * the specified button element instead of the one created by the class. If a button element
+ * is already set, the method will remove the mixin’s effect on that element.
+ *
+ * @param {jQuery} $button Element to use as button
+ */
+OO.ui.mixin.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' } )
+               .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.mixin.ButtonElement.prototype.onMouseDown = function ( e ) {
+       if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
+               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 ) {
+               return false;
+       }
+};
+
+/**
+ * Handles mouse up events.
+ *
+ * @protected
+ * @param {jQuery.Event} e Mouse up event
+ */
+OO.ui.mixin.ButtonElement.prototype.onMouseUp = function ( e ) {
+       if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
+               return;
+       }
+       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.mixin.ButtonElement.prototype.onClick = function ( e ) {
+       if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
+               if ( this.emit( 'click' ) ) {
+                       return false;
+               }
+       }
+};
+
+/**
+ * Handles key down events.
+ *
+ * @protected
+ * @param {jQuery.Event} e Key down event
+ */
+OO.ui.mixin.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.mixin.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.mixin.ButtonElement.prototype.onKeyPress = function ( e ) {
+       if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
+               if ( this.emit( 'click' ) ) {
+                       return false;
+               }
+       }
+};
+
+/**
+ * Check if button has a frame.
+ *
+ * @return {boolean} Button is framed
+ */
+OO.ui.mixin.ButtonElement.prototype.isFramed = function () {
+       return this.framed;
+};
+
+/**
+ * Render the button with or without a frame. Omit the `framed` parameter to toggle the button frame on and off.
+ *
+ * @param {boolean} [framed] Make button framed, omit to toggle
+ * @chainable
+ */
+OO.ui.mixin.ButtonElement.prototype.toggleFramed = function ( framed ) {
+       framed = framed === undefined ? !this.framed : !!framed;
+       if ( framed !== this.framed ) {
+               this.framed = framed;
+               this.$element
+                       .toggleClass( 'oo-ui-buttonElement-frameless', !framed )
+                       .toggleClass( 'oo-ui-buttonElement-framed', framed );
+               this.updateThemeClasses();
+       }
+
+       return this;
+};
+
+/**
+ * Set the button's active state.
+ *
+ * The active state occurs when a {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} or
+ * a {@link OO.ui.ToggleButtonWidget ToggleButtonWidget} is pressed. This method does nothing
+ * for other button types.
+ *
+ * @param {boolean} value Make button active
+ * @chainable
+ */
+OO.ui.mixin.ButtonElement.prototype.setActive = function ( value ) {
+       this.active = !!value;
+       this.$element.toggleClass( 'oo-ui-buttonElement-active', this.active );
+       return this;
+};
+
+/**
+ * Check if the button is active
+ *
+ * @return {boolean} The button is active
+ */
+OO.ui.mixin.ButtonElement.prototype.isActive = function () {
+       return this.active;
+};
+
+/**
+ * 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
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {jQuery} [$group] The container element created by the class. If this configuration
+ *  is omitted, the group element will use a generated `<div>`.
+ */
+OO.ui.mixin.GroupElement = function OoUiMixinGroupElement( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Properties
+       this.$group = null;
+       this.items = [];
+       this.aggregateItemEvents = {};
+
+       // Initialization
+       this.setGroupElement( config.$group || $( '<div>' ) );
+};
+
+/* Methods */
+
+/**
+ * Set the group element.
+ *
+ * If an element is already set, items will be moved to the new element.
+ *
+ * @param {jQuery} $group Element to use as group
+ */
+OO.ui.mixin.GroupElement.prototype.setGroupElement = function ( $group ) {
+       var i, len;
+
+       this.$group = $group;
+       for ( i = 0, len = this.items.length; i < len; i++ ) {
+               this.$group.append( this.items[ i ].$element );
+       }
+};
+
+/**
+ * Check if a group contains no items.
+ *
+ * @return {boolean} Group is empty
+ */
+OO.ui.mixin.GroupElement.prototype.isEmpty = function () {
+       return !this.items.length;
+};
+
+/**
+ * Get all items in the group.
+ *
+ * The method returns an array of item references (e.g., [button1, button2, button3]) and is useful
+ * when synchronizing groups of items, or whenever the references are required (e.g., when removing items
+ * from a group).
+ *
+ * @return {OO.ui.Element[]} An array of items.
+ */
+OO.ui.mixin.GroupElement.prototype.getItems = function () {
+       return this.items.slice( 0 );
+};
+
+/**
+ * Get an item by its data.
+ *
+ * Only the first item with matching data will be returned. To return all matching items,
+ * use the #getItemsFromData method.
+ *
+ * @param {Object} data Item data to search for
+ * @return {OO.ui.Element|null} Item with equivalent data, `null` if none exists
+ */
+OO.ui.mixin.GroupElement.prototype.getItemFromData = function ( data ) {
+       var i, len, item,
+               hash = OO.getHash( data );
+
+       for ( i = 0, len = this.items.length; i < len; i++ ) {
+               item = this.items[ i ];
+               if ( hash === OO.getHash( item.getData() ) ) {
+                       return item;
+               }
+       }
+
+       return null;
+};
+
+/**
+ * Get items by their data.
+ *
+ * All items with matching data will be returned. To return only the first match, use the #getItemFromData method instead.
+ *
+ * @param {Object} data Item data to search for
+ * @return {OO.ui.Element[]} Items with equivalent data
+ */
+OO.ui.mixin.GroupElement.prototype.getItemsFromData = function ( data ) {
+       var i, len, item,
+               hash = OO.getHash( data ),
+               items = [];
+
+       for ( i = 0, len = this.items.length; i < len; i++ ) {
+               item = this.items[ i ];
+               if ( hash === OO.getHash( item.getData() ) ) {
+                       items.push( item );
+               }
+       }
+
+       return items;
+};
+
+/**
+ * Aggregate the events emitted by the group.
+ *
+ * When events are aggregated, the group will listen to all contained items for the event,
+ * and then emit the event under a new name. The new event will contain an additional leading
+ * parameter containing the item that emitted the original event. Other arguments emitted from
+ * the original event are passed through.
+ *
+ * @param {Object.<string,string|null>} events An object keyed by the name of the event that should be
+ *  aggregated  (e.g., ‘click’) and the value of the new name to use (e.g., ‘groupClick’).
+ *  A `null` value will remove aggregated events.
+
+ * @throws {Error} An error is thrown if aggregation already exists.
+ */
+OO.ui.mixin.GroupElement.prototype.aggregate = function ( events ) {
+       var i, len, item, add, remove, itemEvent, groupEvent;
+
+       for ( itemEvent in events ) {
+               groupEvent = events[ itemEvent ];
+
+               // Remove existing aggregated event
+               if ( Object.prototype.hasOwnProperty.call( this.aggregateItemEvents, itemEvent ) ) {
+                       // Don't allow duplicate aggregations
+                       if ( groupEvent ) {
+                               throw new Error( 'Duplicate item event aggregation for ' + itemEvent );
+                       }
+                       // Remove event aggregation from existing items
+                       for ( i = 0, len = this.items.length; i < len; i++ ) {
+                               item = this.items[ i ];
+                               if ( item.connect && item.disconnect ) {
+                                       remove = {};
+                                       remove[ itemEvent ] = [ 'emit', this.aggregateItemEvents[ itemEvent ], item ];
+                                       item.disconnect( this, remove );
+                               }
+                       }
+                       // Prevent future items from aggregating event
+                       delete this.aggregateItemEvents[ itemEvent ];
+               }
+
+               // Add new aggregate event
+               if ( groupEvent ) {
+                       // Make future items aggregate event
+                       this.aggregateItemEvents[ itemEvent ] = groupEvent;
+                       // Add event aggregation to existing items
+                       for ( i = 0, len = this.items.length; i < len; i++ ) {
+                               item = this.items[ i ];
+                               if ( item.connect && item.disconnect ) {
+                                       add = {};
+                                       add[ itemEvent ] = [ 'emit', groupEvent, item ];
+                                       item.connect( this, add );
+                               }
+                       }
+               }
+       }
+};
+
+/**
+ * Add items to the group.
+ *
+ * Items will be added to the end of the group array unless the optional `index` parameter specifies
+ * a different insertion point. Adding an existing item will move it to the end of the array or the point specified by the `index`.
+ *
+ * @param {OO.ui.Element[]} items An array of items to add to the group
+ * @param {number} [index] Index of the insertion point
+ * @chainable
+ */
+OO.ui.mixin.GroupElement.prototype.addItems = function ( items, index ) {
+       var i, len, item, event, events, currentIndex,
+               itemElements = [];
+
+       for ( i = 0, len = items.length; i < len; i++ ) {
+               item = items[ i ];
+
+               // Check if item exists then remove it first, effectively "moving" it
+               currentIndex = this.items.indexOf( item );
+               if ( currentIndex >= 0 ) {
+                       this.removeItems( [ item ] );
+                       // Adjust index to compensate for removal
+                       if ( currentIndex < index ) {
+                               index--;
+                       }
+               }
+               // Add the item
+               if ( item.connect && item.disconnect && !$.isEmptyObject( this.aggregateItemEvents ) ) {
+                       events = {};
+                       for ( event in this.aggregateItemEvents ) {
+                               events[ event ] = [ 'emit', this.aggregateItemEvents[ event ], item ];
+                       }
+                       item.connect( this, events );
+               }
+               item.setElementGroup( this );
+               itemElements.push( item.$element.get( 0 ) );
+       }
+
+       if ( index === undefined || index < 0 || index >= this.items.length ) {
+               this.$group.append( itemElements );
+               this.items.push.apply( this.items, items );
+       } else if ( index === 0 ) {
+               this.$group.prepend( itemElements );
+               this.items.unshift.apply( this.items, items );
+       } else {
+               this.items[ index ].$element.before( itemElements );
+               this.items.splice.apply( this.items, [ index, 0 ].concat( items ) );
+       }
+
+       return this;
+};
+
+/**
+ * Remove the specified items from a group.
+ *
+ * Removed items are detached (not removed) from the DOM so that they may be reused.
+ * To remove all items from a group, you may wish to use the #clearItems method instead.
+ *
+ * @param {OO.ui.Element[]} items An array of items to remove
+ * @chainable
+ */
+OO.ui.mixin.GroupElement.prototype.removeItems = function ( items ) {
+       var i, len, item, index, remove, itemEvent;
+
+       // Remove specific items
+       for ( i = 0, len = items.length; i < len; i++ ) {
+               item = items[ i ];
+               index = this.items.indexOf( item );
+               if ( index !== -1 ) {
+                       if (
+                               item.connect && item.disconnect &&
+                               !$.isEmptyObject( this.aggregateItemEvents )
+                       ) {
+                               remove = {};
+                               if ( Object.prototype.hasOwnProperty.call( this.aggregateItemEvents, itemEvent ) ) {
+                                       remove[ itemEvent ] = [ 'emit', this.aggregateItemEvents[ itemEvent ], item ];
+                               }
+                               item.disconnect( this, remove );
+                       }
+                       item.setElementGroup( null );
+                       this.items.splice( index, 1 );
+                       item.$element.detach();
+               }
+       }
+
+       return this;
+};
+
+/**
+ * Clear all items from the group.
+ *
+ * Cleared items are detached from the DOM, not removed, so that they may be reused.
+ * To remove only a subset of items from a group, use the #removeItems method.
+ *
+ * @chainable
+ */
+OO.ui.mixin.GroupElement.prototype.clearItems = function () {
+       var i, len, item, remove, itemEvent;
+
+       // Remove all items
+       for ( i = 0, len = this.items.length; i < len; 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 ];
+                       }
+                       item.disconnect( this, remove );
+               }
+               item.setElementGroup( null );
+               item.$element.detach();
+       }
+
+       this.items = [];
+       return this;
+};
+
+/**
+ * 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.
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons
+ *
+ * @abstract
+ * @class
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {jQuery} [$icon] The icon element created by the class. If this configuration is omitted,
+ *  the icon element will use a generated `<span>`. To use a different HTML tag, or to specify that
+ *  the icon element be set to an existing icon instead of the one generated by this class, set a
+ *  value using a jQuery selection. For example:
+ *
+ *      // Use a <div> tag instead of a <span>
+ *     $icon: $("<div>")
+ *     // Use an existing icon element instead of the one generated by the class
+ *     $icon: this.$element
+ *     // Use an icon element from a child widget
+ *     $icon: this.childwidget.$element
+ * @cfg {Object|string} [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.
+ *
+ *  Example of an i18n map:
+ *
+ *     { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
+ *  See the [OOjs UI documentation on MediaWiki] [2] for a list of icons included in the library.
+ * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons
+ * @cfg {string|Function} [iconTitle] A text string used as the icon title, or a function that returns title
+ *  text. The icon title is displayed when users move the mouse over the icon.
+ */
+OO.ui.mixin.IconElement = function OoUiMixinIconElement( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Properties
+       this.$icon = null;
+       this.icon = null;
+       this.iconTitle = null;
+
+       // Initialization
+       this.setIcon( config.icon || this.constructor.static.icon );
+       this.setIconTitle( config.iconTitle || this.constructor.static.iconTitle );
+       this.setIconElement( config.$icon || $( '<span>' ) );
+};
+
+/* Setup */
+
+OO.initClass( OO.ui.mixin.IconElement );
+
+/* Static Properties */
+
+/**
+ * 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.
+ *
+ * Example of an i18n map:
+ *
+ *     { 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}
+ */
+OO.ui.mixin.IconElement.static.icon = null;
+
+/**
+ * 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}
+ */
+OO.ui.mixin.IconElement.static.iconTitle = null;
+
+/* Methods */
+
+/**
+ * Set the icon element. This method is used to retarget an icon mixin so that its functionality
+ * applies to the specified icon element instead of the one created by the class. If an icon
+ * element is already set, the mixin’s effect on that element is removed. Generated CSS classes
+ * and mixin methods will no longer affect the element.
+ *
+ * @param {jQuery} $icon Element to use as icon
+ */
+OO.ui.mixin.IconElement.prototype.setIconElement = function ( $icon ) {
+       if ( this.$icon ) {
+               this.$icon
+                       .removeClass( 'oo-ui-iconElement-icon oo-ui-icon-' + this.icon )
+                       .removeAttr( 'title' );
+       }
+
+       this.$icon = $icon
+               .addClass( 'oo-ui-iconElement-icon' )
+               .toggleClass( 'oo-ui-icon-' + this.icon, !!this.icon );
+       if ( this.iconTitle !== null ) {
+               this.$icon.attr( 'title', this.iconTitle );
+       }
+
+       this.updateThemeClasses();
+};
+
+/**
+ * Set icon by symbolic name (e.g., ‘remove’ or ‘menu’). Use `null` to remove an icon.
+ * The icon parameter can also be set to a map of icon names. See the #icon config setting
+ * for an example.
+ *
+ * @param {Object|string|null} icon A symbolic icon name, a {@link #icon map of icon names} keyed
+ *  by language code, or `null` to remove the icon.
+ * @chainable
+ */
+OO.ui.mixin.IconElement.prototype.setIcon = function ( icon ) {
+       icon = OO.isPlainObject( icon ) ? OO.ui.getLocalValue( icon, null, 'default' ) : icon;
+       icon = typeof icon === 'string' && icon.trim().length ? icon.trim() : null;
+
+       if ( this.icon !== icon ) {
+               if ( this.$icon ) {
+                       if ( this.icon !== null ) {
+                               this.$icon.removeClass( 'oo-ui-icon-' + this.icon );
+                       }
+                       if ( icon !== null ) {
+                               this.$icon.addClass( 'oo-ui-icon-' + icon );
+                       }
+               }
+               this.icon = icon;
+       }
+
+       this.$element.toggleClass( 'oo-ui-iconElement', !!this.icon );
+       this.updateThemeClasses();
+
+       return this;
+};
+
+/**
+ * Set the icon title. Use `null` to remove the title.
+ *
+ * @param {string|Function|null} iconTitle A text string used as the icon title,
+ *  a function that returns title text, or `null` for no title.
+ * @chainable
+ */
+OO.ui.mixin.IconElement.prototype.setIconTitle = function ( iconTitle ) {
+       iconTitle = typeof iconTitle === 'function' ||
+               ( typeof iconTitle === 'string' && iconTitle.length ) ?
+                       OO.ui.resolveMsg( iconTitle ) : null;
+
+       if ( this.iconTitle !== iconTitle ) {
+               this.iconTitle = iconTitle;
+               if ( this.$icon ) {
+                       if ( this.iconTitle !== null ) {
+                               this.$icon.attr( 'title', iconTitle );
+                       } else {
+                               this.$icon.removeAttr( 'title' );
+                       }
+               }
+       }
+
+       return this;
+};
+
+/**
+ * Get the symbolic name of the icon.
+ *
+ * @return {string} Icon name
+ */
+OO.ui.mixin.IconElement.prototype.getIcon = function () {
+       return this.icon;
+};
+
+/**
+ * Get the icon title. The title text is displayed when a user moves the mouse over the icon.
+ *
+ * @return {string} Icon title text
+ */
+OO.ui.mixin.IconElement.prototype.getIconTitle = function () {
+       return this.iconTitle;
+};
+
+/**
+ * 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).
+ *
+ * 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
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {jQuery} [$indicator] The indicator element created by the class. If this
+ *  configuration is omitted, the indicator element will use a generated `<span>`.
+ * @cfg {string} [indicator] Symbolic name of the indicator (e.g., ‘alert’ or  ‘down’).
+ *  See the [OOjs UI documentation on MediaWiki][2] for a list of indicators included
+ *  in the library.
+ * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Indicators
+ * @cfg {string|Function} [indicatorTitle] A text string used as the indicator title,
+ *  or a function that returns title text. The indicator title is displayed when users move
+ *  the mouse over the indicator.
+ */
+OO.ui.mixin.IndicatorElement = function OoUiMixinIndicatorElement( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Properties
+       this.$indicator = null;
+       this.indicator = null;
+       this.indicatorTitle = null;
+
+       // Initialization
+       this.setIndicator( config.indicator || this.constructor.static.indicator );
+       this.setIndicatorTitle( config.indicatorTitle || this.constructor.static.indicatorTitle );
+       this.setIndicatorElement( config.$indicator || $( '<span>' ) );
+};
+
+/* Setup */
+
+OO.initClass( OO.ui.mixin.IndicatorElement );
+
+/* Static Properties */
+
+/**
+ * Symbolic name of the indicator (e.g., ‘alert’ or  ‘down’).
+ * The static property will be overridden if the #indicator configuration is used.
+ *
+ * @static
+ * @inheritable
+ * @property {string|null}
+ */
+OO.ui.mixin.IndicatorElement.static.indicator = null;
+
+/**
+ * A text string used as the indicator title, a function that returns title text, or `null`
+ * for no title. The static property will be overridden if the #indicatorTitle configuration is used.
+ *
+ * @static
+ * @inheritable
+ * @property {string|Function|null}
+ */
+OO.ui.mixin.IndicatorElement.static.indicatorTitle = null;
+
+/* Methods */
+
+/**
+ * Set the indicator element.
+ *
+ * If an element is already set, it will be cleaned up before setting up the new element.
+ *
+ * @param {jQuery} $indicator Element to use as indicator
+ */
+OO.ui.mixin.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) {
+       if ( this.$indicator ) {
+               this.$indicator
+                       .removeClass( 'oo-ui-indicatorElement-indicator oo-ui-indicator-' + this.indicator )
+                       .removeAttr( 'title' );
+       }
+
+       this.$indicator = $indicator
+               .addClass( 'oo-ui-indicatorElement-indicator' )
+               .toggleClass( 'oo-ui-indicator-' + this.indicator, !!this.indicator );
+       if ( this.indicatorTitle !== null ) {
+               this.$indicator.attr( 'title', this.indicatorTitle );
+       }
+
+       this.updateThemeClasses();
+};
+
+/**
+ * Set the indicator by its symbolic name: ‘alert’, ‘down’, ‘next’, ‘previous’, ‘required’, ‘up’. Use `null` to remove the indicator.
+ *
+ * @param {string|null} indicator Symbolic name of indicator, or `null` for no indicator
+ * @chainable
+ */
+OO.ui.mixin.IndicatorElement.prototype.setIndicator = function ( indicator ) {
+       indicator = typeof indicator === 'string' && indicator.length ? indicator.trim() : null;
+
+       if ( this.indicator !== indicator ) {
+               if ( this.$indicator ) {
+                       if ( this.indicator !== null ) {
+                               this.$indicator.removeClass( 'oo-ui-indicator-' + this.indicator );
+                       }
+                       if ( indicator !== null ) {
+                               this.$indicator.addClass( 'oo-ui-indicator-' + indicator );
+                       }
+               }
+               this.indicator = indicator;
+       }
+
+       this.$element.toggleClass( 'oo-ui-indicatorElement', !!this.indicator );
+       this.updateThemeClasses();
+
+       return this;
+};
+
+/**
+ * Set the indicator title.
+ *
+ * The title is displayed when a user moves the mouse over the indicator.
+ *
+ * @param {string|Function|null} indicator Indicator title text, a function that returns text, or
+ *   `null` for no indicator title
+ * @chainable
+ */
+OO.ui.mixin.IndicatorElement.prototype.setIndicatorTitle = function ( indicatorTitle ) {
+       indicatorTitle = typeof indicatorTitle === 'function' ||
+               ( typeof indicatorTitle === 'string' && indicatorTitle.length ) ?
+                       OO.ui.resolveMsg( indicatorTitle ) : null;
+
+       if ( this.indicatorTitle !== indicatorTitle ) {
+               this.indicatorTitle = indicatorTitle;
+               if ( this.$indicator ) {
+                       if ( this.indicatorTitle !== null ) {
+                               this.$indicator.attr( 'title', indicatorTitle );
+                       } else {
+                               this.$indicator.removeAttr( 'title' );
+                       }
+               }
+       }
+
+       return this;
+};
+
+/**
+ * Get the symbolic name of the indicator (e.g., ‘alert’ or  ‘down’).
+ *
+ * @return {string} Symbolic name of indicator
+ */
+OO.ui.mixin.IndicatorElement.prototype.getIndicator = function () {
+       return this.indicator;
+};
+
+/**
+ * Get the indicator title.
+ *
+ * The title is displayed when a user moves the mouse over the indicator.
+ *
+ * @return {string} Indicator title text
+ */
+OO.ui.mixin.IndicatorElement.prototype.getIndicatorTitle = function () {
+       return this.indicatorTitle;
+};
+
+/**
+ * LabelElement is often mixed into other classes to generate a label, which
+ * helps identify the function of an interface element.
+ * See the [OOjs UI documentation on MediaWiki] [1] for more information.
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Labels
+ *
+ * @abstract
+ * @class
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {jQuery} [$label] The label element created by the class. If this
+ *  configuration is omitted, the label element will use a generated `<span>`.
+ * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] The label text. The label can be specified
+ *  as a plaintext string, a jQuery selection of elements, or a function that will produce a string
+ *  in the future. See the [OOjs UI documentation on MediaWiki] [2] for examples.
+ *  [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Labels
+ * @cfg {boolean} [autoFitLabel=true] Fit the label to the width of the parent element.
+ *  The label will be truncated to fit if necessary.
+ */
+OO.ui.mixin.LabelElement = function OoUiMixinLabelElement( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Properties
+       this.$label = null;
+       this.label = null;
+       this.autoFitLabel = config.autoFitLabel === undefined || !!config.autoFitLabel;
+
+       // Initialization
+       this.setLabel( config.label || this.constructor.static.label );
+       this.setLabelElement( config.$label || $( '<span>' ) );
+};
+
+/* Setup */
+
+OO.initClass( OO.ui.mixin.LabelElement );
+
+/* Events */
+
+/**
+ * @event labelChange
+ * @param {string} value
+ */
+
+/* Static Properties */
+
+/**
+ * The label text. The label can be specified as a plaintext string, a function that will
+ * produce a string in the future, or `null` for no label. The static value will
+ * be overridden if a label is specified with the #label config option.
+ *
+ * @static
+ * @inheritable
+ * @property {string|Function|null}
+ */
+OO.ui.mixin.LabelElement.static.label = null;
+
+/* Methods */
+
+/**
+ * Set the label element.
+ *
+ * If an element is already set, it will be cleaned up before setting up the new element.
+ *
+ * @param {jQuery} $label Element to use as label
+ */
+OO.ui.mixin.LabelElement.prototype.setLabelElement = function ( $label ) {
+       if ( this.$label ) {
+               this.$label.removeClass( 'oo-ui-labelElement-label' ).empty();
+       }
+
+       this.$label = $label.addClass( 'oo-ui-labelElement-label' );
+       this.setLabelContent( this.label );
+};
+
+/**
+ * Set the label.
+ *
+ * An empty string will result in the label being hidden. A string containing only whitespace will
+ * be converted to a single `&nbsp;`.
+ *
+ * @param {jQuery|string|OO.ui.HtmlSnippet|Function|null} label Label nodes; text; a function that returns nodes or
+ *  text; or null for no label
+ * @chainable
+ */
+OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) {
+       label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label;
+       label = ( ( typeof label === 'string' && label.length ) || label instanceof jQuery || label instanceof OO.ui.HtmlSnippet ) ? 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' );
+       }
+
+       return this;
+};
+
+/**
+ * Get the label.
+ *
+ * @return {jQuery|string|Function|null} Label nodes; text; a function that returns nodes or
+ *  text; or null for no label
+ */
+OO.ui.mixin.LabelElement.prototype.getLabel = function () {
+       return this.label;
+};
+
+/**
+ * Fit the label.
+ *
+ * @chainable
+ */
+OO.ui.mixin.LabelElement.prototype.fitLabel = function () {
+       if ( this.$label && this.$label.autoEllipsis && this.autoFitLabel ) {
+               this.$label.autoEllipsis( { hasSpan: false, tooltip: true } );
+       }
+
+       return this;
+};
+
+/**
+ * Set the content of the label.
+ *
+ * Do not call this method until after the label element has been set by #setLabelElement.
+ *
+ * @private
+ * @param {jQuery|string|Function|null} label Label nodes; text; a function that returns nodes or
+ *  text; or null for no label
+ */
+OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) {
+       if ( typeof label === 'string' ) {
+               if ( label.match( /^\s*$/ ) ) {
+                       // Convert whitespace only string to a single non-breaking space
+                       this.$label.html( '&nbsp;' );
+               } else {
+                       this.$label.text( label );
+               }
+       } else if ( label instanceof OO.ui.HtmlSnippet ) {
+               this.$label.html( label.toString() );
+       } else if ( label instanceof jQuery ) {
+               this.$label.empty().append( label );
+       } else {
+               this.$label.empty();
+       }
+};
+
+/**
+ * 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.
+ *
+ * The flags affect the appearance of the buttons:
+ *
+ *     @example
+ *     // FlaggedElement is mixed into ButtonWidget to provide styling flags
+ *     var button1 = new OO.ui.ButtonWidget( {
+ *         label: 'Constructive',
+ *         flags: 'constructive'
+ *     } );
+ *     var button2 = new OO.ui.ButtonWidget( {
+ *         label: 'Destructive',
+ *         flags: 'destructive'
+ *     } );
+ *     var button3 = new OO.ui.ButtonWidget( {
+ *         label: 'Progressive',
+ *         flags: 'progressive'
+ *     } );
+ *     $( 'body' ).append( button1.$element, button2.$element, button3.$element );
+ *
+ * {@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] The name or names of the flags (e.g., 'constructive' or 'primary') to apply.
+ *  Please see the [OOjs UI documentation on MediaWiki] [2] for more information about available flags.
+ *  [2]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Flagged
+ * @cfg {jQuery} [$flagged] The flagged element. By default,
+ *  the flagged functionality is applied to the element created by the class ($element).
+ *  If a different element is specified, the flagged functionality will be applied to it instead.
+ */
+OO.ui.mixin.FlaggedElement = function OoUiMixinFlaggedElement( 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
+ * A flag event is emitted when the #clearFlags or #setFlags methods are used. The `changes`
+ * parameter contains the name of each modified flag and indicates whether it was
+ * added or removed.
+ *
+ * @param {Object.<string,boolean>} changes Object keyed by flag name. A Boolean `true` indicates
+ * that the flag was added, `false` that the flag was removed.
+ */
+
+/* Methods */
+
+/**
+ * Set the flagged element.
+ *
+ * This method is used to retarget a flagged mixin so that its functionality applies to the specified element.
+ * If an element is already set, the method will remove the mixin’s effect on that element.
+ *
+ * @param {jQuery} $flagged Element that should be flagged
+ */
+OO.ui.mixin.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 the specified flag is set.
+ *
+ * @param {string} flag Name of flag
+ * @return {boolean} The flag is set
+ */
+OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
+       // This may be called before the constructor, thus before this.flags is set
+       return this.flags && ( flag in this.flags );
+};
+
+/**
+ * Get the names of all flags set.
+ *
+ * @return {string[]} Flag names
+ */
+OO.ui.mixin.FlaggedElement.prototype.getFlags = function () {
+       // This may be called before the constructor, thus before this.flags is set
+       return Object.keys( this.flags || {} );
+};
+
+/**
+ * Clear all flags.
+ *
+ * @chainable
+ * @fires flag
+ */
+OO.ui.mixin.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.<string, boolean>} flags A flag name, an array of flag names,
+ *  or an object keyed by flag name with a boolean value that indicates whether the flag should
+ *  be added (`true`) or removed (`false`).
+ * @chainable
+ * @fires flag
+ */
+OO.ui.mixin.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;
+};
+
+/**
+ * TitledElement is mixed into other classes to provide a `title` attribute.
+ * Titles are rendered by the browser and are made visible when the user moves
+ * the mouse over the element. Titles are not visible on touch devices.
+ *
+ *     @example
+ *     // TitledElement provides a 'title' attribute to the
+ *     // ButtonWidget class
+ *     var button = new OO.ui.ButtonWidget( {
+ *         label: 'Button with Title',
+ *         title: 'I am a button'
+ *     } );
+ *     $( 'body' ).append( button.$element );
+ *
+ * @abstract
+ * @class
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {jQuery} [$titled] The element to which the `title` attribute is applied.
+ *  If this config is omitted, the title functionality is applied to $element, the
+ *  element created by the class.
+ * @cfg {string|Function} [title] The title text or a function that returns text. If
+ *  this config is omitted, the value of the {@link #static-title static title} property is used.
+ */
+OO.ui.mixin.TitledElement = function OoUiMixinTitledElement( 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 );
+};
+
+/* Setup */
+
+OO.initClass( OO.ui.mixin.TitledElement );
+
+/* Static Properties */
+
+/**
+ * The title text, a function that returns text, or `null` for no title. The value of the static property
+ * is overridden if the #title config option is used.
+ *
+ * @static
+ * @inheritable
+ * @property {string|Function|null}
+ */
+OO.ui.mixin.TitledElement.static.title = null;
+
+/* Methods */
+
+/**
+ * Set the titled element.
+ *
+ * This method is used to retarget a titledElement mixin so that its functionality applies to the specified element.
+ * If an element is already set, the mixin’s effect on that element is removed before the new element is set up.
+ *
+ * @param {jQuery} $titled Element that should use the 'titled' functionality
+ */
+OO.ui.mixin.TitledElement.prototype.setTitledElement = function ( $titled ) {
+       if ( this.$titled ) {
+               this.$titled.removeAttr( 'title' );
+       }
+
+       this.$titled = $titled;
+       if ( this.title ) {
+               this.$titled.attr( 'title', this.title );
+       }
+};
+
+/**
+ * Set title.
+ *
+ * @param {string|Function|null} title Title text, a function that returns text, or `null` for no title
+ * @chainable
+ */
+OO.ui.mixin.TitledElement.prototype.setTitle = function ( title ) {
+       title = typeof title === 'function' ? OO.ui.resolveMsg( title ) : title;
+       title = ( typeof title === 'string' && title.length ) ? title : null;
+
+       if ( this.title !== title ) {
+               if ( this.$titled ) {
+                       if ( title !== null ) {
+                               this.$titled.attr( 'title', title );
+                       } else {
+                               this.$titled.removeAttr( 'title' );
+                       }
+               }
+               this.title = title;
+       }
+
+       return this;
+};
+
+/**
+ * Get title.
+ *
+ * @return {string} Title string
+ */
+OO.ui.mixin.TitledElement.prototype.getTitle = function () {
+       return this.title;
+};
+
+/**
+ * AccessKeyedElement is mixed into other classes to provide an `accesskey` attribute.
+ * Accesskeys allow an user to go to a specific element by using
+ * a shortcut combination of a browser specific keys + the key
+ * set to the field.
+ *
+ *     @example
+ *     // AccessKeyedElement provides an 'accesskey' attribute to the
+ *     // ButtonWidget class
+ *     var button = new OO.ui.ButtonWidget( {
+ *         label: 'Button with Accesskey',
+ *         accessKey: 'k'
+ *     } );
+ *     $( 'body' ).append( button.$element );
+ *
+ * @abstract
+ * @class
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {jQuery} [$accessKeyed] The element to which the `accesskey` attribute is applied.
+ *  If this config is omitted, the accesskey functionality is applied to $element, the
+ *  element created by the class.
+ * @cfg {string|Function} [accessKey] The key or a function that returns the key. If
+ *  this config is omitted, no accesskey will be added.
+ */
+OO.ui.mixin.AccessKeyedElement = function OoUiMixinAccessKeyedElement( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Properties
+       this.$accessKeyed = null;
+       this.accessKey = null;
+
+       // Initialization
+       this.setAccessKey( config.accessKey || null );
+       this.setAccessKeyedElement( config.$accessKeyed || this.$element );
+};
+
+/* Setup */
+
+OO.initClass( OO.ui.mixin.AccessKeyedElement );
+
+/* Static Properties */
+
+/**
+ * The access key, a function that returns a key, or `null` for no accesskey.
+ *
+ * @static
+ * @inheritable
+ * @property {string|Function|null}
+ */
+OO.ui.mixin.AccessKeyedElement.static.accessKey = null;
+
+/* Methods */
+
+/**
+ * Set the accesskeyed element.
+ *
+ * This method is used to retarget a AccessKeyedElement mixin so that its functionality applies to the specified element.
+ * If an element is already set, the mixin's effect on that element is removed before the new element is set up.
+ *
+ * @param {jQuery} $accessKeyed Element that should use the 'accesskeyes' functionality
+ */
+OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $accessKeyed ) {
+       if ( this.$accessKeyed ) {
+               this.$accessKeyed.removeAttr( 'accesskey' );
+       }
+
+       this.$accessKeyed = $accessKeyed;
+       if ( this.accessKey ) {
+               this.$accessKeyed.attr( 'accesskey', this.accessKey );
+       }
+};
+
+/**
+ * Set accesskey.
+ *
+ * @param {string|Function|null} accesskey Key, a function that returns a key, or `null` for no accesskey
+ * @chainable
+ */
+OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) {
+       accessKey = typeof accessKey === 'string' ? OO.ui.resolveMsg( accessKey ) : null;
+
+       if ( this.accessKey !== accessKey ) {
+               if ( this.$accessKeyed ) {
+                       if ( accessKey !== null ) {
+                               this.$accessKeyed.attr( 'accesskey', accessKey );
+                       } else {
+                               this.$accessKeyed.removeAttr( 'accesskey' );
+                       }
+               }
+               this.accessKey = accessKey;
+       }
+
+       return this;
+};
+
+/**
+ * Get accesskey.
+ *
+ * @return {string} accessKey string
+ */
+OO.ui.mixin.AccessKeyedElement.prototype.getAccessKey = function () {
+       return this.accessKey;
+};
+
+/**
+ * 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
+ * @mixins OO.ui.mixin.ButtonElement
+ * @mixins OO.ui.mixin.IconElement
+ * @mixins OO.ui.mixin.IndicatorElement
+ * @mixins OO.ui.mixin.LabelElement
+ * @mixins OO.ui.mixin.TitledElement
+ * @mixins OO.ui.mixin.FlaggedElement
+ * @mixins OO.ui.mixin.TabIndexedElement
+ * @mixins OO.ui.mixin.AccessKeyedElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @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 || {};
+
+       // Parent constructor
+       OO.ui.ButtonWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.ButtonElement.call( this, config );
+       OO.ui.mixin.IconElement.call( this, config );
+       OO.ui.mixin.IndicatorElement.call( this, config );
+       OO.ui.mixin.LabelElement.call( this, config );
+       OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
+       OO.ui.mixin.FlaggedElement.call( this, config );
+       OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) );
+       OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$button } ) );
+
+       // Properties
+       this.href = null;
+       this.target = null;
+       this.noFollow = false;
+
+       // Events
+       this.connect( this, { disable: 'onDisable' } );
+
+       // Initialization
+       this.$button.append( this.$icon, this.$label, this.$indicator );
+       this.$element
+               .addClass( 'oo-ui-buttonWidget' )
+               .append( this.$button );
+       this.setHref( config.href );
+       this.setTarget( config.target );
+       this.setNoFollow( config.noFollow );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.ButtonWidget, OO.ui.Widget );
+OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.ButtonElement );
+OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IconElement );
+OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IndicatorElement );
+OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.LabelElement );
+OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TitledElement );
+OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.FlaggedElement );
+OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TabIndexedElement );
+OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement );
+
+/* Methods */
+
+/**
+ * @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' );
+       }
+
+       return OO.ui.mixin.ButtonElement.prototype.onMouseDown.call( this, e );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.ButtonWidget.prototype.onMouseUp = function ( e ) {
+       if ( !this.isDisabled() ) {
+               // Restore the tab-index after the button is up to restore the button's accessibility
+               this.$button.attr( 'tabindex', this.tabIndex );
+       }
+
+       return OO.ui.mixin.ButtonElement.prototype.onMouseUp.call( this, e );
+};
+
+/**
+ * Get hyperlink location.
+ *
+ * @return {string} Hyperlink location
+ */
+OO.ui.ButtonWidget.prototype.getHref = function () {
+       return this.href;
+};
+
+/**
+ * Get hyperlink target.
+ *
+ * @return {string} Hyperlink target
+ */
+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.
+ *
+ * @param {string|null} href Hyperlink location, null to remove
+ */
+OO.ui.ButtonWidget.prototype.setHref = function ( href ) {
+       href = typeof href === 'string' ? href : null;
+       if ( href !== null && !OO.ui.isSafeUrl( href ) ) {
+               href = './' + href;
+       }
+
+       if ( href !== this.href ) {
+               this.href = href;
+               this.updateHref();
+       }
+
+       return this;
+};
+
+/**
+ * Update the `href` attribute, in case of changes to href or
+ * disabled state.
+ *
+ * @private
+ * @chainable
+ */
+OO.ui.ButtonWidget.prototype.updateHref = function () {
+       if ( this.href !== null && !this.isDisabled() ) {
+               this.$button.attr( 'href', this.href );
+       } else {
+               this.$button.removeAttr( 'href' );
+       }
+
+       return this;
+};
+
+/**
+ * Handle disable events.
+ *
+ * @private
+ * @param {boolean} disabled Element is disabled
+ */
+OO.ui.ButtonWidget.prototype.onDisable = function () {
+       this.updateHref();
+};
+
+/**
+ * Set hyperlink target.
+ *
+ * @param {string|null} target Hyperlink target, null to remove
+ */
+OO.ui.ButtonWidget.prototype.setTarget = function ( target ) {
+       target = typeof target === 'string' ? target : null;
+
+       if ( target !== this.target ) {
+               this.target = target;
+               if ( target !== null ) {
+                       this.$button.attr( 'target', target );
+               } else {
+                       this.$button.removeAttr( 'target' );
+               }
+       }
+
+       return this;
+};
+
+/**
+ * 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;
+};
+
+/**
+ * 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: $( '<p>List of categories...</p>' ),
+ *             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
+ * @mixins OO.ui.mixin.GroupElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {OO.ui.ButtonWidget[]} [items] Buttons to add
+ */
+OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.ButtonGroupWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-buttonGroupWidget' );
+       if ( Array.isArray( config.items ) ) {
+               this.addItems( config.items );
+       }
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget );
+OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.GroupElement );
+
+/**
+ * IconWidget is a generic widget for {@link OO.ui.mixin.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 );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons
+ *
+ * @class
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.IconElement
+ * @mixins OO.ui.mixin.TitledElement
+ * @mixins OO.ui.mixin.FlaggedElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.IconWidget = function OoUiIconWidget( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.IconWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.IconElement.call( this, $.extend( {}, config, { $icon: this.$element } ) );
+       OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
+       OO.ui.mixin.FlaggedElement.call( this, $.extend( {}, config, { $flagged: this.$element } ) );
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-iconWidget' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget );
+OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.IconElement );
+OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.TitledElement );
+OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement );
+
+/* Static Properties */
+
+OO.ui.IconWidget.static.tagName = 'span';
+
+/**
+ * IndicatorWidgets create indicators, which are small graphics that are generally used to draw
+ * attention to the status of an item or to clarify the function of a control. For a list of
+ * indicators included in the library, please see the [OOjs UI documentation on MediaWiki][1].
+ *
+ *     @example
+ *     // Example of an indicator widget
+ *     var indicator1 = new OO.ui.IndicatorWidget( {
+ *         indicator: 'alert'
+ *     } );
+ *
+ *     // Create a fieldset layout to add a label
+ *     var fieldset = new OO.ui.FieldsetLayout();
+ *     fieldset.addItems( [
+ *         new OO.ui.FieldLayout( indicator1, { label: 'An alert indicator:' } )
+ *     ] );
+ *     $( 'body' ).append( fieldset.$element );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Indicators
+ *
+ * @class
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.IndicatorElement
+ * @mixins OO.ui.mixin.TitledElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.IndicatorWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$element } ) );
+       OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-indicatorWidget' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget );
+OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.IndicatorElement );
+OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.TitledElement );
+
+/* Static Properties */
+
+OO.ui.IndicatorWidget.static.tagName = 'span';
+
+/**
+ * LabelWidgets help identify the function of interface elements. Each LabelWidget can
+ * be configured with a `label` option that is set to a string, a label node, or a function:
+ *
+ * - String: a plaintext string
+ * - jQuery selection: a jQuery selection, used for anything other than a plaintext label, e.g., a
+ *   label that includes a link or special styling, such as a gray color or additional graphical elements.
+ * - Function: a function that will produce a string in the future. Functions are used
+ *   in cases where the value of the label is not currently defined.
+ *
+ * In addition, the LabelWidget can be associated with an {@link OO.ui.InputWidget input widget}, which
+ * will come into focus when the label is clicked.
+ *
+ *     @example
+ *     // Examples of LabelWidgets
+ *     var label1 = new OO.ui.LabelWidget( {
+ *         label: 'plaintext label'
+ *     } );
+ *     var label2 = new OO.ui.LabelWidget( {
+ *         label: $( '<a href="default.html">jQuery label</a>' )
+ *     } );
+ *     // Create a fieldset layout with fields for each example
+ *     var fieldset = new OO.ui.FieldsetLayout();
+ *     fieldset.addItems( [
+ *         new OO.ui.FieldLayout( label1 ),
+ *         new OO.ui.FieldLayout( label2 )
+ *     ] );
+ *     $( 'body' ).append( fieldset.$element );
+ *
+ * @class
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.LabelElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {OO.ui.InputWidget} [input] {@link OO.ui.InputWidget Input widget} that uses the label.
+ *  Clicking the label will focus the specified input field.
+ */
+OO.ui.LabelWidget = function OoUiLabelWidget( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.LabelWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: this.$element } ) );
+       OO.ui.mixin.TitledElement.call( this, config );
+
+       // Properties
+       this.input = config.input;
+
+       // Events
+       if ( this.input instanceof OO.ui.InputWidget ) {
+               this.$element.on( 'click', this.onClick.bind( this ) );
+       }
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-labelWidget' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.LabelWidget, OO.ui.Widget );
+OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.LabelElement );
+OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement );
+
+/* Static Properties */
+
+OO.ui.LabelWidget.static.tagName = 'span';
+
+/* Methods */
+
+/**
+ * Handles label mouse click events.
+ *
+ * @private
+ * @param {jQuery.Event} e Mouse click event
+ */
+OO.ui.LabelWidget.prototype.onClick = function () {
+       this.input.simulateLabelClick();
+       return false;
+};
+
+/**
+ * PendingElement is a mixin that is used to create elements that notify users that something is happening
+ * and that they should wait before proceeding. The pending state is visually represented with a pending
+ * texture that appears in the head of a pending {@link OO.ui.ProcessDialog process dialog} or in the input
+ * field of a {@link OO.ui.TextInputWidget text input widget}.
+ *
+ * Currently, {@link OO.ui.ActionWidget Action widgets}, which mix in this class, can also be marked as pending, but only when
+ * used in {@link OO.ui.MessageDialog message dialogs}. The behavior is not currently supported for action widgets used
+ * in process dialogs.
+ *
+ *     @example
+ *     function MessageDialog( config ) {
+ *         MessageDialog.parent.call( this, config );
+ *     }
+ *     OO.inheritClass( MessageDialog, OO.ui.MessageDialog );
+ *
+ *     MessageDialog.static.actions = [
+ *         { action: 'save', label: 'Done', flags: 'primary' },
+ *         { label: 'Cancel', flags: 'safe' }
+ *     ];
+ *
+ *     MessageDialog.prototype.initialize = function () {
+ *         MessageDialog.parent.prototype.initialize.apply( this, arguments );
+ *         this.content = new OO.ui.PanelLayout( { $: this.$, padded: true } );
+ *         this.content.$element.append( '<p>Click the \'Done\' action widget to see its pending state. Note that action widgets can be marked pending in message dialogs but not process dialogs.</p>' );
+ *         this.$body.append( this.content.$element );
+ *     };
+ *     MessageDialog.prototype.getBodyHeight = function () {
+ *         return 100;
+ *     }
+ *     MessageDialog.prototype.getActionProcess = function ( action ) {
+ *         var dialog = this;
+ *         if ( action === 'save' ) {
+ *             dialog.getActions().get({actions: 'save'})[0].pushPending();
+ *             return new OO.ui.Process()
+ *             .next( 1000 )
+ *             .next( function () {
+ *                 dialog.getActions().get({actions: 'save'})[0].popPending();
+ *             } );
+ *         }
+ *         return MessageDialog.parent.prototype.getActionProcess.call( this, action );
+ *     };
+ *
+ *     var windowManager = new OO.ui.WindowManager();
+ *     $( 'body' ).append( windowManager.$element );
+ *
+ *     var dialog = new MessageDialog();
+ *     windowManager.addWindows( [ dialog ] );
+ *     windowManager.openWindow( dialog );
+ *
+ * @abstract
+ * @class
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {jQuery} [$pending] Element to mark as pending, defaults to this.$element
+ */
+OO.ui.mixin.PendingElement = function OoUiMixinPendingElement( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Properties
+       this.pending = 0;
+       this.$pending = null;
+
+       // Initialisation
+       this.setPendingElement( config.$pending || this.$element );
+};
+
+/* Setup */
+
+OO.initClass( OO.ui.mixin.PendingElement );
+
+/* Methods */
+
+/**
+ * Set the pending element (and clean up any existing one).
+ *
+ * @param {jQuery} $pending The element to set to pending.
+ */
+OO.ui.mixin.PendingElement.prototype.setPendingElement = function ( $pending ) {
+       if ( this.$pending ) {
+               this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
+       }
+
+       this.$pending = $pending;
+       if ( this.pending > 0 ) {
+               this.$pending.addClass( 'oo-ui-pendingElement-pending' );
+       }
+};
+
+/**
+ * Check if an element is pending.
+ *
+ * @return {boolean} Element is pending
+ */
+OO.ui.mixin.PendingElement.prototype.isPending = function () {
+       return !!this.pending;
+};
+
+/**
+ * Increase the pending counter. The pending state will remain active until the counter is zero
+ * (i.e., the number of calls to #pushPending and #popPending is the same).
+ *
+ * @chainable
+ */
+OO.ui.mixin.PendingElement.prototype.pushPending = function () {
+       if ( this.pending === 0 ) {
+               this.$pending.addClass( 'oo-ui-pendingElement-pending' );
+               this.updateThemeClasses();
+       }
+       this.pending++;
+
+       return this;
+};
+
+/**
+ * Decrease the pending counter. The pending state will remain active until the counter is zero
+ * (i.e., the number of calls to #pushPending and #popPending is the same).
+ *
+ * @chainable
+ */
+OO.ui.mixin.PendingElement.prototype.popPending = function () {
+       if ( this.pending === 1 ) {
+               this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
+               this.updateThemeClasses();
+       }
+       this.pending = Math.max( 0, this.pending - 1 );
+
+       return this;
+};
+
+/**
+ * Element that can be automatically clipped to visible boundaries.
+ *
+ * Whenever the element's natural height changes, you have to call
+ * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
+ * clipping correctly.
+ *
+ * The dimensions of #$clippableContainer will be compared to the boundaries of the
+ * nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
+ * then #$clippable will be given a fixed reduced height and/or width and will be made
+ * scrollable. By default, #$clippable and #$clippableContainer are the same element,
+ * but you can build a static footer by setting #$clippableContainer to an element that contains
+ * #$clippable and the footer.
+ *
+ * @abstract
+ * @class
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element
+ * @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
+ *   omit to use #$clippable
+ */
+OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Properties
+       this.$clippable = null;
+       this.$clippableContainer = null;
+       this.clipping = false;
+       this.clippedHorizontally = false;
+       this.clippedVertically = false;
+       this.$clippableScrollableContainer = null;
+       this.$clippableScroller = null;
+       this.$clippableWindow = null;
+       this.idealWidth = null;
+       this.idealHeight = null;
+       this.onClippableScrollHandler = this.clip.bind( this );
+       this.onClippableWindowResizeHandler = this.clip.bind( this );
+
+       // Initialization
+       if ( config.$clippableContainer ) {
+               this.setClippableContainer( config.$clippableContainer );
+       }
+       this.setClippableElement( config.$clippable || this.$element );
+};
+
+/* Methods */
+
+/**
+ * Set clippable element.
+ *
+ * If an element is already set, it will be cleaned up before setting up the new element.
+ *
+ * @param {jQuery} $clippable Element to make clippable
+ */
+OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) {
+       if ( this.$clippable ) {
+               this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' );
+               this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
+               OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
+       }
+
+       this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' );
+       this.clip();
+};
+
+/**
+ * Set clippable container.
+ *
+ * This is the container that will be measured when deciding whether to clip. When clipping,
+ * #$clippable will be resized in order to keep the clippable container fully visible.
+ *
+ * If the clippable container is unset, #$clippable will be used.
+ *
+ * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
+ */
+OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
+       this.$clippableContainer = $clippableContainer;
+       if ( this.$clippable ) {
+               this.clip();
+       }
+};
+
+/**
+ * Toggle clipping.
+ *
+ * Do not turn clipping on until after the element is attached to the DOM and visible.
+ *
+ * @param {boolean} [clipping] Enable clipping, omit to toggle
+ * @chainable
+ */
+OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
+       clipping = clipping === undefined ? !this.clipping : !!clipping;
+
+       if ( this.clipping !== clipping ) {
+               this.clipping = clipping;
+               if ( clipping ) {
+                       this.$clippableScrollableContainer = $( 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.$clippableScrollableContainer.is( 'html, body' ) ?
+                               $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
+                               this.$clippableScrollableContainer;
+                       this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
+                       this.$clippableWindow = $( this.getElementWindow() )
+                               .on( 'resize', this.onClippableWindowResizeHandler );
+                       // Initial clip after visible
+                       this.clip();
+               } else {
+                       this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
+                       OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
+
+                       this.$clippableScrollableContainer = null;
+                       this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
+                       this.$clippableScroller = null;
+                       this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
+                       this.$clippableWindow = null;
+               }
+       }
+
+       return this;
+};
+
+/**
+ * Check if the element will be clipped to fit the visible area of the nearest scrollable container.
+ *
+ * @return {boolean} Element will be clipped to the visible area
+ */
+OO.ui.mixin.ClippableElement.prototype.isClipping = function () {
+       return this.clipping;
+};
+
+/**
+ * Check if the bottom or right of the element is being clipped by the nearest scrollable container.
+ *
+ * @return {boolean} Part of the element is being clipped
+ */
+OO.ui.mixin.ClippableElement.prototype.isClipped = function () {
+       return this.clippedHorizontally || this.clippedVertically;
+};
+
+/**
+ * Check if the right of the element is being clipped by the nearest scrollable container.
+ *
+ * @return {boolean} Part of the element is being clipped
+ */
+OO.ui.mixin.ClippableElement.prototype.isClippedHorizontally = function () {
+       return this.clippedHorizontally;
+};
+
+/**
+ * Check if the bottom of the element is being clipped by the nearest scrollable container.
+ *
+ * @return {boolean} Part of the element is being clipped
+ */
+OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () {
+       return this.clippedVertically;
+};
+
+/**
+ * Set the ideal size. These are the dimensions the element will have when it's not being clipped.
+ *
+ * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
+ * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
+ */
+OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) {
+       this.idealWidth = width;
+       this.idealHeight = height;
+
+       if ( !this.clipping ) {
+               // Update dimensions
+               this.$clippable.css( { width: width, height: height } );
+       }
+       // While clipping, idealWidth and idealHeight are not considered
+};
+
+/**
+ * Clip element to visible boundaries and allow scrolling when needed. Call this method when
+ * the element's natural height changes.
+ *
+ * Element will be clipped the bottom or right of the element is within 10px of the edge of, or
+ * overlapped by, the visible area of the nearest scrollable container.
+ *
+ * @chainable
+ */
+OO.ui.mixin.ClippableElement.prototype.clip = function () {
+       var $container, extraHeight, extraWidth, ccOffset,
+               $scrollableContainer, scOffset, scHeight, scWidth,
+               ccWidth, scrollerIsWindow, scrollTop, scrollLeft,
+               desiredWidth, desiredHeight, allotedWidth, allotedHeight,
+               naturalWidth, naturalHeight, clipWidth, clipHeight,
+               buffer = 7; // Chosen by fair dice roll
+
+       if ( !this.clipping ) {
+               // this.$clippableScrollableContainer and this.$clippableWindow are null, so the below will fail
+               return this;
+       }
+
+       $container = this.$clippableContainer || this.$clippable;
+       extraHeight = $container.outerHeight() - this.$clippable.outerHeight();
+       extraWidth = $container.outerWidth() - this.$clippable.outerWidth();
+       ccOffset = $container.offset();
+       $scrollableContainer = this.$clippableScrollableContainer.is( 'html, body' ) ?
+               this.$clippableWindow : this.$clippableScrollableContainer;
+       scOffset = $scrollableContainer.offset() || { top: 0, left: 0 };
+       scHeight = $scrollableContainer.innerHeight() - buffer;
+       scWidth = $scrollableContainer.innerWidth() - buffer;
+       ccWidth = $container.outerWidth() + buffer;
+       scrollerIsWindow = this.$clippableScroller[ 0 ] === this.$clippableWindow[ 0 ];
+       scrollTop = scrollerIsWindow ? this.$clippableScroller.scrollTop() : 0;
+       scrollLeft = scrollerIsWindow ? this.$clippableScroller.scrollLeft() : 0;
+       desiredWidth = ccOffset.left < 0 ?
+               ccWidth + ccOffset.left :
+               ( scOffset.left + scrollLeft + scWidth ) - ccOffset.left;
+       desiredHeight = ( scOffset.top + scrollTop + scHeight ) - ccOffset.top;
+       allotedWidth = Math.ceil( desiredWidth - extraWidth );
+       allotedHeight = Math.ceil( desiredHeight - extraHeight );
+       naturalWidth = this.$clippable.prop( 'scrollWidth' );
+       naturalHeight = this.$clippable.prop( 'scrollHeight' );
+       clipWidth = allotedWidth < naturalWidth;
+       clipHeight = allotedHeight < naturalHeight;
+
+       if ( clipWidth ) {
+               this.$clippable.css( { overflowX: 'scroll', width: Math.max( 0, allotedWidth ) } );
+       } else {
+               this.$clippable.css( { width: this.idealWidth ? this.idealWidth - extraWidth : '', overflowX: '' } );
+       }
+       if ( clipHeight ) {
+               this.$clippable.css( { overflowY: 'scroll', height: Math.max( 0, allotedHeight ) } );
+       } else {
+               this.$clippable.css( { height: this.idealHeight ? this.idealHeight - extraHeight : '', overflowY: '' } );
+       }
+
+       // If we stopped clipping in at least one of the dimensions
+       if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) {
+               OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
+       }
+
+       this.clippedHorizontally = clipWidth;
+       this.clippedVertically = clipHeight;
+
+       return this;
+};
+
+/**
+ * PopupWidget is a container for content. The popup is overlaid and positioned absolutely.
+ * By default, each popup has an anchor that points toward its origin.
+ * Please see the [OOjs UI documentation on Mediawiki] [1] for more information and examples.
+ *
+ *     @example
+ *     // A popup widget.
+ *     var popup = new OO.ui.PopupWidget( {
+ *         $content: $( '<p>Hi there!</p>' ),
+ *         padded: true,
+ *         width: 300
+ *     } );
+ *
+ *     $( 'body' ).append( popup.$element );
+ *     // To display the popup, toggle the visibility to 'true'.
+ *     popup.toggle( true );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups
+ *
+ * @class
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.LabelElement
+ * @mixins OO.ui.mixin.ClippableElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {number} [width=320] Width of popup in pixels
+ * @cfg {number} [height] Height of popup in pixels. Omit to use the automatic height.
+ * @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup
+ * @cfg {string} [align='center'] Alignment of the popup: `center`, `force-left`, `force-right`, `backwards` or `forwards`.
+ *  If the popup is forced-left the popup body is leaning towards the left. For force-right alignment, the body of the
+ *  popup is leaning towards the right of the screen.
+ *  Using 'backwards' is a logical direction which will result in the popup leaning towards the beginning of the sentence
+ *  in the given language, which means it will flip to the correct positioning in right-to-left languages.
+ *  Using 'forward' will also result in a logical alignment where the body of the popup leans towards the end of the
+ *  sentence in the given language.
+ * @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container.
+ *  See the [OOjs UI docs on MediaWiki][3] for an example.
+ *  [3]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups#containerExample
+ * @cfg {number} [containerPadding=10] Padding between the popup and its container, specified as a number of pixels.
+ * @cfg {jQuery} [$content] Content to append to the popup's body
+ * @cfg {jQuery} [$footer] Content to append to the popup's footer
+ * @cfg {boolean} [autoClose=false] Automatically close the popup when it loses focus.
+ * @cfg {jQuery} [$autoCloseIgnore] Elements that will not close the popup when clicked.
+ *  This config option is only relevant if #autoClose is set to `true`. See the [OOjs UI docs on MediaWiki][2]
+ *  for an example.
+ *  [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups#autocloseExample
+ * @cfg {boolean} [head] Show a popup header that contains a #label (if specified) and close
+ *  button.
+ * @cfg {boolean} [padded] Add padding to the popup's body
+ */
+OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.PopupWidget.parent.call( this, config );
+
+       // Properties (must be set before ClippableElement constructor call)
+       this.$body = $( '<div>' );
+       this.$popup = $( '<div>' );
+
+       // Mixin constructors
+       OO.ui.mixin.LabelElement.call( this, config );
+       OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, {
+               $clippable: this.$body,
+               $clippableContainer: this.$popup
+       } ) );
+
+       // Properties
+       this.$head = $( '<div>' );
+       this.$footer = $( '<div>' );
+       this.$anchor = $( '<div>' );
+       // If undefined, will be computed lazily in updateDimensions()
+       this.$container = config.$container;
+       this.containerPadding = config.containerPadding !== undefined ? config.containerPadding : 10;
+       this.autoClose = !!config.autoClose;
+       this.$autoCloseIgnore = config.$autoCloseIgnore;
+       this.transitionTimeout = null;
+       this.anchor = null;
+       this.width = config.width !== undefined ? config.width : 320;
+       this.height = config.height !== undefined ? config.height : null;
+       this.setAlignment( config.align );
+       this.closeButton = new OO.ui.ButtonWidget( { framed: false, icon: 'close' } );
+       this.onMouseDownHandler = this.onMouseDown.bind( this );
+       this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
+
+       // Events
+       this.closeButton.connect( this, { click: 'onCloseButtonClick' } );
+
+       // Initialization
+       this.toggleAnchor( config.anchor === undefined || config.anchor );
+       this.$body.addClass( 'oo-ui-popupWidget-body' );
+       this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
+       this.$head
+               .addClass( 'oo-ui-popupWidget-head' )
+               .append( this.$label, this.closeButton.$element );
+       this.$footer.addClass( 'oo-ui-popupWidget-footer' );
+       if ( !config.head ) {
+               this.$head.addClass( 'oo-ui-element-hidden' );
+       }
+       if ( !config.$footer ) {
+               this.$footer.addClass( 'oo-ui-element-hidden' );
+       }
+       this.$popup
+               .addClass( 'oo-ui-popupWidget-popup' )
+               .append( this.$head, this.$body, this.$footer );
+       this.$element
+               .addClass( 'oo-ui-popupWidget' )
+               .append( this.$popup, this.$anchor );
+       // Move content, which was added to #$element by OO.ui.Widget, to the body
+       if ( config.$content instanceof jQuery ) {
+               this.$body.append( config.$content );
+       }
+       if ( config.$footer instanceof jQuery ) {
+               this.$footer.append( config.$footer );
+       }
+       if ( config.padded ) {
+               this.$body.addClass( 'oo-ui-popupWidget-body-padded' );
+       }
+
+       // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
+       // that reference properties not initialized at that time of parent class construction
+       // TODO: Find a better way to handle post-constructor setup
+       this.visible = false;
+       this.$element.addClass( 'oo-ui-element-hidden' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget );
+OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement );
+OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
+
+/* Methods */
+
+/**
+ * Handles mouse down events.
+ *
+ * @private
+ * @param {MouseEvent} e Mouse down event
+ */
+OO.ui.PopupWidget.prototype.onMouseDown = function ( e ) {
+       if (
+               this.isVisible() &&
+               !$.contains( this.$element[ 0 ], e.target ) &&
+               ( !this.$autoCloseIgnore || !this.$autoCloseIgnore.has( e.target ).length )
+       ) {
+               this.toggle( false );
+       }
+};
+
+/**
+ * Bind mouse down listener.
+ *
+ * @private
+ */
+OO.ui.PopupWidget.prototype.bindMouseDownListener = function () {
+       // Capture clicks outside popup
+       this.getElementWindow().addEventListener( 'mousedown', this.onMouseDownHandler, true );
+};
+
+/**
+ * Handles close button click events.
+ *
+ * @private
+ */
+OO.ui.PopupWidget.prototype.onCloseButtonClick = function () {
+       if ( this.isVisible() ) {
+               this.toggle( false );
+       }
+};
+
+/**
+ * Unbind mouse down listener.
+ *
+ * @private
+ */
+OO.ui.PopupWidget.prototype.unbindMouseDownListener = function () {
+       this.getElementWindow().removeEventListener( 'mousedown', this.onMouseDownHandler, true );
+};
+
+/**
+ * Handles key down events.
+ *
+ * @private
+ * @param {KeyboardEvent} e Key down event
+ */
+OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) {
+       if (
+               e.which === OO.ui.Keys.ESCAPE &&
+               this.isVisible()
+       ) {
+               this.toggle( false );
+               e.preventDefault();
+               e.stopPropagation();
+       }
+};
+
+/**
+ * Bind key down listener.
+ *
+ * @private
+ */
+OO.ui.PopupWidget.prototype.bindKeyDownListener = function () {
+       this.getElementWindow().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
+};
+
+/**
+ * Unbind key down listener.
+ *
+ * @private
+ */
+OO.ui.PopupWidget.prototype.unbindKeyDownListener = function () {
+       this.getElementWindow().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
+};
+
+/**
+ * Show, hide, or toggle the visibility of the anchor.
+ *
+ * @param {boolean} [show] Show anchor, omit to toggle
+ */
+OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
+       show = show === undefined ? !this.anchored : !!show;
+
+       if ( this.anchored !== show ) {
+               if ( show ) {
+                       this.$element.addClass( 'oo-ui-popupWidget-anchored' );
+               } else {
+                       this.$element.removeClass( 'oo-ui-popupWidget-anchored' );
+               }
+               this.anchored = show;
+       }
+};
+
+/**
+ * Check if the anchor is visible.
+ *
+ * @return {boolean} Anchor is visible
+ */
+OO.ui.PopupWidget.prototype.hasAnchor = function () {
+       return this.anchor;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.PopupWidget.prototype.toggle = function ( show ) {
+       var change;
+       show = show === undefined ? !this.isVisible() : !!show;
+
+       change = show !== this.isVisible();
+
+       // Parent method
+       OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
+
+       if ( change ) {
+               if ( show ) {
+                       if ( this.autoClose ) {
+                               this.bindMouseDownListener();
+                               this.bindKeyDownListener();
+                       }
+                       this.updateDimensions();
+                       this.toggleClipping( true );
+               } else {
+                       this.toggleClipping( false );
+                       if ( this.autoClose ) {
+                               this.unbindMouseDownListener();
+                               this.unbindKeyDownListener();
+                       }
+               }
+       }
+
+       return this;
+};
+
+/**
+ * Set the size of the popup.
+ *
+ * Changing the size may also change the popup's position depending on the alignment.
+ *
+ * @param {number} width Width in pixels
+ * @param {number} height Height in pixels
+ * @param {boolean} [transition=false] Use a smooth transition
+ * @chainable
+ */
+OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) {
+       this.width = width;
+       this.height = height !== undefined ? height : null;
+       if ( this.isVisible() ) {
+               this.updateDimensions( transition );
+       }
+};
+
+/**
+ * Update the size and position.
+ *
+ * Only use this to keep the popup properly anchored. Use #setSize to change the size, and this will
+ * be called automatically.
+ *
+ * @param {boolean} [transition=false] Use a smooth transition
+ * @chainable
+ */
+OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
+       var popupOffset, originOffset, containerLeft, containerWidth, containerRight,
+               popupLeft, popupRight, overlapLeft, overlapRight, anchorWidth,
+               align = this.align,
+               widget = this;
+
+       if ( !this.$container ) {
+               // Lazy-initialize $container if not specified in constructor
+               this.$container = $( this.getClosestScrollableElementContainer() );
+       }
+
+       // Set height and width before measuring things, since it might cause our measurements
+       // to change (e.g. due to scrollbars appearing or disappearing)
+       this.$popup.css( {
+               width: this.width,
+               height: this.height !== null ? this.height : 'auto'
+       } );
+
+       // If we are in RTL, we need to flip the alignment, unless it is center
+       if ( align === 'forwards' || align === 'backwards' ) {
+               if ( this.$container.css( 'direction' ) === 'rtl' ) {
+                       align = ( { forwards: 'force-left', backwards: 'force-right' } )[ this.align ];
+               } else {
+                       align = ( { forwards: 'force-right', backwards: 'force-left' } )[ this.align ];
+               }
+
+       }
+
+       // Compute initial popupOffset based on alignment
+       popupOffset = this.width * ( { 'force-left': -1, center: -0.5, 'force-right': 0 } )[ align ];
+
+       // Figure out if this will cause the popup to go beyond the edge of the container
+       originOffset = this.$element.offset().left;
+       containerLeft = this.$container.offset().left;
+       containerWidth = this.$container.innerWidth();
+       containerRight = containerLeft + containerWidth;
+       popupLeft = popupOffset - this.containerPadding;
+       popupRight = popupOffset + this.containerPadding + this.width + this.containerPadding;
+       overlapLeft = ( originOffset + popupLeft ) - containerLeft;
+       overlapRight = containerRight - ( originOffset + popupRight );
+
+       // Adjust offset to make the popup not go beyond the edge, if needed
+       if ( overlapRight < 0 ) {
+               popupOffset += overlapRight;
+       } else if ( overlapLeft < 0 ) {
+               popupOffset -= overlapLeft;
+       }
+
+       // Adjust offset to avoid anchor being rendered too close to the edge
+       // $anchor.width() doesn't work with the pure CSS anchor (returns 0)
+       // TODO: Find a measurement that works for CSS anchors and image anchors
+       anchorWidth = this.$anchor[ 0 ].scrollWidth * 2;
+       if ( popupOffset + this.width < anchorWidth ) {
+               popupOffset = anchorWidth - this.width;
+       } else if ( -popupOffset < anchorWidth ) {
+               popupOffset = -anchorWidth;
+       }
+
+       // Prevent transition from being interrupted
+       clearTimeout( this.transitionTimeout );
+       if ( transition ) {
+               // Enable transition
+               this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
+       }
+
+       // Position body relative to anchor
+       this.$popup.css( 'margin-left', popupOffset );
+
+       if ( transition ) {
+               // Prevent transitioning after transition is complete
+               this.transitionTimeout = setTimeout( function () {
+                       widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
+               }, 200 );
+       } else {
+               // Prevent transitioning immediately
+               this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
+       }
+
+       // Reevaluate clipping state since we've relocated and resized the popup
+       this.clip();
+
+       return this;
+};
+
+/**
+ * Set popup alignment
+ * @param {string} align Alignment of the popup, `center`, `force-left`, `force-right`,
+ *  `backwards` or `forwards`.
+ */
+OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
+       // Validate alignment and transform deprecated values
+       if ( [ 'left', 'right', 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
+               this.align = { left: 'force-right', right: 'force-left' }[ align ] || align;
+       } else {
+               this.align = 'center';
+       }
+};
+
+/**
+ * Get popup alignment
+ * @return {string} align Alignment of the popup, `center`, `force-left`, `force-right`,
+ *  `backwards` or `forwards`.
+ */
+OO.ui.PopupWidget.prototype.getAlignment = function () {
+       return this.align;
+};
+
+/**
+ * PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
+ * A popup is a container for content. It is overlaid and positioned absolutely. By default, each
+ * popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin.
+ * See {@link OO.ui.PopupWidget PopupWidget} for an example.
+ *
+ * @abstract
+ * @class
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {Object} [popup] Configuration to pass to popup
+ * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus
+ */
+OO.ui.mixin.PopupElement = function OoUiMixinPopupElement( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Properties
+       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.mixin.PopupElement.prototype.getPopup = function () {
+       return this.popup;
+};
+
+/**
+ * PopupButtonWidgets toggle the visibility of a contained {@link OO.ui.PopupWidget PopupWidget},
+ * which is used to display additional information or options.
+ *
+ *     @example
+ *     // Example of a popup button.
+ *     var popupButton = new OO.ui.PopupButtonWidget( {
+ *         label: 'Popup button with options',
+ *         icon: 'menu',
+ *         popup: {
+ *             $content: $( '<p>Additional options here.</p>' ),
+ *             padded: true,
+ *             align: 'force-left'
+ *         }
+ *     } );
+ *     // Append the button to the DOM.
+ *     $( 'body' ).append( popupButton.$element );
+ *
+ * @class
+ * @extends OO.ui.ButtonWidget
+ * @mixins OO.ui.mixin.PopupElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
+       // Parent constructor
+       OO.ui.PopupButtonWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.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 );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.PopupButtonWidget, OO.ui.ButtonWidget );
+OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.mixin.PopupElement );
+
+/* Methods */
+
+/**
+ * Handle the button action being triggered.
+ *
+ * @private
+ */
+OO.ui.PopupButtonWidget.prototype.onAction = function () {
+       this.popup.toggle();
+};
+
+/**
+ * Mixin for OO.ui.Widget subclasses to provide OO.ui.mixin.GroupElement.
+ *
+ * Use together with OO.ui.mixin.ItemWidget to make disabled state inheritable.
+ *
+ * @private
+ * @abstract
+ * @class
+ * @extends OO.ui.mixin.GroupElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.mixin.GroupWidget = function OoUiMixinGroupWidget( config ) {
+       // Parent constructor
+       OO.ui.mixin.GroupWidget.parent.call( this, config );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.mixin.GroupWidget, OO.ui.mixin.GroupElement );
+
+/* Methods */
+
+/**
+ * Set the disabled state of the widget.
+ *
+ * This will also update the disabled state of child widgets.
+ *
+ * @param {boolean} disabled Disable widget
+ * @chainable
+ */
+OO.ui.mixin.GroupWidget.prototype.setDisabled = function ( disabled ) {
+       var i, len;
+
+       // Parent method
+       // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget
+       OO.ui.Widget.prototype.setDisabled.call( this, disabled );
+
+       // During construction, #setDisabled is called before the OO.ui.mixin.GroupElement constructor
+       if ( this.items ) {
+               for ( i = 0, len = this.items.length; i < len; i++ ) {
+                       this.items[ i ].updateDisabled();
+               }
+       }
+
+       return this;
+};
+
+/**
+ * Mixin for widgets used as items in widgets that mix in OO.ui.mixin.GroupWidget.
+ *
+ * Item widgets have a reference to a OO.ui.mixin.GroupWidget while they are attached to the group. This
+ * allows bidirectional communication.
+ *
+ * Use together with OO.ui.mixin.GroupWidget to make disabled state inheritable.
+ *
+ * @private
+ * @abstract
+ * @class
+ *
+ * @constructor
+ */
+OO.ui.mixin.ItemWidget = function OoUiMixinItemWidget() {
+       //
+};
+
+/* Methods */
+
+/**
+ * Check if widget is disabled.
+ *
+ * Checks parent if present, making disabled state inheritable.
+ *
+ * @return {boolean} Widget is disabled
+ */
+OO.ui.mixin.ItemWidget.prototype.isDisabled = function () {
+       return this.disabled ||
+               ( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() );
+};
+
+/**
+ * Set group element is in.
+ *
+ * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
+ * @chainable
+ */
+OO.ui.mixin.ItemWidget.prototype.setElementGroup = function ( group ) {
+       // Parent method
+       // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element
+       OO.ui.Element.prototype.setElementGroup.call( this, group );
+
+       // Initialize item disabled states
+       this.updateDisabled();
+
+       return this;
+};
+
+/**
+ * OptionWidgets are special elements that can be selected and configured with data. The
+ * data is often unique for each option, but it does not have to be. OptionWidgets are used
+ * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
+ * and examples, please see the [OOjs UI documentation on MediaWiki][1].
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
+ *
+ * @class
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.LabelElement
+ * @mixins OO.ui.mixin.FlaggedElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.OptionWidget = function OoUiOptionWidget( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.OptionWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.ItemWidget.call( this );
+       OO.ui.mixin.LabelElement.call( this, config );
+       OO.ui.mixin.FlaggedElement.call( this, config );
+
+       // Properties
+       this.selected = false;
+       this.highlighted = false;
+       this.pressed = false;
+
+       // Initialization
+       this.$element
+               .data( 'oo-ui-optionWidget', this )
+               .attr( 'role', 'option' )
+               .attr( 'aria-selected', 'false' )
+               .addClass( 'oo-ui-optionWidget' )
+               .append( this.$label );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.OptionWidget, OO.ui.Widget );
+OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.ItemWidget );
+OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.LabelElement );
+OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.FlaggedElement );
+
+/* Static Properties */
+
+OO.ui.OptionWidget.static.selectable = true;
+
+OO.ui.OptionWidget.static.highlightable = true;
+
+OO.ui.OptionWidget.static.pressable = true;
+
+OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
+
+/* Methods */
+
+/**
+ * Check if the option can be selected.
+ *
+ * @return {boolean} Item is selectable
+ */
+OO.ui.OptionWidget.prototype.isSelectable = function () {
+       return this.constructor.static.selectable && !this.isDisabled() && this.isVisible();
+};
+
+/**
+ * Check if the option can be highlighted. A highlight indicates that the option
+ * may be selected when a user presses enter or clicks. Disabled items cannot
+ * be highlighted.
+ *
+ * @return {boolean} Item is highlightable
+ */
+OO.ui.OptionWidget.prototype.isHighlightable = function () {
+       return this.constructor.static.highlightable && !this.isDisabled() && this.isVisible();
+};
+
+/**
+ * Check if the option can be pressed. The pressed state occurs when a user mouses
+ * down on an item, but has not yet let go of the mouse.
+ *
+ * @return {boolean} Item is pressable
+ */
+OO.ui.OptionWidget.prototype.isPressable = function () {
+       return this.constructor.static.pressable && !this.isDisabled() && this.isVisible();
+};
+
+/**
+ * Check if the option is selected.
+ *
+ * @return {boolean} Item is selected
+ */
+OO.ui.OptionWidget.prototype.isSelected = function () {
+       return this.selected;
+};
+
+/**
+ * Check if the option is highlighted. A highlight indicates that the
+ * item may be selected when a user presses enter or clicks.
+ *
+ * @return {boolean} Item is highlighted
+ */
+OO.ui.OptionWidget.prototype.isHighlighted = function () {
+       return this.highlighted;
+};
+
+/**
+ * Check if the option is pressed. The pressed state occurs when a user mouses
+ * down on an item, but has not yet let go of the mouse. The item may appear
+ * selected, but it will not be selected until the user releases the mouse.
+ *
+ * @return {boolean} Item is pressed
+ */
+OO.ui.OptionWidget.prototype.isPressed = function () {
+       return this.pressed;
+};
+
+/**
+ * Set the option’s selected state. In general, all modifications to the selection
+ * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
+ * method instead of this method.
+ *
+ * @param {boolean} [state=false] Select option
+ * @chainable
+ */
+OO.ui.OptionWidget.prototype.setSelected = function ( state ) {
+       if ( this.constructor.static.selectable ) {
+               this.selected = !!state;
+               this.$element
+                       .toggleClass( 'oo-ui-optionWidget-selected', state )
+                       .attr( 'aria-selected', state.toString() );
+               if ( state && this.constructor.static.scrollIntoViewOnSelect ) {
+                       this.scrollElementIntoView();
+               }
+               this.updateThemeClasses();
+       }
+       return this;
+};
+
+/**
+ * Set the option’s highlighted state. In general, all programmatic
+ * modifications to the highlight should be handled by the
+ * SelectWidget’s {@link OO.ui.SelectWidget#highlightItem highlightItem( [item] )}
+ * method instead of this method.
+ *
+ * @param {boolean} [state=false] Highlight option
+ * @chainable
+ */
+OO.ui.OptionWidget.prototype.setHighlighted = function ( state ) {
+       if ( this.constructor.static.highlightable ) {
+               this.highlighted = !!state;
+               this.$element.toggleClass( 'oo-ui-optionWidget-highlighted', state );
+               this.updateThemeClasses();
+       }
+       return this;
+};
+
+/**
+ * Set the option’s pressed state. In general, all
+ * programmatic modifications to the pressed state should be handled by the
+ * SelectWidget’s {@link OO.ui.SelectWidget#pressItem pressItem( [item] )}
+ * method instead of this method.
+ *
+ * @param {boolean} [state=false] Press option
+ * @chainable
+ */
+OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
+       if ( this.constructor.static.pressable ) {
+               this.pressed = !!state;
+               this.$element.toggleClass( 'oo-ui-optionWidget-pressed', state );
+               this.updateThemeClasses();
+       }
+       return this;
+};
+
+/**
+ * A SelectWidget is of a generic selection of options. The OOjs UI library contains several types of
+ * select widgets, including {@link OO.ui.ButtonSelectWidget button selects},
+ * {@link OO.ui.RadioSelectWidget radio selects}, and {@link OO.ui.MenuSelectWidget
+ * menu selects}.
+ *
+ * This class should be used together with OO.ui.OptionWidget or OO.ui.DecoratedOptionWidget. For more
+ * information, please see the [OOjs UI documentation on MediaWiki][1].
+ *
+ *     @example
+ *     // Example of a select widget with three options
+ *     var select = new OO.ui.SelectWidget( {
+ *         items: [
+ *             new OO.ui.OptionWidget( {
+ *                 data: 'a',
+ *                 label: 'Option One',
+ *             } ),
+ *             new OO.ui.OptionWidget( {
+ *                 data: 'b',
+ *                 label: 'Option Two',
+ *             } ),
+ *             new OO.ui.OptionWidget( {
+ *                 data: 'c',
+ *                 label: 'Option Three',
+ *             } )
+ *         ]
+ *     } );
+ *     $( 'body' ).append( select.$element );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
+ *
+ * @abstract
+ * @class
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.GroupWidget
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {OO.ui.OptionWidget[]} [items] An array of options to add to the select.
+ *  Options are created with {@link OO.ui.OptionWidget OptionWidget} classes. See
+ *  the [OOjs UI documentation on MediaWiki] [2] for examples.
+ *  [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
+ */
+OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.SelectWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.GroupWidget.call( this, $.extend( {}, config, { $group: this.$element } ) );
+
+       // Properties
+       this.pressed = false;
+       this.selecting = null;
+       this.onMouseUpHandler = this.onMouseUp.bind( this );
+       this.onMouseMoveHandler = this.onMouseMove.bind( this );
+       this.onKeyDownHandler = this.onKeyDown.bind( this );
+       this.onKeyPressHandler = this.onKeyPress.bind( this );
+       this.keyPressBuffer = '';
+       this.keyPressBufferTimer = null;
+
+       // Events
+       this.connect( this, {
+               toggle: 'onToggle'
+       } );
+       this.$element.on( {
+               mousedown: this.onMouseDown.bind( this ),
+               mouseover: this.onMouseOver.bind( this ),
+               mouseleave: this.onMouseLeave.bind( this )
+       } );
+
+       // Initialization
+       this.$element
+               .addClass( 'oo-ui-selectWidget oo-ui-selectWidget-depressed' )
+               .attr( 'role', 'listbox' );
+       if ( Array.isArray( config.items ) ) {
+               this.addItems( config.items );
+       }
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget );
+
+// Need to mixin base class as well
+OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupElement );
+OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupWidget );
+
+/* Static */
+OO.ui.SelectWidget.static.passAllFilter = function () {
+       return true;
+};
+
+/* Events */
+
+/**
+ * @event highlight
+ *
+ * A `highlight` event is emitted when the highlight is changed with the #highlightItem method.
+ *
+ * @param {OO.ui.OptionWidget|null} item Highlighted item
+ */
+
+/**
+ * @event press
+ *
+ * A `press` event is emitted when the #pressItem method is used to programmatically modify the
+ * pressed state of an option.
+ *
+ * @param {OO.ui.OptionWidget|null} item Pressed item
+ */
+
+/**
+ * @event select
+ *
+ * A `select` event is emitted when the selection is modified programmatically with the #selectItem method.
+ *
+ * @param {OO.ui.OptionWidget|null} item Selected item
+ */
+
+/**
+ * @event choose
+ * A `choose` event is emitted when an item is chosen with the #chooseItem method.
+ * @param {OO.ui.OptionWidget} item Chosen item
+ */
+
+/**
+ * @event add
+ *
+ * An `add` event is emitted when options are added to the select with the #addItems method.
+ *
+ * @param {OO.ui.OptionWidget[]} items Added items
+ * @param {number} index Index of insertion point
+ */
+
+/**
+ * @event remove
+ *
+ * A `remove` event is emitted when options are removed from the select with the #clearItems
+ * or #removeItems methods.
+ *
+ * @param {OO.ui.OptionWidget[]} items Removed items
+ */
+
+/* Methods */
+
+/**
+ * Handle mouse down events.
+ *
+ * @private
+ * @param {jQuery.Event} e Mouse down event
+ */
+OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
+       var item;
+
+       if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
+               this.togglePressed( true );
+               item = this.getTargetItem( e );
+               if ( item && item.isSelectable() ) {
+                       this.pressItem( item );
+                       this.selecting = item;
+                       this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true );
+                       this.getElementDocument().addEventListener( 'mousemove', this.onMouseMoveHandler, true );
+               }
+       }
+       return false;
+};
+
+/**
+ * Handle mouse up events.
+ *
+ * @private
+ * @param {jQuery.Event} e Mouse up event
+ */
+OO.ui.SelectWidget.prototype.onMouseUp = function ( e ) {
+       var item;
+
+       this.togglePressed( false );
+       if ( !this.selecting ) {
+               item = this.getTargetItem( e );
+               if ( item && item.isSelectable() ) {
+                       this.selecting = item;
+               }
+       }
+       if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT && this.selecting ) {
+               this.pressItem( null );
+               this.chooseItem( this.selecting );
+               this.selecting = null;
+       }
+
+       this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true );
+       this.getElementDocument().removeEventListener( 'mousemove', this.onMouseMoveHandler, true );
+
+       return false;
+};
+
+/**
+ * Handle mouse move events.
+ *
+ * @private
+ * @param {jQuery.Event} e Mouse move event
+ */
+OO.ui.SelectWidget.prototype.onMouseMove = function ( e ) {
+       var item;
+
+       if ( !this.isDisabled() && this.pressed ) {
+               item = this.getTargetItem( e );
+               if ( item && item !== this.selecting && item.isSelectable() ) {
+                       this.pressItem( item );
+                       this.selecting = item;
+               }
+       }
+       return false;
+};
+
+/**
+ * Handle mouse over events.
+ *
+ * @private
+ * @param {jQuery.Event} e Mouse over event
+ */
+OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
+       var item;
+
+       if ( !this.isDisabled() ) {
+               item = this.getTargetItem( e );
+               this.highlightItem( item && item.isHighlightable() ? item : null );
+       }
+       return false;
+};
+
+/**
+ * Handle mouse leave events.
+ *
+ * @private
+ * @param {jQuery.Event} e Mouse over event
+ */
+OO.ui.SelectWidget.prototype.onMouseLeave = function () {
+       if ( !this.isDisabled() ) {
+               this.highlightItem( null );
+       }
+       return false;
+};
+
+/**
+ * Handle key down events.
+ *
+ * @protected
+ * @param {jQuery.Event} e Key down event
+ */
+OO.ui.SelectWidget.prototype.onKeyDown = function ( e ) {
+       var nextItem,
+               handled = false,
+               currentItem = this.getHighlightedItem() || this.getSelectedItem();
+
+       if ( !this.isDisabled() && this.isVisible() ) {
+               switch ( e.keyCode ) {
+                       case OO.ui.Keys.ENTER:
+                               if ( currentItem && currentItem.constructor.static.highlightable ) {
+                                       // Was only highlighted, now let's select it. No-op if already selected.
+                                       this.chooseItem( currentItem );
+                                       handled = true;
+                               }
+                               break;
+                       case OO.ui.Keys.UP:
+                       case OO.ui.Keys.LEFT:
+                               this.clearKeyPressBuffer();
+                               nextItem = this.getRelativeSelectableItem( currentItem, -1 );
+                               handled = true;
+                               break;
+                       case OO.ui.Keys.DOWN:
+                       case OO.ui.Keys.RIGHT:
+                               this.clearKeyPressBuffer();
+                               nextItem = this.getRelativeSelectableItem( currentItem, 1 );
+                               handled = true;
+                               break;
+                       case OO.ui.Keys.ESCAPE:
+                       case OO.ui.Keys.TAB:
+                               if ( currentItem && currentItem.constructor.static.highlightable ) {
+                                       currentItem.setHighlighted( false );
+                               }
+                               this.unbindKeyDownListener();
+                               this.unbindKeyPressListener();
+                               // Don't prevent tabbing away / defocusing
+                               handled = false;
+                               break;
+               }
+
+               if ( nextItem ) {
+                       if ( nextItem.constructor.static.highlightable ) {
+                               this.highlightItem( nextItem );
+                       } else {
+                               this.chooseItem( nextItem );
+                       }
+                       nextItem.scrollElementIntoView();
+               }
+
+               if ( handled ) {
+                       // Can't just return false, because e is not always a jQuery event
+                       e.preventDefault();
+                       e.stopPropagation();
+               }
+       }
+};
+
+/**
+ * Bind key down listener.
+ *
+ * @protected
+ */
+OO.ui.SelectWidget.prototype.bindKeyDownListener = function () {
+       this.getElementWindow().addEventListener( 'keydown', this.onKeyDownHandler, true );
+};
+
+/**
+ * Unbind key down listener.
+ *
+ * @protected
+ */
+OO.ui.SelectWidget.prototype.unbindKeyDownListener = function () {
+       this.getElementWindow().removeEventListener( 'keydown', this.onKeyDownHandler, true );
+};
+
+/**
+ * Clear the key-press buffer
+ *
+ * @protected
+ */
+OO.ui.SelectWidget.prototype.clearKeyPressBuffer = function () {
+       if ( this.keyPressBufferTimer ) {
+               clearTimeout( this.keyPressBufferTimer );
+               this.keyPressBufferTimer = null;
+       }
+       this.keyPressBuffer = '';
+};
+
+/**
+ * Handle key press events.
+ *
+ * @protected
+ * @param {jQuery.Event} e Key press event
+ */
+OO.ui.SelectWidget.prototype.onKeyPress = function ( e ) {
+       var c, filter, item;
+
+       if ( !e.charCode ) {
+               if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.keyPressBuffer !== '' ) {
+                       this.keyPressBuffer = this.keyPressBuffer.substr( 0, this.keyPressBuffer.length - 1 );
+                       return false;
+               }
+               return;
+       }
+       if ( String.fromCodePoint ) {
+               c = String.fromCodePoint( e.charCode );
+       } else {
+               c = String.fromCharCode( e.charCode );
+       }
+
+       if ( this.keyPressBufferTimer ) {
+               clearTimeout( this.keyPressBufferTimer );
+       }
+       this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 );
+
+       item = this.getHighlightedItem() || this.getSelectedItem();
+
+       if ( this.keyPressBuffer === c ) {
+               // Common (if weird) special case: typing "xxxx" will cycle through all
+               // the items beginning with "x".
+               if ( item ) {
+                       item = this.getRelativeSelectableItem( item, 1 );
+               }
+       } else {
+               this.keyPressBuffer += c;
+       }
+
+       filter = this.getItemMatcher( this.keyPressBuffer, false );
+       if ( !item || !filter( item ) ) {
+               item = this.getRelativeSelectableItem( item, 1, filter );
+       }
+       if ( item ) {
+               if ( item.constructor.static.highlightable ) {
+                       this.highlightItem( item );
+               } else {
+                       this.chooseItem( item );
+               }
+               item.scrollElementIntoView();
+       }
+
+       return false;
+};
+
+/**
+ * Get a matcher for the specific string
+ *
+ * @protected
+ * @param {string} s String to match against items
+ * @param {boolean} [exact=false] Only accept exact matches
+ * @return {Function} function ( OO.ui.OptionItem ) => boolean
+ */
+OO.ui.SelectWidget.prototype.getItemMatcher = function ( s, exact ) {
+       var re;
+
+       if ( s.normalize ) {
+               s = s.normalize();
+       }
+       s = exact ? s.trim() : s.replace( /^\s+/, '' );
+       re = '^\\s*' + s.replace( /([\\{}()|.?*+\-\^$\[\]])/g, '\\$1' ).replace( /\s+/g, '\\s+' );
+       if ( exact ) {
+               re += '\\s*$';
+       }
+       re = new RegExp( re, 'i' );
+       return function ( item ) {
+               var l = item.getLabel();
+               if ( typeof l !== 'string' ) {
+                       l = item.$label.text();
+               }
+               if ( l.normalize ) {
+                       l = l.normalize();
+               }
+               return re.test( l );
+       };
+};
+
+/**
+ * Bind key press listener.
+ *
+ * @protected
+ */
+OO.ui.SelectWidget.prototype.bindKeyPressListener = function () {
+       this.getElementWindow().addEventListener( 'keypress', this.onKeyPressHandler, true );
+};
+
+/**
+ * Unbind key down listener.
+ *
+ * If you override this, be sure to call this.clearKeyPressBuffer() from your
+ * implementation.
+ *
+ * @protected
+ */
+OO.ui.SelectWidget.prototype.unbindKeyPressListener = function () {
+       this.getElementWindow().removeEventListener( 'keypress', this.onKeyPressHandler, true );
+       this.clearKeyPressBuffer();
+};
+
+/**
+ * Visibility change handler
+ *
+ * @protected
+ * @param {boolean} visible
+ */
+OO.ui.SelectWidget.prototype.onToggle = function ( visible ) {
+       if ( !visible ) {
+               this.clearKeyPressBuffer();
+       }
+};
+
+/**
+ * Get the closest item to a jQuery.Event.
+ *
+ * @private
+ * @param {jQuery.Event} e
+ * @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found
+ */
+OO.ui.SelectWidget.prototype.getTargetItem = function ( e ) {
+       return $( e.target ).closest( '.oo-ui-optionWidget' ).data( 'oo-ui-optionWidget' ) || null;
+};
+
+/**
+ * Get selected item.
+ *
+ * @return {OO.ui.OptionWidget|null} Selected item, `null` if no item is selected
+ */
+OO.ui.SelectWidget.prototype.getSelectedItem = function () {
+       var i, len;
+
+       for ( i = 0, len = this.items.length; i < len; i++ ) {
+               if ( this.items[ i ].isSelected() ) {
+                       return this.items[ i ];
+               }
+       }
+       return null;
+};
+
+/**
+ * Get highlighted item.
+ *
+ * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
+ */
+OO.ui.SelectWidget.prototype.getHighlightedItem = function () {
+       var i, len;
+
+       for ( i = 0, len = this.items.length; i < len; i++ ) {
+               if ( this.items[ i ].isHighlighted() ) {
+                       return this.items[ i ];
+               }
+       }
+       return null;
+};
+
+/**
+ * Toggle pressed state.
+ *
+ * Press is a state that occurs when a user mouses down on an item, but
+ * has not yet let go of the mouse. The item may appear selected, but it will not be selected
+ * until the user releases the mouse.
+ *
+ * @param {boolean} pressed An option is being pressed
+ */
+OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) {
+       if ( pressed === undefined ) {
+               pressed = !this.pressed;
+       }
+       if ( pressed !== this.pressed ) {
+               this.$element
+                       .toggleClass( 'oo-ui-selectWidget-pressed', pressed )
+                       .toggleClass( 'oo-ui-selectWidget-depressed', !pressed );
+               this.pressed = pressed;
+       }
+};
+
+/**
+ * Highlight an option. If the `item` param is omitted, no options will be highlighted
+ * and any existing highlight will be removed. The highlight is mutually exclusive.
+ *
+ * @param {OO.ui.OptionWidget} [item] Item to highlight, omit for no highlight
+ * @fires highlight
+ * @chainable
+ */
+OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
+       var i, len, highlighted,
+               changed = false;
+
+       for ( i = 0, len = this.items.length; i < len; i++ ) {
+               highlighted = this.items[ i ] === item;
+               if ( this.items[ i ].isHighlighted() !== highlighted ) {
+                       this.items[ i ].setHighlighted( highlighted );
+                       changed = true;
+               }
+       }
+       if ( changed ) {
+               this.emit( 'highlight', item );
+       }
+
+       return this;
+};
+
+/**
+ * Fetch an item by its label.
+ *
+ * @param {string} label Label of the item to select.
+ * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
+ * @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists
+ */
+OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) {
+       var i, item, found,
+               len = this.items.length,
+               filter = this.getItemMatcher( label, true );
+
+       for ( i = 0; i < len; i++ ) {
+               item = this.items[ i ];
+               if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
+                       return item;
+               }
+       }
+
+       if ( prefix ) {
+               found = null;
+               filter = this.getItemMatcher( label, false );
+               for ( i = 0; i < len; i++ ) {
+                       item = this.items[ i ];
+                       if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
+                               if ( found ) {
+                                       return null;
+                               }
+                               found = item;
+                       }
+               }
+               if ( found ) {
+                       return found;
+               }
+       }
+
+       return null;
+};
+
+/**
+ * Programmatically select an option by its label. If the item does not exist,
+ * all options will be deselected.
+ *
+ * @param {string} [label] Label of the item to select.
+ * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
+ * @fires select
+ * @chainable
+ */
+OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) {
+       var itemFromLabel = this.getItemFromLabel( label, !!prefix );
+       if ( label === undefined || !itemFromLabel ) {
+               return this.selectItem();
+       }
+       return this.selectItem( itemFromLabel );
+};
+
+/**
+ * Programmatically select an option by its data. If the `data` parameter is omitted,
+ * or if the item does not exist, all options will be deselected.
+ *
+ * @param {Object|string} [data] Value of the item to select, omit to deselect all
+ * @fires select
+ * @chainable
+ */
+OO.ui.SelectWidget.prototype.selectItemByData = function ( data ) {
+       var itemFromData = this.getItemFromData( data );
+       if ( data === undefined || !itemFromData ) {
+               return this.selectItem();
+       }
+       return this.selectItem( itemFromData );
+};
+
+/**
+ * Programmatically select an option by its reference. If the `item` parameter is omitted,
+ * all options will be deselected.
+ *
+ * @param {OO.ui.OptionWidget} [item] Item to select, omit to deselect all
+ * @fires select
+ * @chainable
+ */
+OO.ui.SelectWidget.prototype.selectItem = function ( item ) {
+       var i, len, selected,
+               changed = false;
+
+       for ( i = 0, len = this.items.length; i < len; i++ ) {
+               selected = this.items[ i ] === item;
+               if ( this.items[ i ].isSelected() !== selected ) {
+                       this.items[ i ].setSelected( selected );
+                       changed = true;
+               }
+       }
+       if ( changed ) {
+               this.emit( 'select', item );
+       }
+
+       return this;
+};
+
+/**
+ * Press an item.
+ *
+ * Press is a state that occurs when a user mouses down on an item, but has not
+ * yet let go of the mouse. The item may appear selected, but it will not be selected until the user
+ * releases the mouse.
+ *
+ * @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all
+ * @fires press
+ * @chainable
+ */
+OO.ui.SelectWidget.prototype.pressItem = function ( item ) {
+       var i, len, pressed,
+               changed = false;
+
+       for ( i = 0, len = this.items.length; i < len; i++ ) {
+               pressed = this.items[ i ] === item;
+               if ( this.items[ i ].isPressed() !== pressed ) {
+                       this.items[ i ].setPressed( pressed );
+                       changed = true;
+               }
+       }
+       if ( changed ) {
+               this.emit( 'press', item );
+       }
+
+       return this;
+};
+
+/**
+ * Choose an item.
+ *
+ * Note that ‘choose’ should never be modified programmatically. A user can choose
+ * an option with the keyboard or mouse and it becomes selected. To select an item programmatically,
+ * use the #selectItem method.
+ *
+ * This method is identical to #selectItem, but may vary in subclasses that take additional action
+ * when users choose an item with the keyboard or mouse.
+ *
+ * @param {OO.ui.OptionWidget} item Item to choose
+ * @fires choose
+ * @chainable
+ */
+OO.ui.SelectWidget.prototype.chooseItem = function ( item ) {
+       if ( item ) {
+               this.selectItem( item );
+               this.emit( 'choose', item );
+       }
+
+       return this;
+};
+
+/**
+ * Get an option by its position relative to the specified item (or to the start of the option array,
+ * if item is `null`). The direction in which to search through the option array is specified with a
+ * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
+ * `null` if there are no options in the array.
+ *
+ * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
+ * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
+ * @param {Function} filter Only consider items for which this function returns
+ *  true. Function takes an OO.ui.OptionWidget and returns a boolean.
+ * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
+ */
+OO.ui.SelectWidget.prototype.getRelativeSelectableItem = function ( item, direction, filter ) {
+       var currentIndex, nextIndex, i,
+               increase = direction > 0 ? 1 : -1,
+               len = this.items.length;
+
+       if ( !$.isFunction( filter ) ) {
+               filter = OO.ui.SelectWidget.static.passAllFilter;
+       }
+
+       if ( item instanceof OO.ui.OptionWidget ) {
+               currentIndex = this.items.indexOf( item );
+               nextIndex = ( currentIndex + increase + len ) % len;
+       } else {
+               // If no item is selected and moving forward, start at the beginning.
+               // If moving backward, start at the end.
+               nextIndex = direction > 0 ? 0 : len - 1;
+       }
+
+       for ( i = 0; i < len; i++ ) {
+               item = this.items[ nextIndex ];
+               if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
+                       return item;
+               }
+               nextIndex = ( nextIndex + increase + len ) % len;
+       }
+       return null;
+};
+
+/**
+ * Get the next selectable item or `null` if there are no selectable items.
+ * Disabled options and menu-section markers and breaks are not selectable.
+ *
+ * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
+ */
+OO.ui.SelectWidget.prototype.getFirstSelectableItem = function () {
+       var i, len, item;
+
+       for ( i = 0, len = this.items.length; i < len; i++ ) {
+               item = this.items[ i ];
+               if ( item instanceof OO.ui.OptionWidget && item.isSelectable() ) {
+                       return item;
+               }
+       }
+
+       return null;
+};
+
+/**
+ * Add an array of options to the select. Optionally, an index number can be used to
+ * specify an insertion point.
+ *
+ * @param {OO.ui.OptionWidget[]} items Items to add
+ * @param {number} [index] Index to insert items after
+ * @fires add
+ * @chainable
+ */
+OO.ui.SelectWidget.prototype.addItems = function ( items, index ) {
+       // Mixin method
+       OO.ui.mixin.GroupWidget.prototype.addItems.call( this, items, index );
+
+       // Always provide an index, even if it was omitted
+       this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index );
+
+       return this;
+};
+
+/**
+ * Remove the specified array of options from the select. Options will be detached
+ * from the DOM, not removed, so they can be reused later. To remove all options from
+ * the select, you may wish to use the #clearItems method instead.
+ *
+ * @param {OO.ui.OptionWidget[]} items Items to remove
+ * @fires remove
+ * @chainable
+ */
+OO.ui.SelectWidget.prototype.removeItems = function ( items ) {
+       var i, len, item;
+
+       // Deselect items being removed
+       for ( i = 0, len = items.length; i < len; i++ ) {
+               item = items[ i ];
+               if ( item.isSelected() ) {
+                       this.selectItem( null );
+               }
+       }
+
+       // Mixin method
+       OO.ui.mixin.GroupWidget.prototype.removeItems.call( this, items );
+
+       this.emit( 'remove', items );
+
+       return this;
+};
+
+/**
+ * Clear all options from the select. Options will be detached from the DOM, not removed,
+ * so that they can be reused later. To remove a subset of options from the select, use
+ * the #removeItems method.
+ *
+ * @fires remove
+ * @chainable
+ */
+OO.ui.SelectWidget.prototype.clearItems = function () {
+       var items = this.items.slice();
+
+       // Mixin method
+       OO.ui.mixin.GroupWidget.prototype.clearItems.call( this );
+
+       // Clear selection
+       this.selectItem( null );
+
+       this.emit( 'remove', items );
+
+       return this;
+};
+
+/**
+ * DecoratedOptionWidgets are {@link OO.ui.OptionWidget options} that can be configured
+ * with an {@link OO.ui.mixin.IconElement icon} and/or {@link OO.ui.mixin.IndicatorElement indicator}.
+ * This class is used with OO.ui.SelectWidget to create a selection of mutually exclusive
+ * options. For more information about options and selects, please see the
+ * [OOjs UI documentation on MediaWiki][1].
+ *
+ *     @example
+ *     // Decorated options in a select widget
+ *     var select = new OO.ui.SelectWidget( {
+ *         items: [
+ *             new OO.ui.DecoratedOptionWidget( {
+ *                 data: 'a',
+ *                 label: 'Option with icon',
+ *                 icon: 'help'
+ *             } ),
+ *             new OO.ui.DecoratedOptionWidget( {
+ *                 data: 'b',
+ *                 label: 'Option with indicator',
+ *                 indicator: 'next'
+ *             } )
+ *         ]
+ *     } );
+ *     $( 'body' ).append( select.$element );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
+ *
+ * @class
+ * @extends OO.ui.OptionWidget
+ * @mixins OO.ui.mixin.IconElement
+ * @mixins OO.ui.mixin.IndicatorElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) {
+       // Parent constructor
+       OO.ui.DecoratedOptionWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.IconElement.call( this, config );
+       OO.ui.mixin.IndicatorElement.call( this, config );
+
+       // Initialization
+       this.$element
+               .addClass( 'oo-ui-decoratedOptionWidget' )
+               .prepend( this.$icon )
+               .append( this.$indicator );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.DecoratedOptionWidget, OO.ui.OptionWidget );
+OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IconElement );
+OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement );
+
+/**
+ * MenuOptionWidget is an option widget that looks like a menu item. The class is used with
+ * OO.ui.MenuSelectWidget to create a menu of mutually exclusive options. Please see
+ * the [OOjs UI documentation on MediaWiki] [1] for more information.
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
+ *
+ * @class
+ * @extends OO.ui.DecoratedOptionWidget
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.MenuOptionWidget = function OoUiMenuOptionWidget( config ) {
+       // Configuration initialization
+       config = $.extend( { icon: 'check' }, config );
+
+       // Parent constructor
+       OO.ui.MenuOptionWidget.parent.call( this, config );
+
+       // Initialization
+       this.$element
+               .attr( 'role', 'menuitem' )
+               .addClass( 'oo-ui-menuOptionWidget' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.MenuOptionWidget, OO.ui.DecoratedOptionWidget );
+
+/* Static Properties */
+
+OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true;
+
+/**
+ * MenuSectionOptionWidgets are used inside {@link OO.ui.MenuSelectWidget menu select widgets} to group one or more related
+ * {@link OO.ui.MenuOptionWidget menu options}. MenuSectionOptionWidgets cannot be highlighted or selected.
+ *
+ *     @example
+ *     var myDropdown = new OO.ui.DropdownWidget( {
+ *         menu: {
+ *             items: [
+ *                 new OO.ui.MenuSectionOptionWidget( {
+ *                     label: 'Dogs'
+ *                 } ),
+ *                 new OO.ui.MenuOptionWidget( {
+ *                     data: 'corgi',
+ *                     label: 'Welsh Corgi'
+ *                 } ),
+ *                 new OO.ui.MenuOptionWidget( {
+ *                     data: 'poodle',
+ *                     label: 'Standard Poodle'
+ *                 } ),
+ *                 new OO.ui.MenuSectionOptionWidget( {
+ *                     label: 'Cats'
+ *                 } ),
+ *                 new OO.ui.MenuOptionWidget( {
+ *                     data: 'lion',
+ *                     label: 'Lion'
+ *                 } )
+ *             ]
+ *         }
+ *     } );
+ *     $( 'body' ).append( myDropdown.$element );
+ *
+ * @class
+ * @extends OO.ui.DecoratedOptionWidget
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.MenuSectionOptionWidget = function OoUiMenuSectionOptionWidget( config ) {
+       // Parent constructor
+       OO.ui.MenuSectionOptionWidget.parent.call( this, config );
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-menuSectionOptionWidget' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.MenuSectionOptionWidget, OO.ui.DecoratedOptionWidget );
+
+/* Static Properties */
+
+OO.ui.MenuSectionOptionWidget.static.selectable = false;
+
+OO.ui.MenuSectionOptionWidget.static.highlightable = false;
+
+/**
+ * MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and
+ * is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget.
+ * See {@link OO.ui.DropdownWidget DropdownWidget}, {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget},
+ * and {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus.
+ * MenuSelectWidgets themselves are not instantiated directly, rather subclassed
+ * and customized to be opened, closed, and displayed as needed.
+ *
+ * By default, menus are clipped to the visible viewport and are not visible when a user presses the
+ * mouse outside the menu.
+ *
+ * Menus also have support for keyboard interaction:
+ *
+ * - Enter/Return key: choose and select a menu option
+ * - Up-arrow key: highlight the previous menu option
+ * - Down-arrow key: highlight the next menu option
+ * - Esc key: hide the menu
+ *
+ * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
+ *
+ * @class
+ * @extends OO.ui.SelectWidget
+ * @mixins OO.ui.mixin.ClippableElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {OO.ui.TextInputWidget} [input] Text input used to implement option highlighting for menu items that match
+ *  the text the user types. This config is used by {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget}
+ *  and {@link OO.ui.mixin.LookupElement LookupElement}
+ * @cfg {jQuery} [$input] Text input used to implement option highlighting for menu items that match
+ *  the text the user types. This config is used by {@link OO.ui.CapsuleMultiSelectWidget CapsuleMultiSelectWidget}
+ * @cfg {OO.ui.Widget} [widget] Widget associated with the menu's active state. If the user clicks the mouse
+ *  anywhere on the page outside of this widget, the menu is hidden. For example, if there is a button
+ *  that toggles the menu's visibility on click, the menu will be hidden then re-shown when the user clicks
+ *  that button, unless the button (or its parent widget) is passed in here.
+ * @cfg {boolean} [autoHide=true] Hide the menu when the mouse is pressed outside the menu.
+ * @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
+ */
+OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.MenuSelectWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) );
+
+       // Properties
+       this.newItems = null;
+       this.autoHide = config.autoHide === undefined || !!config.autoHide;
+       this.filterFromInput = !!config.filterFromInput;
+       this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
+       this.$widget = config.widget ? config.widget.$element : null;
+       this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
+       this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
+
+       // Initialization
+       this.$element
+               .addClass( 'oo-ui-menuSelectWidget' )
+               .attr( 'role', 'menu' );
+
+       // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
+       // that reference properties not initialized at that time of parent class construction
+       // TODO: Find a better way to handle post-constructor setup
+       this.visible = false;
+       this.$element.addClass( 'oo-ui-element-hidden' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.MenuSelectWidget, OO.ui.SelectWidget );
+OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement );
+
+/* Methods */
+
+/**
+ * Handles document mouse down events.
+ *
+ * @protected
+ * @param {jQuery.Event} e Key down event
+ */
+OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
+       if (
+               !OO.ui.contains( this.$element[ 0 ], e.target, true ) &&
+               ( !this.$widget || !OO.ui.contains( this.$widget[ 0 ], e.target, true ) )
+       ) {
+               this.toggle( false );
+       }
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.MenuSelectWidget.prototype.onKeyDown = function ( e ) {
+       var currentItem = this.getHighlightedItem() || this.getSelectedItem();
+
+       if ( !this.isDisabled() && this.isVisible() ) {
+               switch ( e.keyCode ) {
+                       case OO.ui.Keys.LEFT:
+                       case OO.ui.Keys.RIGHT:
+                               // Do nothing if a text field is associated, arrow keys will be handled natively
+                               if ( !this.$input ) {
+                                       OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
+                               }
+                               break;
+                       case OO.ui.Keys.ESCAPE:
+                       case OO.ui.Keys.TAB:
+                               if ( currentItem ) {
+                                       currentItem.setHighlighted( false );
+                               }
+                               this.toggle( false );
+                               // Don't prevent tabbing away, prevent defocusing
+                               if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
+                                       e.preventDefault();
+                                       e.stopPropagation();
+                               }
+                               break;
+                       default:
+                               OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
+                               return;
+               }
+       }
+};
+
+/**
+ * Update menu item visibility after input changes.
+ * @protected
+ */
+OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
+       var i, item,
+               len = this.items.length,
+               showAll = !this.isVisible(),
+               filter = showAll ? null : this.getItemMatcher( this.$input.val() );
+
+       for ( i = 0; i < len; i++ ) {
+               item = this.items[ i ];
+               if ( item instanceof OO.ui.OptionWidget ) {
+                       item.toggle( showAll || filter( item ) );
+               }
+       }
+
+       // Reevaluate clipping
+       this.clip();
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.MenuSelectWidget.prototype.bindKeyDownListener = function () {
+       if ( this.$input ) {
+               this.$input.on( 'keydown', this.onKeyDownHandler );
+       } else {
+               OO.ui.MenuSelectWidget.parent.prototype.bindKeyDownListener.call( this );
+       }
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.MenuSelectWidget.prototype.unbindKeyDownListener = function () {
+       if ( this.$input ) {
+               this.$input.off( 'keydown', this.onKeyDownHandler );
+       } else {
+               OO.ui.MenuSelectWidget.parent.prototype.unbindKeyDownListener.call( this );
+       }
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.MenuSelectWidget.prototype.bindKeyPressListener = function () {
+       if ( this.$input ) {
+               if ( this.filterFromInput ) {
+                       this.$input.on( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
+               }
+       } else {
+               OO.ui.MenuSelectWidget.parent.prototype.bindKeyPressListener.call( this );
+       }
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.MenuSelectWidget.prototype.unbindKeyPressListener = function () {
+       if ( this.$input ) {
+               if ( this.filterFromInput ) {
+                       this.$input.off( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
+                       this.updateItemVisibility();
+               }
+       } else {
+               OO.ui.MenuSelectWidget.parent.prototype.unbindKeyPressListener.call( this );
+       }
+};
+
+/**
+ * Choose an item.
+ *
+ * When a user chooses an item, the menu is closed.
+ *
+ * Note that ‘choose’ should never be modified programmatically. A user can choose an option with the keyboard
+ * or mouse and it becomes selected. To select an item programmatically, use the #selectItem method.
+ * @param {OO.ui.OptionWidget} item Item to choose
+ * @chainable
+ */
+OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
+       OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
+       this.toggle( false );
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
+       var i, len, item;
+
+       // Parent method
+       OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index );
+
+       // Auto-initialize
+       if ( !this.newItems ) {
+               this.newItems = [];
+       }
+
+       for ( i = 0, len = items.length; i < len; i++ ) {
+               item = items[ i ];
+               if ( this.isVisible() ) {
+                       // Defer fitting label until item has been attached
+                       item.fitLabel();
+               } else {
+                       this.newItems.push( item );
+               }
+       }
+
+       // Reevaluate clipping
+       this.clip();
+
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) {
+       // Parent method
+       OO.ui.MenuSelectWidget.parent.prototype.removeItems.call( this, items );
+
+       // Reevaluate clipping
+       this.clip();
+
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.MenuSelectWidget.prototype.clearItems = function () {
+       // Parent method
+       OO.ui.MenuSelectWidget.parent.prototype.clearItems.call( this );
+
+       // Reevaluate clipping
+       this.clip();
+
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
+       var i, len, change;
+
+       visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
+       change = visible !== this.isVisible();
+
+       // Parent method
+       OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
+
+       if ( change ) {
+               if ( visible ) {
+                       this.bindKeyDownListener();
+                       this.bindKeyPressListener();
+
+                       if ( this.newItems && this.newItems.length ) {
+                               for ( i = 0, len = this.newItems.length; i < len; i++ ) {
+                                       this.newItems[ i ].fitLabel();
+                               }
+                               this.newItems = null;
+                       }
+                       this.toggleClipping( true );
+
+                       // Auto-hide
+                       if ( this.autoHide ) {
+                               this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
+                       }
+               } else {
+                       this.unbindKeyDownListener();
+                       this.unbindKeyPressListener();
+                       this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
+                       this.toggleClipping( false );
+               }
+       }
+
+       return this;
+};
+
+/**
+ * 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.
+ *
+ * If you want to use this within a HTML form, such as a OO.ui.FormLayout, use
+ * OO.ui.DropdownInputWidget instead.
+ *
+ *     @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'
+ *                 } )
+ *             ]
+ *         }
+ *     } );
+ *
+ *     $( 'body' ).append( dropDown.$element );
+ *
+ *     dropDown.getMenu().selectItemByData( 'b' );
+ *
+ *     dropDown.getMenu().getSelectedItem().getData(); // returns 'b'
+ *
+ * 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
+ * @mixins OO.ui.mixin.IconElement
+ * @mixins OO.ui.mixin.IndicatorElement
+ * @mixins OO.ui.mixin.LabelElement
+ * @mixins OO.ui.mixin.TitledElement
+ * @mixins OO.ui.mixin.TabIndexedElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {Object} [menu] Configuration options to pass to {@link OO.ui.FloatingMenuSelectWidget menu select widget}
+ * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
+ *  the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
+ *  containing `<div>` and has a larger area. By default, the menu uses relative positioning.
+ */
+OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
+       // Configuration initialization
+       config = $.extend( { indicator: 'down' }, config );
+
+       // Parent constructor
+       OO.ui.DropdownWidget.parent.call( this, config );
+
+       // Properties (must be set before TabIndexedElement constructor call)
+       this.$handle = this.$( '<span>' );
+       this.$overlay = config.$overlay || this.$element;
+
+       // Mixin constructors
+       OO.ui.mixin.IconElement.call( this, config );
+       OO.ui.mixin.IndicatorElement.call( this, config );
+       OO.ui.mixin.LabelElement.call( this, config );
+       OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
+       OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) );
+
+       // Properties
+       this.menu = new OO.ui.FloatingMenuSelectWidget( $.extend( {
+               widget: this,
+               $container: this.$element
+       }, config.menu ) );
+
+       // Events
+       this.$handle.on( {
+               click: this.onClick.bind( this ),
+               keypress: this.onKeyPress.bind( this )
+       } );
+       this.menu.connect( this, { select: 'onMenuSelect' } );
+
+       // Initialization
+       this.$handle
+               .addClass( 'oo-ui-dropdownWidget-handle' )
+               .append( this.$icon, this.$label, this.$indicator );
+       this.$element
+               .addClass( 'oo-ui-dropdownWidget' )
+               .append( this.$handle );
+       this.$overlay.append( this.menu.$element );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
+OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
+OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
+OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
+OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
+OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
+
+/* Methods */
+
+/**
+ * Get the menu.
+ *
+ * @return {OO.ui.MenuSelectWidget} Menu of widget
+ */
+OO.ui.DropdownWidget.prototype.getMenu = function () {
+       return this.menu;
+};
+
+/**
+ * Handles menu select events.
+ *
+ * @private
+ * @param {OO.ui.MenuOptionWidget} item Selected menu item
+ */
+OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
+       var selectedLabel;
+
+       if ( !item ) {
+               this.setLabel( null );
+               return;
+       }
+
+       selectedLabel = item.getLabel();
+
+       // If the label is a DOM element, clone it, because setLabel will append() it
+       if ( selectedLabel instanceof jQuery ) {
+               selectedLabel = selectedLabel.clone();
+       }
+
+       this.setLabel( selectedLabel );
+};
+
+/**
+ * Handle mouse click events.
+ *
+ * @private
+ * @param {jQuery.Event} e Mouse click event
+ */
+OO.ui.DropdownWidget.prototype.onClick = function ( e ) {
+       if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
+               this.menu.toggle();
+       }
+       return false;
+};
+
+/**
+ * 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 && !this.menu.isVisible() ) || e.which === OO.ui.Keys.ENTER )
+       ) {
+               this.menu.toggle();
+               return false;
+       }
+};
+
+/**
+ * RadioOptionWidget is an option widget that looks like a radio button.
+ * The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
+ * Please see the [OOjs UI documentation on MediaWiki] [1] for more information.
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Button_selects_and_option
+ *
+ * @class
+ * @extends OO.ui.OptionWidget
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Properties (must be done before parent constructor which calls #setDisabled)
+       this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
+
+       // Parent constructor
+       OO.ui.RadioOptionWidget.parent.call( this, config );
+
+       // Events
+       this.radio.$input.on( 'focus', this.onInputFocus.bind( this ) );
+
+       // Initialization
+       // Remove implicit role, we're handling it ourselves
+       this.radio.$input.attr( 'role', 'presentation' );
+       this.$element
+               .addClass( 'oo-ui-radioOptionWidget' )
+               .attr( 'role', 'radio' )
+               .attr( 'aria-checked', 'false' )
+               .removeAttr( 'aria-selected' )
+               .prepend( this.radio.$element );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
+
+/* Static Properties */
+
+OO.ui.RadioOptionWidget.static.highlightable = false;
+
+OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
+
+OO.ui.RadioOptionWidget.static.pressable = false;
+
+OO.ui.RadioOptionWidget.static.tagName = 'label';
+
+/* Methods */
+
+/**
+ * @param {jQuery.Event} e Focus event
+ * @private
+ */
+OO.ui.RadioOptionWidget.prototype.onInputFocus = function () {
+       this.radio.$input.blur();
+       this.$element.parent().focus();
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
+       OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
+
+       this.radio.setSelected( state );
+       this.$element
+               .attr( 'aria-checked', state.toString() )
+               .removeAttr( 'aria-selected' );
+
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
+       OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled );
+
+       this.radio.setDisabled( this.isDisabled() );
+
+       return this;
+};
+
+/**
+ * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
+ * options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
+ * an interface for adding, removing and selecting options.
+ * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
+ *
+ * If you want to use this within a HTML form, such as a OO.ui.FormLayout, use
+ * OO.ui.RadioSelectInputWidget instead.
+ *
+ *     @example
+ *     // A RadioSelectWidget with RadioOptions.
+ *     var option1 = new OO.ui.RadioOptionWidget( {
+ *         data: 'a',
+ *         label: 'Selected radio option'
+ *     } );
+ *
+ *     var option2 = new OO.ui.RadioOptionWidget( {
+ *         data: 'b',
+ *         label: 'Unselected radio option'
+ *     } );
+ *
+ *     var radioSelect=new OO.ui.RadioSelectWidget( {
+ *         items: [ option1, option2 ]
+ *      } );
+ *
+ *     // Select 'option 1' using the RadioSelectWidget's selectItem() method.
+ *     radioSelect.selectItem( option1 );
+ *
+ *     $( 'body' ).append( radioSelect.$element );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
+
+ *
+ * @class
+ * @extends OO.ui.SelectWidget
+ * @mixins OO.ui.mixin.TabIndexedElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
+       // Parent constructor
+       OO.ui.RadioSelectWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.TabIndexedElement.call( this, config );
+
+       // Events
+       this.$element.on( {
+               focus: this.bindKeyDownListener.bind( this ),
+               blur: this.unbindKeyDownListener.bind( this )
+       } );
+
+       // Initialization
+       this.$element
+               .addClass( 'oo-ui-radioSelectWidget' )
+               .attr( 'role', 'radiogroup' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
+OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
+
+/**
+ * Element that will stick under a specified container, even when it is inserted elsewhere in the
+ * document (for example, in a OO.ui.Window's $overlay).
+ *
+ * The elements's position is automatically calculated and maintained when window is resized or the
+ * page is scrolled. If you reposition the container manually, you have to call #position to make
+ * sure the element is still placed correctly.
+ *
+ * As positioning is only possible when both the element and the container are attached to the DOM
+ * and visible, it's only done after you call #togglePositioning. You might want to do this inside
+ * the #toggle method to display a floating popup, for example.
+ *
+ * @abstract
+ * @class
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
+ * @cfg {jQuery} [$floatableContainer] Node to position below
+ */
+OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Properties
+       this.$floatable = null;
+       this.$floatableContainer = null;
+       this.$floatableWindow = null;
+       this.$floatableClosestScrollable = null;
+       this.onFloatableScrollHandler = this.position.bind( this );
+       this.onFloatableWindowResizeHandler = this.position.bind( this );
+
+       // Initialization
+       this.setFloatableContainer( config.$floatableContainer );
+       this.setFloatableElement( config.$floatable || this.$element );
+};
+
+/* Methods */
+
+/**
+ * Set floatable element.
+ *
+ * If an element is already set, it will be cleaned up before setting up the new element.
+ *
+ * @param {jQuery} $floatable Element to make floatable
+ */
+OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
+       if ( this.$floatable ) {
+               this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
+               this.$floatable.css( { left: '', top: '' } );
+       }
+
+       this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
+       this.position();
+};
+
+/**
+ * Set floatable container.
+ *
+ * The element will be always positioned under the specified container.
+ *
+ * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
+ */
+OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
+       this.$floatableContainer = $floatableContainer;
+       if ( this.$floatable ) {
+               this.position();
+       }
+};
+
+/**
+ * Toggle positioning.
+ *
+ * Do not turn positioning on until after the element is attached to the DOM and visible.
+ *
+ * @param {boolean} [positioning] Enable positioning, omit to toggle
+ * @chainable
+ */
+OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
+       var closestScrollableOfContainer, closestScrollableOfFloatable;
+
+       positioning = positioning === undefined ? !this.positioning : !!positioning;
+
+       if ( this.positioning !== positioning ) {
+               this.positioning = positioning;
+
+               closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer( this.$floatableContainer[ 0 ] );
+               closestScrollableOfFloatable = OO.ui.Element.static.getClosestScrollableContainer( this.$floatable[ 0 ] );
+               if ( closestScrollableOfContainer !== closestScrollableOfFloatable ) {
+                       // If the scrollable is the root, we have to listen to scroll events
+                       // on the window because of browser inconsistencies (or do we? someone should verify this)
+                       if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
+                               closestScrollableOfContainer = OO.ui.Element.static.getWindow( closestScrollableOfContainer );
+                       }
+               }
+
+               if ( positioning ) {
+                       this.$floatableWindow = $( this.getElementWindow() );
+                       this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
+
+                       if ( closestScrollableOfContainer !== closestScrollableOfFloatable ) {
+                               this.$floatableClosestScrollable = $( closestScrollableOfContainer );
+                               this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
+                       }
+
+                       // Initial position after visible
+                       this.position();
+               } else {
+                       if ( this.$floatableWindow ) {
+                               this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
+                               this.$floatableWindow = null;
+                       }
+
+                       if ( this.$floatableClosestScrollable ) {
+                               this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
+                               this.$floatableClosestScrollable = null;
+                       }
+
+                       this.$floatable.css( { left: '', top: '' } );
+               }
+       }
+
+       return this;
+};
+
+/**
+ * Position the floatable below its container.
+ *
+ * This should only be done when both of them are attached to the DOM and visible.
+ *
+ * @chainable
+ */
+OO.ui.mixin.FloatableElement.prototype.position = function () {
+       var pos;
+
+       if ( !this.positioning ) {
+               return this;
+       }
+
+       pos = OO.ui.Element.static.getRelativePosition( this.$floatableContainer, this.$floatable.offsetParent() );
+
+       // Position under container
+       pos.top += this.$floatableContainer.height();
+       this.$floatable.css( pos );
+
+       // We updated the position, so re-evaluate the clipping state.
+       // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
+       // will not notice the need to update itself.)
+       // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here. Why does
+       // it not listen to the right events in the right places?
+       if ( this.clip ) {
+               this.clip();
+       }
+
+       return this;
+};
+
+/**
+ * FloatingMenuSelectWidget is a menu that will stick under a specified
+ * container, even when it is inserted elsewhere in the document (for example,
+ * in a OO.ui.Window's $overlay). This is sometimes necessary to prevent the
+ * menu from being clipped too aggresively.
+ *
+ * The menu's position is automatically calculated and maintained when the menu
+ * is toggled or the window is resized.
+ *
+ * See OO.ui.ComboBoxInputWidget for an example of a widget that uses this class.
+ *
+ * @class
+ * @extends OO.ui.MenuSelectWidget
+ * @mixins OO.ui.mixin.FloatableElement
+ *
+ * @constructor
+ * @param {OO.ui.Widget} [inputWidget] Widget to provide the menu for.
+ *   Deprecated, omit this parameter and specify `$container` instead.
+ * @param {Object} [config] Configuration options
+ * @cfg {jQuery} [$container=inputWidget.$element] Element to render menu under
+ */
+OO.ui.FloatingMenuSelectWidget = function OoUiFloatingMenuSelectWidget( inputWidget, config ) {
+       // Allow 'inputWidget' parameter and config for backwards compatibility
+       if ( OO.isPlainObject( inputWidget ) && config === undefined ) {
+               config = inputWidget;
+               inputWidget = config.inputWidget;
+       }
+
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.FloatingMenuSelectWidget.parent.call( this, config );
+
+       // Properties (must be set before mixin constructors)
+       this.inputWidget = inputWidget; // For backwards compatibility
+       this.$container = config.$container || this.inputWidget.$element;
+
+       // Mixins constructors
+       OO.ui.mixin.FloatableElement.call( this, $.extend( {}, config, { $floatableContainer: this.$container } ) );
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-floatingMenuSelectWidget' );
+       // For backwards compatibility
+       this.$element.addClass( 'oo-ui-textInputMenuSelectWidget' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.FloatingMenuSelectWidget, OO.ui.MenuSelectWidget );
+OO.mixinClass( OO.ui.FloatingMenuSelectWidget, OO.ui.mixin.FloatableElement );
+
+// For backwards compatibility
+OO.ui.TextInputMenuSelectWidget = OO.ui.FloatingMenuSelectWidget;
+
+/* Methods */
+
+/**
+ * @inheritdoc
+ */
+OO.ui.FloatingMenuSelectWidget.prototype.toggle = function ( visible ) {
+       var change;
+       visible = visible === undefined ? !this.isVisible() : !!visible;
+       change = visible !== this.isVisible();
+
+       if ( change && visible ) {
+               // Make sure the width is set before the parent method runs.
+               this.setIdealSize( this.$container.width() );
+       }
+
+       // Parent method
+       // This will call this.clip(), which is nonsensical since we're not positioned yet...
+       OO.ui.FloatingMenuSelectWidget.parent.prototype.toggle.call( this, visible );
+
+       if ( change ) {
+               this.togglePositioning( this.isVisible() );
+       }
+
+       return this;
+};
+
+/**
+ * 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.mixin.FlaggedElement
+ * @mixins OO.ui.mixin.TabIndexedElement
+ * @mixins OO.ui.mixin.TitledElement
+ * @mixins OO.ui.mixin.AccessKeyedElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
+ * @cfg {string} [value=''] The value of the input.
+ * @cfg {string} [dir] The directionality of the input (ltr/rtl).
+ * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the value of an input
+ *  before it is accepted.
+ */
+OO.ui.InputWidget = function OoUiInputWidget( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.InputWidget.parent.call( this, config );
+
+       // Properties
+       this.$input = this.getInputElement( config );
+       this.value = '';
+       this.inputFilter = config.inputFilter;
+
+       // Mixin constructors
+       OO.ui.mixin.FlaggedElement.call( this, config );
+       OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
+       OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
+       OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$input } ) );
+
+       // Events
+       this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
+
+       // Initialization
+       this.$input
+               .addClass( 'oo-ui-inputWidget-input' )
+               .attr( 'name', config.name )
+               .prop( 'disabled', this.isDisabled() );
+       this.$element
+               .addClass( 'oo-ui-inputWidget' )
+               .append( this.$input );
+       this.setValue( config.value );
+       this.setAccessKey( config.accessKey );
+       if ( config.dir ) {
+               this.setDir( config.dir );
+       }
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
+OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.FlaggedElement );
+OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
+OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
+OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
+
+/* Static Properties */
+
+OO.ui.InputWidget.static.supportsSimpleLabel = true;
+
+/* Static Methods */
+
+/**
+ * @inheritdoc
+ */
+OO.ui.InputWidget.static.reusePreInfuseDOM = function ( node, config ) {
+       config = OO.ui.InputWidget.parent.static.reusePreInfuseDOM( node, config );
+       // Reusing $input lets browsers preserve inputted values across page reloads (T114134)
+       config.$input = $( node ).find( '.oo-ui-inputWidget-input' );
+       return config;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
+       var state = OO.ui.InputWidget.parent.static.gatherPreInfuseState( node, config );
+       state.value = config.$input.val();
+       // Might be better in TabIndexedElement, but it's awkward to do there because mixins are awkward
+       state.focus = config.$input.is( ':focus' );
+       return state;
+};
+
+/* Events */
+
+/**
+ * @event change
+ *
+ * A change event is emitted when the value of the input changes.
+ *
+ * @param {string} value
+ */
+
+/* Methods */
+
+/**
+ * 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).
+ *
+ * @protected
+ * @param {Object} config Configuration options
+ * @return {jQuery} Input element
+ */
+OO.ui.InputWidget.prototype.getInputElement = function ( config ) {
+       // See #reusePreInfuseDOM about config.$input
+       return config.$input || $( '<input>' );
+};
+
+/**
+ * Handle potentially value-changing events.
+ *
+ * @private
+ * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event
+ */
+OO.ui.InputWidget.prototype.onEdit = function () {
+       var widget = this;
+       if ( !this.isDisabled() ) {
+               // Allow the stack to clear so the value will be updated
+               setTimeout( function () {
+                       widget.setValue( widget.$input.val() );
+               } );
+       }
+};
+
+/**
+ * Get the value of the input.
+ *
+ * @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;
+};
+
+/**
+ * Set the directionality of the input, either RTL (right-to-left) or LTR (left-to-right).
+ *
+ * @deprecated since v0.13.1, use #setDir directly
+ * @param {boolean} isRTL Directionality is right-to-left
+ * @chainable
+ */
+OO.ui.InputWidget.prototype.setRTL = function ( isRTL ) {
+       this.setDir( isRTL ? 'rtl' : 'ltr' );
+       return this;
+};
+
+/**
+ * Set the directionality of the input.
+ *
+ * @param {string} dir Text directionality: 'ltr', 'rtl' or 'auto'
+ * @chainable
+ */
+OO.ui.InputWidget.prototype.setDir = function ( dir ) {
+       this.$input.prop( 'dir', dir );
+       return this;
+};
+
+/**
+ * Set the value of the input.
+ *
+ * @param {string} value New value
+ * @fires change
+ * @chainable
+ */
+OO.ui.InputWidget.prototype.setValue = function ( value ) {
+       value = this.cleanUpValue( value );
+       // Update the DOM if it has changed. Note that with cleanUpValue, it
+       // is possible for the DOM value to change without this.value changing.
+       if ( this.$input.val() !== value ) {
+               this.$input.val( value );
+       }
+       if ( this.value !== value ) {
+               this.value = value;
+               this.emit( 'change', this.value );
+       }
+       return this;
+};
+
+/**
+ * Set the input's access key.
+ * FIXME: This is the same code as in OO.ui.mixin.ButtonElement, maybe find a better place for it?
+ *
+ * @param {string} accessKey Input's access key, use empty string to remove
+ * @chainable
+ */
+OO.ui.InputWidget.prototype.setAccessKey = function ( accessKey ) {
+       accessKey = typeof accessKey === 'string' && accessKey.length ? accessKey : null;
+
+       if ( this.accessKey !== accessKey ) {
+               if ( this.$input ) {
+                       if ( accessKey !== null ) {
+                               this.$input.attr( 'accesskey', accessKey );
+                       } else {
+                               this.$input.removeAttr( 'accesskey' );
+                       }
+               }
+               this.accessKey = accessKey;
+       }
+
+       return this;
+};
+
+/**
+ * Clean up incoming value.
+ *
+ * Ensures value is a string, and converts undefined and null to empty string.
+ *
+ * @private
+ * @param {string} value Original value
+ * @return {string} Cleaned up value
+ */
+OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
+       if ( value === undefined || value === null ) {
+               return '';
+       } else if ( this.inputFilter ) {
+               return this.inputFilter( String( value ) );
+       } else {
+               return String( value );
+       }
+};
+
+/**
+ * Simulate the behavior of clicking on a label bound to this input. This method is only called by
+ * {@link OO.ui.LabelWidget LabelWidget} and {@link OO.ui.FieldLayout FieldLayout}. It should not be
+ * called directly.
+ */
+OO.ui.InputWidget.prototype.simulateLabelClick = function () {
+       if ( !this.isDisabled() ) {
+               if ( this.$input.is( ':checkbox, :radio' ) ) {
+                       this.$input.click();
+               }
+               if ( this.$input.is( ':input' ) ) {
+                       this.$input[ 0 ].focus();
+               }
+       }
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
+       OO.ui.InputWidget.parent.prototype.setDisabled.call( this, state );
+       if ( this.$input ) {
+               this.$input.prop( 'disabled', this.isDisabled() );
+       }
+       return this;
+};
+
+/**
+ * Focus the input.
+ *
+ * @chainable
+ */
+OO.ui.InputWidget.prototype.focus = function () {
+       this.$input[ 0 ].focus();
+       return this;
+};
+
+/**
+ * Blur the input.
+ *
+ * @chainable
+ */
+OO.ui.InputWidget.prototype.blur = function () {
+       this.$input[ 0 ].blur();
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
+       OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
+       if ( state.value !== undefined && state.value !== this.getValue() ) {
+               this.setValue( state.value );
+       }
+       if ( state.focus ) {
+               this.focus();
+       }
+};
+
+/**
+ * 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 `<button/>` (the default) or an HTML `<input/>` tags. See the
+ * [OOjs UI documentation on MediaWiki] [1] for more information.
+ *
+ *     @example
+ *     // A ButtonInputWidget rendered as an HTML button, the default.
+ *     var button = new OO.ui.ButtonInputWidget( {
+ *         label: 'Input button',
+ *         icon: 'check',
+ *         value: 'check'
+ *     } );
+ *     $( 'body' ).append( button.$element );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs#Button_inputs
+ *
+ * @class
+ * @extends OO.ui.InputWidget
+ * @mixins OO.ui.mixin.ButtonElement
+ * @mixins OO.ui.mixin.IconElement
+ * @mixins OO.ui.mixin.IndicatorElement
+ * @mixins OO.ui.mixin.LabelElement
+ * @mixins OO.ui.mixin.TitledElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {string} [type='button'] The value of the HTML `'type'` attribute: 'button', 'submit' or 'reset'.
+ * @cfg {boolean} [useInputTag=false] Use an `<input/>` tag instead of a `<button/>` tag, the default.
+ *  Widgets configured to be an `<input/>` do not support {@link #icon icons} and {@link #indicator indicators},
+ *  non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should only
+ *  be set to `true` when there’s need to support IE6 in a form with multiple buttons.
+ */
+OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
+       // Configuration initialization
+       config = $.extend( { type: 'button', useInputTag: false }, config );
+
+       // Properties (must be set before parent constructor, which calls #setValue)
+       this.useInputTag = config.useInputTag;
+
+       // Parent constructor
+       OO.ui.ButtonInputWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.ButtonElement.call( this, $.extend( {}, config, { $button: this.$input } ) );
+       OO.ui.mixin.IconElement.call( this, config );
+       OO.ui.mixin.IndicatorElement.call( this, config );
+       OO.ui.mixin.LabelElement.call( this, config );
+       OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
+
+       // Initialization
+       if ( !config.useInputTag ) {
+               this.$input.append( this.$icon, this.$label, this.$indicator );
+       }
+       this.$element.addClass( 'oo-ui-buttonInputWidget' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
+OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
+OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
+OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
+OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
+OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.TitledElement );
+
+/* Static Properties */
+
+/**
+ * Disable generating `<label>` elements for buttons. One would very rarely need additional label
+ * for a button, and it's already a big clickable target, and it causes unexpected rendering.
+ */
+OO.ui.ButtonInputWidget.static.supportsSimpleLabel = false;
+
+/* Methods */
+
+/**
+ * @inheritdoc
+ * @protected
+ */
+OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
+       var type;
+       // See InputWidget#reusePreInfuseDOM about config.$input
+       if ( config.$input ) {
+               return config.$input.empty();
+       }
+       type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? config.type : 'button';
+       return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
+};
+
+/**
+ * Set label value.
+ *
+ * If #useInputTag is `true`, the label is set as the `value` of the `<input/>` tag.
+ *
+ * @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
+ *  text, or `null` for no label
+ * @chainable
+ */
+OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
+       OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
+
+       if ( this.useInputTag ) {
+               if ( typeof label === 'function' ) {
+                       label = OO.ui.resolveMsg( label );
+               }
+               if ( label instanceof jQuery ) {
+                       label = label.text();
+               }
+               if ( !label ) {
+                       label = '';
+               }
+               this.$input.val( label );
+       }
+
+       return this;
+};
+
+/**
+ * Set the value of the input.
+ *
+ * This method is disabled for button inputs configured as {@link #useInputTag <input/> tags}, as
+ * they do not support {@link #value values}.
+ *
+ * @param {string} value New value
+ * @chainable
+ */
+OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
+       if ( !this.useInputTag ) {
+               OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value );
+       }
+       return this;
+};
+
+/**
+ * CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
+ * Note that these {@link OO.ui.InputWidget input widgets} are best laid out
+ * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
+ * alignment. For more information, please see the [OOjs UI documentation on MediaWiki][1].
+ *
+ * This widget can be used inside a HTML form, such as a OO.ui.FormLayout.
+ *
+ *     @example
+ *     // An example of selected, unselected, and disabled checkbox inputs
+ *     var checkbox1=new OO.ui.CheckboxInputWidget( {
+ *          value: 'a',
+ *          selected: true
+ *     } );
+ *     var checkbox2=new OO.ui.CheckboxInputWidget( {
+ *         value: 'b'
+ *     } );
+ *     var checkbox3=new OO.ui.CheckboxInputWidget( {
+ *         value:'c',
+ *         disabled: true
+ *     } );
+ *     // Create a fieldset layout with fields for each checkbox.
+ *     var fieldset = new OO.ui.FieldsetLayout( {
+ *         label: 'Checkboxes'
+ *     } );
+ *     fieldset.addItems( [
+ *         new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
+ *         new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
+ *         new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
+ *     ] );
+ *     $( 'body' ).append( fieldset.$element );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
+ *
+ * @class
+ * @extends OO.ui.InputWidget
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {boolean} [selected=false] Select the checkbox initially. By default, the checkbox is not selected.
+ */
+OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.CheckboxInputWidget.parent.call( this, config );
+
+       // Initialization
+       this.$element
+               .addClass( 'oo-ui-checkboxInputWidget' )
+               // Required for pretty styling in MediaWiki theme
+               .append( $( '<span>' ) );
+       this.setSelected( config.selected !== undefined ? config.selected : false );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
+
+/* Static Methods */
+
+/**
+ * @inheritdoc
+ */
+OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config ) {
+       var state = OO.ui.CheckboxInputWidget.parent.static.gatherPreInfuseState( node, config );
+       state.checked = config.$input.prop( 'checked' );
+       return state;
+};
+
+/* Methods */
+
+/**
+ * @inheritdoc
+ * @protected
+ */
+OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
+       return $( '<input type="checkbox" />' );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
+       var widget = this;
+       if ( !this.isDisabled() ) {
+               // Allow the stack to clear so the value will be updated
+               setTimeout( function () {
+                       widget.setSelected( widget.$input.prop( 'checked' ) );
+               } );
+       }
+};
+
+/**
+ * Set selection state of this checkbox.
+ *
+ * @param {boolean} state `true` for selected
+ * @chainable
+ */
+OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state ) {
+       state = !!state;
+       if ( this.selected !== state ) {
+               this.selected = state;
+               this.$input.prop( 'checked', this.selected );
+               this.emit( 'change', this.selected );
+       }
+       return this;
+};
+
+/**
+ * Check if this checkbox is selected.
+ *
+ * @return {boolean} Checkbox is selected
+ */
+OO.ui.CheckboxInputWidget.prototype.isSelected = 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 selected = this.$input.prop( 'checked' );
+       if ( this.selected !== selected ) {
+               this.setSelected( selected );
+       }
+       return this.selected;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
+       OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
+       if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
+               this.setSelected( state.checked );
+       }
+};
+
+/**
+ * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
+ * within a HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
+ * of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for
+ * more information about input widgets.
+ *
+ * A DropdownInputWidget always has a value (one of the options is always selected), unless there
+ * are no options. If no `value` configuration option is provided, the first option is selected.
+ * If you need a state representing no value (no option being selected), use a DropdownWidget.
+ *
+ * This and OO.ui.RadioSelectInputWidget support the same configuration options.
+ *
+ *     @example
+ *     // Example: A DropdownInputWidget with three options
+ *     var dropdownInput = new OO.ui.DropdownInputWidget( {
+ *         options: [
+ *             { data: 'a', label: 'First' },
+ *             { data: 'b', label: 'Second'},
+ *             { data: 'c', label: 'Third' }
+ *         ]
+ *     } );
+ *     $( 'body' ).append( dropdownInput.$element );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
+ *
+ * @class
+ * @extends OO.ui.InputWidget
+ * @mixins OO.ui.mixin.TitledElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
+ * @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
+ */
+OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Properties (must be done before parent constructor which calls #setDisabled)
+       this.dropdownWidget = new OO.ui.DropdownWidget( config.dropdown );
+
+       // Parent constructor
+       OO.ui.DropdownInputWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.TitledElement.call( this, config );
+
+       // Events
+       this.dropdownWidget.getMenu().connect( this, { select: 'onMenuSelect' } );
+
+       // Initialization
+       this.setOptions( config.options || [] );
+       this.$element
+               .addClass( 'oo-ui-dropdownInputWidget' )
+               .append( this.dropdownWidget.$element );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
+OO.mixinClass( OO.ui.DropdownInputWidget, OO.ui.mixin.TitledElement );
+
+/* Methods */
+
+/**
+ * @inheritdoc
+ * @protected
+ */
+OO.ui.DropdownInputWidget.prototype.getInputElement = function ( config ) {
+       // See InputWidget#reusePreInfuseDOM about config.$input
+       if ( config.$input ) {
+               return config.$input.addClass( 'oo-ui-element-hidden' );
+       }
+       return $( '<input type="hidden">' );
+};
+
+/**
+ * Handles menu select events.
+ *
+ * @private
+ * @param {OO.ui.MenuOptionWidget} item Selected menu item
+ */
+OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
+       this.setValue( item.getData() );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
+       value = this.cleanUpValue( value );
+       this.dropdownWidget.getMenu().selectItemByData( value );
+       OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
+       this.dropdownWidget.setDisabled( state );
+       OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state );
+       return this;
+};
+
+/**
+ * Set the options available for this input.
+ *
+ * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
+ * @chainable
+ */
+OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
+       var
+               value = this.getValue(),
+               widget = this;
+
+       // Rebuild the dropdown menu
+       this.dropdownWidget.getMenu()
+               .clearItems()
+               .addItems( options.map( function ( opt ) {
+                       var optValue = widget.cleanUpValue( opt.data );
+                       return new OO.ui.MenuOptionWidget( {
+                               data: optValue,
+                               label: opt.label !== undefined ? opt.label : optValue
+                       } );
+               } ) );
+
+       // Restore the previous value, or reset to something sensible
+       if ( this.dropdownWidget.getMenu().getItemFromData( value ) ) {
+               // Previous value is still available, ensure consistency with the dropdown
+               this.setValue( value );
+       } else {
+               // No longer valid, reset
+               if ( options.length ) {
+                       this.setValue( options[ 0 ].data );
+               }
+       }
+
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.DropdownInputWidget.prototype.focus = function () {
+       this.dropdownWidget.getMenu().toggle( true );
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.DropdownInputWidget.prototype.blur = function () {
+       this.dropdownWidget.getMenu().toggle( false );
+       return this;
+};
+
+/**
+ * RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
+ * in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
+ * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
+ * please see the [OOjs UI documentation on MediaWiki][1].
+ *
+ * This widget can be used inside a HTML form, such as a OO.ui.FormLayout.
+ *
+ *     @example
+ *     // An example of selected, unselected, and disabled radio inputs
+ *     var radio1 = new OO.ui.RadioInputWidget( {
+ *         value: 'a',
+ *         selected: true
+ *     } );
+ *     var radio2 = new OO.ui.RadioInputWidget( {
+ *         value: 'b'
+ *     } );
+ *     var radio3 = new OO.ui.RadioInputWidget( {
+ *         value: 'c',
+ *         disabled: true
+ *     } );
+ *     // Create a fieldset layout with fields for each radio button.
+ *     var fieldset = new OO.ui.FieldsetLayout( {
+ *         label: 'Radio inputs'
+ *     } );
+ *     fieldset.addItems( [
+ *         new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
+ *         new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
+ *         new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
+ *     ] );
+ *     $( 'body' ).append( fieldset.$element );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
+ *
+ * @class
+ * @extends OO.ui.InputWidget
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {boolean} [selected=false] Select the radio button initially. By default, the radio button is not selected.
+ */
+OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.RadioInputWidget.parent.call( this, config );
+
+       // Initialization
+       this.$element
+               .addClass( 'oo-ui-radioInputWidget' )
+               // Required for pretty styling in MediaWiki theme
+               .append( $( '<span>' ) );
+       this.setSelected( config.selected !== undefined ? config.selected : false );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
+
+/* Static Methods */
+
+/**
+ * @inheritdoc
+ */
+OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
+       var state = OO.ui.RadioInputWidget.parent.static.gatherPreInfuseState( node, config );
+       state.checked = config.$input.prop( 'checked' );
+       return state;
+};
+
+/* Methods */
+
+/**
+ * @inheritdoc
+ * @protected
+ */
+OO.ui.RadioInputWidget.prototype.getInputElement = function () {
+       return $( '<input type="radio" />' );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.RadioInputWidget.prototype.onEdit = function () {
+       // RadioInputWidget doesn't track its state.
+};
+
+/**
+ * Set selection state of this radio button.
+ *
+ * @param {boolean} state `true` for selected
+ * @chainable
+ */
+OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
+       // RadioInputWidget doesn't track its state.
+       this.$input.prop( 'checked', state );
+       return this;
+};
+
+/**
+ * Check if this radio button is selected.
+ *
+ * @return {boolean} Radio is selected
+ */
+OO.ui.RadioInputWidget.prototype.isSelected = function () {
+       return this.$input.prop( 'checked' );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
+       OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
+       if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
+               this.setSelected( state.checked );
+       }
+};
+
+/**
+ * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be used
+ * within a HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
+ * of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for
+ * more information about input widgets.
+ *
+ * This and OO.ui.DropdownInputWidget support the same configuration options.
+ *
+ *     @example
+ *     // Example: A RadioSelectInputWidget with three options
+ *     var radioSelectInput = new OO.ui.RadioSelectInputWidget( {
+ *         options: [
+ *             { data: 'a', label: 'First' },
+ *             { data: 'b', label: 'Second'},
+ *             { data: 'c', label: 'Third' }
+ *         ]
+ *     } );
+ *     $( 'body' ).append( radioSelectInput.$element );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
+ *
+ * @class
+ * @extends OO.ui.InputWidget
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
+ */
+OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Properties (must be done before parent constructor which calls #setDisabled)
+       this.radioSelectWidget = new OO.ui.RadioSelectWidget();
+
+       // Parent constructor
+       OO.ui.RadioSelectInputWidget.parent.call( this, config );
+
+       // Events
+       this.radioSelectWidget.connect( this, { select: 'onMenuSelect' } );
+
+       // Initialization
+       this.setOptions( config.options || [] );
+       this.$element
+               .addClass( 'oo-ui-radioSelectInputWidget' )
+               .append( this.radioSelectWidget.$element );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
+
+/* Static Properties */
+
+OO.ui.RadioSelectInputWidget.static.supportsSimpleLabel = false;
+
+/* Static Methods */
+
+/**
+ * @inheritdoc
+ */
+OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
+       var state = OO.ui.RadioSelectInputWidget.parent.static.gatherPreInfuseState( node, config );
+       state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
+       return state;
+};
+
+/* Methods */
+
+/**
+ * @inheritdoc
+ * @protected
+ */
+OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
+       return $( '<input type="hidden">' );
+};
+
+/**
+ * Handles menu select events.
+ *
+ * @private
+ * @param {OO.ui.RadioOptionWidget} item Selected menu item
+ */
+OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
+       this.setValue( item.getData() );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
+       value = this.cleanUpValue( value );
+       this.radioSelectWidget.selectItemByData( value );
+       OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value );
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
+       this.radioSelectWidget.setDisabled( state );
+       OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state );
+       return this;
+};
+
+/**
+ * Set the options available for this input.
+ *
+ * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
+ * @chainable
+ */
+OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
+       var
+               value = this.getValue(),
+               widget = this;
+
+       // Rebuild the radioSelect menu
+       this.radioSelectWidget
+               .clearItems()
+               .addItems( options.map( function ( opt ) {
+                       var optValue = widget.cleanUpValue( opt.data );
+                       return new OO.ui.RadioOptionWidget( {
+                               data: optValue,
+                               label: opt.label !== undefined ? opt.label : optValue
+                       } );
+               } ) );
+
+       // Restore the previous value, or reset to something sensible
+       if ( this.radioSelectWidget.getItemFromData( value ) ) {
+               // Previous value is still available, ensure consistency with the radioSelect
+               this.setValue( value );
+       } else {
+               // No longer valid, reset
+               if ( options.length ) {
+                       this.setValue( options[ 0 ].data );
+               }
+       }
+
+       return this;
+};
+
+/**
+ * TextInputWidgets, like HTML text inputs, can be configured with options that customize the
+ * size of the field as well as its presentation. In addition, these widgets can be configured
+ * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an optional
+ * validation-pattern (used to determine if an input value is valid or not) and an input filter,
+ * which modifies incoming values rather than validating them.
+ * Please see the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
+ *
+ * This widget can be used inside a HTML form, such as a OO.ui.FormLayout.
+ *
+ *     @example
+ *     // Example of a text input widget
+ *     var textInput = new OO.ui.TextInputWidget( {
+ *         value: 'Text input'
+ *     } )
+ *     $( 'body' ).append( textInput.$element );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
+ *
+ * @class
+ * @extends OO.ui.InputWidget
+ * @mixins OO.ui.mixin.IconElement
+ * @mixins OO.ui.mixin.IndicatorElement
+ * @mixins OO.ui.mixin.PendingElement
+ * @mixins OO.ui.mixin.LabelElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password', 'search',
+ *  'email' or 'url'. Ignored if `multiline` is true.
+ *
+ *  Some values of `type` result in additional behaviors:
+ *
+ *  - `search`: implies `icon: 'search'` and `indicator: 'clear'`; when clicked, the indicator
+ *    empties the text field
+ * @cfg {string} [placeholder] Placeholder text
+ * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
+ *  instruct the browser to focus this widget.
+ * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input.
+ * @cfg {number} [maxLength] Maximum number of characters allowed in the input.
+ * @cfg {boolean} [multiline=false] Allow multiple lines of text
+ * @cfg {number} [rows] If multiline, number of visible lines in textarea. If used with `autosize`,
+ *  specifies minimum number of rows to display.
+ * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
+ *  Use the #maxRows config to specify a maximum number of displayed rows.
+ * @cfg {boolean} [maxRows] Maximum number of rows to display when #autosize is set to true.
+ *  Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
+ * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
+ *  the value or placeholder text: `'before'` or `'after'`
+ * @cfg {boolean} [required=false] Mark the field as required. Implies `indicator: 'required'`.
+ * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
+ * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
+ *  pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
+ *  (the value must contain only numbers); when RegExp, a regular expression that must match the
+ *  value for it to be considered valid; when Function, a function receiving the value as parameter
+ *  that must return true, or promise resolving to true, for it to be considered valid.
+ */
+OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
+       // Configuration initialization
+       config = $.extend( {
+               type: 'text',
+               labelPosition: 'after'
+       }, config );
+       if ( config.type === 'search' ) {
+               if ( config.icon === undefined ) {
+                       config.icon = 'search';
+               }
+               // indicator: 'clear' is set dynamically later, depending on value
+       }
+       if ( config.required ) {
+               if ( config.indicator === undefined ) {
+                       config.indicator = 'required';
+               }
+       }
+
+       // Parent constructor
+       OO.ui.TextInputWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.IconElement.call( this, config );
+       OO.ui.mixin.IndicatorElement.call( this, config );
+       OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$input } ) );
+       OO.ui.mixin.LabelElement.call( this, config );
+
+       // Properties
+       this.type = this.getSaneType( config );
+       this.readOnly = false;
+       this.multiline = !!config.multiline;
+       this.autosize = !!config.autosize;
+       this.minRows = config.rows !== undefined ? config.rows : '';
+       this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
+       this.validate = null;
+       this.styleHeight = null;
+       this.scrollWidth = null;
+
+       // Clone for resizing
+       if ( this.autosize ) {
+               this.$clone = this.$input
+                       .clone()
+                       .insertAfter( this.$input )
+                       .attr( 'aria-hidden', 'true' )
+                       .addClass( 'oo-ui-element-hidden' );
+       }
+
+       this.setValidation( config.validate );
+       this.setLabelPosition( config.labelPosition );
+
+       // Events
+       this.$input.on( {
+               keypress: this.onKeyPress.bind( this ),
+               blur: this.onBlur.bind( this )
+       } );
+       this.$input.one( {
+               focus: this.onElementAttach.bind( this )
+       } );
+       this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
+       this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
+       this.on( 'labelChange', this.updatePosition.bind( this ) );
+       this.connect( this, {
+               change: 'onChange',
+               disable: 'onDisable'
+       } );
+
+       // Initialization
+       this.$element
+               .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
+               .append( this.$icon, this.$indicator );
+       this.setReadOnly( !!config.readOnly );
+       this.updateSearchIndicator();
+       if ( config.placeholder ) {
+               this.$input.attr( 'placeholder', config.placeholder );
+       }
+       if ( config.maxLength !== undefined ) {
+               this.$input.attr( 'maxlength', config.maxLength );
+       }
+       if ( config.autofocus ) {
+               this.$input.attr( 'autofocus', 'autofocus' );
+       }
+       if ( config.required ) {
+               this.$input.attr( 'required', 'required' );
+               this.$input.attr( 'aria-required', 'true' );
+       }
+       if ( config.autocomplete === false ) {
+               this.$input.attr( 'autocomplete', 'off' );
+               // Turning off autocompletion also disables "form caching" when the user navigates to a
+               // different page and then clicks "Back". Re-enable it when leaving. Borrowed from jQuery UI.
+               $( window ).on( {
+                       beforeunload: function () {
+                               this.$input.removeAttr( 'autocomplete' );
+                       }.bind( this ),
+                       pageshow: function () {
+                               // Browsers don't seem to actually fire this event on "Back", they instead just reload the
+                               // whole page... it shouldn't hurt, though.
+                               this.$input.attr( 'autocomplete', 'off' );
+                       }.bind( this )
+               } );
+       }
+       if ( this.multiline && config.rows ) {
+               this.$input.attr( 'rows', config.rows );
+       }
+       if ( this.label || config.autosize ) {
+               this.installParentChangeDetector();
+       }
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
+OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
+OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
+OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
+OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
+
+/* Static Properties */
+
+OO.ui.TextInputWidget.static.validationPatterns = {
+       'non-empty': /.+/,
+       integer: /^\d+$/
+};
+
+/* Static Methods */
+
+/**
+ * @inheritdoc
+ */
+OO.ui.TextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
+       var state = OO.ui.TextInputWidget.parent.static.gatherPreInfuseState( node, config );
+       if ( config.multiline ) {
+               state.scrollTop = config.$input.scrollTop();
+       }
+       return state;
+};
+
+/* Events */
+
+/**
+ * An `enter` event is emitted when the user presses 'enter' inside the text box.
+ *
+ * Not emitted if the input is multiline.
+ *
+ * @event enter
+ */
+
+/**
+ * A `resize` event is emitted when autosize is set and the widget resizes
+ *
+ * @event resize
+ */
+
+/* Methods */
+
+/**
+ * Handle icon mouse down events.
+ *
+ * @private
+ * @param {jQuery.Event} e Mouse down event
+ * @fires icon
+ */
+OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
+       if ( e.which === OO.ui.MouseButtons.LEFT ) {
+               this.$input[ 0 ].focus();
+               return false;
+       }
+};
+
+/**
+ * Handle indicator mouse down events.
+ *
+ * @private
+ * @param {jQuery.Event} e Mouse down event
+ * @fires indicator
+ */
+OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
+       if ( e.which === OO.ui.MouseButtons.LEFT ) {
+               if ( this.type === 'search' ) {
+                       // Clear the text field
+                       this.setValue( '' );
+               }
+               this.$input[ 0 ].focus();
+               return false;
+       }
+};
+
+/**
+ * Handle key press events.
+ *
+ * @private
+ * @param {jQuery.Event} e Key press event
+ * @fires enter If enter key is pressed and input is not multiline
+ */
+OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
+       if ( e.which === OO.ui.Keys.ENTER && !this.multiline ) {
+               this.emit( 'enter', e );
+       }
+};
+
+/**
+ * Handle blur events.
+ *
+ * @private
+ * @param {jQuery.Event} e Blur event
+ */
+OO.ui.TextInputWidget.prototype.onBlur = function () {
+       this.setValidityFlag();
+};
+
+/**
+ * Handle element attach events.
+ *
+ * @private
+ * @param {jQuery.Event} e Element attach event
+ */
+OO.ui.TextInputWidget.prototype.onElementAttach = function () {
+       // Any previously calculated size is now probably invalid if we reattached elsewhere
+       this.valCache = null;
+       this.adjustSize();
+       this.positionLabel();
+};
+
+/**
+ * Handle change events.
+ *
+ * @param {string} value
+ * @private
+ */
+OO.ui.TextInputWidget.prototype.onChange = function () {
+       this.updateSearchIndicator();
+       this.setValidityFlag();
+       this.adjustSize();
+};
+
+/**
+ * Handle disable events.
+ *
+ * @param {boolean} disabled Element is disabled
+ * @private
+ */
+OO.ui.TextInputWidget.prototype.onDisable = function () {
+       this.updateSearchIndicator();
+};
+
+/**
+ * Check if the input is {@link #readOnly read-only}.
+ *
+ * @return {boolean}
+ */
+OO.ui.TextInputWidget.prototype.isReadOnly = function () {
+       return this.readOnly;
+};
+
+/**
+ * Set the {@link #readOnly read-only} state of the input.
+ *
+ * @param {boolean} state Make input read-only
+ * @chainable
+ */
+OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
+       this.readOnly = !!state;
+       this.$input.prop( 'readOnly', this.readOnly );
+       this.updateSearchIndicator();
+       return this;
+};
+
+/**
+ * Support function for making #onElementAttach work across browsers.
+ *
+ * This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument
+ * event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback.
+ *
+ * Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the
+ * first time that the element gets attached to the documented.
+ */
+OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
+       var mutationObserver, onRemove, topmostNode, fakeParentNode,
+               MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver,
+               widget = this;
+
+       if ( MutationObserver ) {
+               // The new way. If only it wasn't so ugly.
+
+               if ( this.$element.closest( 'html' ).length ) {
+                       // Widget is attached already, do nothing. This breaks the functionality of this function when
+                       // the widget is detached and reattached. Alas, doing this correctly with MutationObserver
+                       // would require observation of the whole document, which would hurt performance of other,
+                       // more important code.
+                       return;
+               }
+
+               // Find topmost node in the tree
+               topmostNode = this.$element[ 0 ];
+               while ( topmostNode.parentNode ) {
+                       topmostNode = topmostNode.parentNode;
+               }
+
+               // We have no way to detect the $element being attached somewhere without observing the entire
+               // DOM with subtree modifications, which would hurt performance. So we cheat: we hook to the
+               // parent node of $element, and instead detect when $element is removed from it (and thus
+               // probably attached somewhere else). If there is no parent, we create a "fake" one. If it
+               // doesn't get attached, we end up back here and create the parent.
+
+               mutationObserver = new MutationObserver( function ( mutations ) {
+                       var i, j, removedNodes;
+                       for ( i = 0; i < mutations.length; i++ ) {
+                               removedNodes = mutations[ i ].removedNodes;
+                               for ( j = 0; j < removedNodes.length; j++ ) {
+                                       if ( removedNodes[ j ] === topmostNode ) {
+                                               setTimeout( onRemove, 0 );
+                                               return;
+                                       }
+                               }
+                       }
+               } );
+
+               onRemove = function () {
+                       // If the node was attached somewhere else, report it
+                       if ( widget.$element.closest( 'html' ).length ) {
+                               widget.onElementAttach();
+                       }
+                       mutationObserver.disconnect();
+                       widget.installParentChangeDetector();
+               };
+
+               // Create a fake parent and observe it
+               fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ];
+               mutationObserver.observe( fakeParentNode, { childList: true } );
+       } else {
+               // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
+               // detachment and reattachment, but it's not supported by Firefox and allegedly deprecated.
+               this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) );
+       }
+};
+
+/**
+ * Automatically adjust the size of the text input.
+ *
+ * This only affects #multiline inputs that are {@link #autosize autosized}.
+ *
+ * @chainable
+ * @fires resize
+ */
+OO.ui.TextInputWidget.prototype.adjustSize = function () {
+       var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError,
+               idealHeight, newHeight, scrollWidth, property;
+
+       if ( this.multiline && this.$input.val() !== this.valCache ) {
+               if ( this.autosize ) {
+                       this.$clone
+                               .val( this.$input.val() )
+                               .attr( 'rows', this.minRows )
+                               // Set inline height property to 0 to measure scroll height
+                               .css( 'height', 0 );
+
+                       this.$clone.removeClass( 'oo-ui-element-hidden' );
+
+                       this.valCache = this.$input.val();
+
+                       scrollHeight = this.$clone[ 0 ].scrollHeight;
+
+                       // Remove inline height property to measure natural heights
+                       this.$clone.css( 'height', '' );
+                       innerHeight = this.$clone.innerHeight();
+                       outerHeight = this.$clone.outerHeight();
+
+                       // Measure max rows height
+                       this.$clone
+                               .attr( 'rows', this.maxRows )
+                               .css( 'height', 'auto' )
+                               .val( '' );
+                       maxInnerHeight = this.$clone.innerHeight();
+
+                       // Difference between reported innerHeight and scrollHeight with no scrollbars present
+                       // Equals 1 on Blink-based browsers and 0 everywhere else
+                       measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
+                       idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
+
+                       this.$clone.addClass( 'oo-ui-element-hidden' );
+
+                       // Only apply inline height when expansion beyond natural height is needed
+                       // Use the difference between the inner and outer height as a buffer
+                       newHeight = idealHeight > innerHeight ? idealHeight + ( outerHeight - innerHeight ) : '';
+                       if ( newHeight !== this.styleHeight ) {
+                               this.$input.css( 'height', newHeight );
+                               this.styleHeight = newHeight;
+                               this.emit( 'resize' );
+                       }
+               }
+               scrollWidth = this.$input[ 0 ].offsetWidth - this.$input[ 0 ].clientWidth;
+               if ( scrollWidth !== this.scrollWidth ) {
+                       property = this.$element.css( 'direction' ) === 'rtl' ? 'left' : 'right';
+                       // Reset
+                       this.$label.css( { right: '', left: '' } );
+                       this.$indicator.css( { right: '', left: '' } );
+
+                       if ( scrollWidth ) {
+                               this.$indicator.css( property, scrollWidth );
+                               if ( this.labelPosition === 'after' ) {
+                                       this.$label.css( property, scrollWidth );
+                               }
+                       }
+
+                       this.scrollWidth = scrollWidth;
+                       this.positionLabel();
+               }
+       }
+       return this;
+};
+
+/**
+ * @inheritdoc
+ * @protected
+ */
+OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
+       return config.multiline ?
+               $( '<textarea>' ) :
+               $( '<input type="' + this.getSaneType( config ) + '" />' );
+};
+
+/**
+ * Get sanitized value for 'type' for given config.
+ *
+ * @param {Object} config Configuration options
+ * @return {string|null}
+ * @private
+ */
+OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
+       var type = [ 'text', 'password', 'search', 'email', 'url' ].indexOf( config.type ) !== -1 ?
+               config.type :
+               'text';
+       return config.multiline ? 'multiline' : type;
+};
+
+/**
+ * Check if the input supports multiple lines.
+ *
+ * @return {boolean}
+ */
+OO.ui.TextInputWidget.prototype.isMultiline = function () {
+       return !!this.multiline;
+};
+
+/**
+ * Check if the input automatically adjusts its size.
+ *
+ * @return {boolean}
+ */
+OO.ui.TextInputWidget.prototype.isAutosizing = function () {
+       return !!this.autosize;
+};
+
+/**
+ * Focus the input and select a specified range within the text.
+ *
+ * @param {number} from Select from offset
+ * @param {number} [to] Select to offset, defaults to from
+ * @chainable
+ */
+OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
+       var isBackwards, start, end,
+               input = this.$input[ 0 ];
+
+       to = to || from;
+
+       isBackwards = to < from;
+       start = isBackwards ? to : from;
+       end = isBackwards ? from : to;
+
+       this.focus();
+
+       input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
+       return this;
+};
+
+/**
+ * Get an object describing the current selection range in a directional manner
+ *
+ * @return {Object} Object containing 'from' and 'to' offsets
+ */
+OO.ui.TextInputWidget.prototype.getRange = function () {
+       var input = this.$input[ 0 ],
+               start = input.selectionStart,
+               end = input.selectionEnd,
+               isBackwards = input.selectionDirection === 'backward';
+
+       return {
+               from: isBackwards ? end : start,
+               to: isBackwards ? start : end
+       };
+};
+
+/**
+ * Get the length of the text input value.
+ *
+ * This could differ from the length of #getValue if the
+ * value gets filtered
+ *
+ * @return {number} Input length
+ */
+OO.ui.TextInputWidget.prototype.getInputLength = function () {
+       return this.$input[ 0 ].value.length;
+};
+
+/**
+ * Focus the input and select the entire text.
+ *
+ * @chainable
+ */
+OO.ui.TextInputWidget.prototype.select = function () {
+       return this.selectRange( 0, this.getInputLength() );
+};
+
+/**
+ * Focus the input and move the cursor to the start.
+ *
+ * @chainable
+ */
+OO.ui.TextInputWidget.prototype.moveCursorToStart = function () {
+       return this.selectRange( 0 );
+};
+
+/**
+ * Focus the input and move the cursor to the end.
+ *
+ * @chainable
+ */
+OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
+       return this.selectRange( this.getInputLength() );
+};
+
+/**
+ * Insert new content into the input.
+ *
+ * @param {string} content Content to be inserted
+ * @chainable
+ */
+OO.ui.TextInputWidget.prototype.insertContent = function ( content ) {
+       var start, end,
+               range = this.getRange(),
+               value = this.getValue();
+
+       start = Math.min( range.from, range.to );
+       end = Math.max( range.from, range.to );
+
+       this.setValue( value.slice( 0, start ) + content + value.slice( end ) );
+       this.selectRange( start + content.length );
+       return this;
+};
+
+/**
+ * Insert new content either side of a selection.
+ *
+ * @param {string} pre Content to be inserted before the selection
+ * @param {string} post Content to be inserted after the selection
+ * @chainable
+ */
+OO.ui.TextInputWidget.prototype.encapsulateContent = function ( pre, post ) {
+       var start, end,
+               range = this.getRange(),
+               offset = pre.length;
+
+       start = Math.min( range.from, range.to );
+       end = Math.max( range.from, range.to );
+
+       this.selectRange( start ).insertContent( pre );
+       this.selectRange( offset + end ).insertContent( post );
+
+       this.selectRange( offset + start, offset + end );
+       return this;
+};
+
+/**
+ * Set the validation pattern.
+ *
+ * The validation pattern is either a regular expression, a function, or the symbolic name of a
+ * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
+ * value must contain only numbers).
+ *
+ * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
+ *  of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
+ */
+OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
+       if ( validate instanceof RegExp || validate instanceof Function ) {
+               this.validate = validate;
+       } else {
+               this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/;
+       }
+};
+
+/**
+ * Sets the 'invalid' flag appropriately.
+ *
+ * @param {boolean} [isValid] Optionally override validation result
+ */
+OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
+       var widget = this,
+               setFlag = function ( valid ) {
+                       if ( !valid ) {
+                               widget.$input.attr( 'aria-invalid', 'true' );
+                       } else {
+                               widget.$input.removeAttr( 'aria-invalid' );
+                       }
+                       widget.setFlags( { invalid: !valid } );
+               };
+
+       if ( isValid !== undefined ) {
+               setFlag( isValid );
+       } else {
+               this.getValidity().then( function () {
+                       setFlag( true );
+               }, function () {
+                       setFlag( false );
+               } );
+       }
+};
+
+/**
+ * Check if a value is valid.
+ *
+ * This method returns a promise that resolves with a boolean `true` if the current value is
+ * considered valid according to the supplied {@link #validate validation pattern}.
+ *
+ * @deprecated
+ * @return {jQuery.Promise} A promise that resolves to a boolean `true` if the value is valid.
+ */
+OO.ui.TextInputWidget.prototype.isValid = function () {
+       var result;
+
+       if ( this.validate instanceof Function ) {
+               result = this.validate( this.getValue() );
+               if ( result && $.isFunction( result.promise ) ) {
+                       return result.promise();
+               } else {
+                       return $.Deferred().resolve( !!result ).promise();
+               }
+       } else {
+               return $.Deferred().resolve( !!this.getValue().match( this.validate ) ).promise();
+       }
+};
+
+/**
+ * Get the validity of current value.
+ *
+ * This method returns a promise that resolves if the value is valid and rejects if
+ * it isn't. Uses the {@link #validate validation pattern}  to check for validity.
+ *
+ * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
+ */
+OO.ui.TextInputWidget.prototype.getValidity = function () {
+       var result;
+
+       function rejectOrResolve( valid ) {
+               if ( valid ) {
+                       return $.Deferred().resolve().promise();
+               } else {
+                       return $.Deferred().reject().promise();
+               }
+       }
+
+       if ( this.validate instanceof Function ) {
+               result = this.validate( this.getValue() );
+               if ( result && $.isFunction( result.promise ) ) {
+                       return result.promise().then( function ( valid ) {
+                               return rejectOrResolve( valid );
+                       } );
+               } else {
+                       return rejectOrResolve( result );
+               }
+       } else {
+               return rejectOrResolve( this.getValue().match( this.validate ) );
+       }
+};
+
+/**
+ * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
+ *
+ * @param {string} labelPosition Label position, 'before' or 'after'
+ * @chainable
+ */
+OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
+       this.labelPosition = labelPosition;
+       this.updatePosition();
+       return this;
+};
+
+/**
+ * Update the position of the inline label.
+ *
+ * This method is called by #setLabelPosition, and can also be called on its own if
+ * something causes the label to be mispositioned.
+ *
+ * @chainable
+ */
+OO.ui.TextInputWidget.prototype.updatePosition = function () {
+       var after = this.labelPosition === 'after';
+
+       this.$element
+               .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
+               .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
+
+       this.valCache = null;
+       this.scrollWidth = null;
+       this.adjustSize();
+       this.positionLabel();
+
+       return this;
+};
+
+/**
+ * Update the 'clear' indicator displayed on type: 'search' text fields, hiding it when the field is
+ * already empty or when it's not editable.
+ */
+OO.ui.TextInputWidget.prototype.updateSearchIndicator = function () {
+       if ( this.type === 'search' ) {
+               if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
+                       this.setIndicator( null );
+               } else {
+                       this.setIndicator( 'clear' );
+               }
+       }
+};
+
+/**
+ * Position the label by setting the correct padding on the input.
+ *
+ * @private
+ * @chainable
+ */
+OO.ui.TextInputWidget.prototype.positionLabel = function () {
+       var after, rtl, property;
+       // Clear old values
+       this.$input
+               // Clear old values if present
+               .css( {
+                       'padding-right': '',
+                       'padding-left': ''
+               } );
+
+       if ( this.label ) {
+               this.$element.append( this.$label );
+       } else {
+               this.$label.detach();
+               return;
+       }
+
+       after = this.labelPosition === 'after';
+       rtl = this.$element.css( 'direction' ) === 'rtl';
+       property = after === rtl ? 'padding-left' : 'padding-right';
+
+       this.$input.css( property, this.$label.outerWidth( true ) + ( after ? this.scrollWidth : 0 ) );
+
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.TextInputWidget.prototype.restorePreInfuseState = function ( state ) {
+       OO.ui.TextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
+       if ( state.scrollTop !== undefined ) {
+               this.$input.scrollTop( state.scrollTop );
+       }
+};
+
+/**
+ * ComboBoxInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
+ * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
+ * a value can be chosen instead). Users can choose options from the combo box in one of two ways:
+ *
+ * - by typing a value in the text input field. If the value exactly matches the value of a menu
+ *   option, that option will appear to be selected.
+ * - by choosing a value from the menu. The value of the chosen option will then appear in the text
+ *   input field.
+ *
+ * This widget can be used inside a HTML form, such as a OO.ui.FormLayout.
+ *
+ * For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
+ *
+ *     @example
+ *     // Example: A ComboBoxInputWidget.
+ *     var comboBox = new OO.ui.ComboBoxInputWidget( {
+ *         label: 'ComboBoxInputWidget',
+ *         value: 'Option 1',
+ *         menu: {
+ *             items: [
+ *                 new OO.ui.MenuOptionWidget( {
+ *                     data: 'Option 1',
+ *                     label: 'Option One'
+ *                 } ),
+ *                 new OO.ui.MenuOptionWidget( {
+ *                     data: 'Option 2',
+ *                     label: 'Option Two'
+ *                 } ),
+ *                 new OO.ui.MenuOptionWidget( {
+ *                     data: 'Option 3',
+ *                     label: 'Option Three'
+ *                 } ),
+ *                 new OO.ui.MenuOptionWidget( {
+ *                     data: 'Option 4',
+ *                     label: 'Option Four'
+ *                 } ),
+ *                 new OO.ui.MenuOptionWidget( {
+ *                     data: 'Option 5',
+ *                     label: 'Option Five'
+ *                 } )
+ *             ]
+ *         }
+ *     } );
+ *     $( 'body' ).append( comboBox.$element );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
+ *
+ * @class
+ * @extends OO.ui.TextInputWidget
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
+ * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.FloatingMenuSelectWidget menu select widget}.
+ * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
+ *  the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
+ *  containing `<div>` and has a larger area. By default, the menu uses relative positioning.
+ */
+OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
+       // Configuration initialization
+       config = $.extend( {
+               indicator: 'down'
+       }, config );
+       // For backwards-compatibility with ComboBoxWidget config
+       $.extend( config, config.input );
+
+       // Parent constructor
+       OO.ui.ComboBoxInputWidget.parent.call( this, config );
+
+       // Properties
+       this.$overlay = config.$overlay || this.$element;
+       this.menu = new OO.ui.FloatingMenuSelectWidget( $.extend(
+               {
+                       widget: this,
+                       input: this,
+                       $container: this.$element,
+                       disabled: this.isDisabled()
+               },
+               config.menu
+       ) );
+       // For backwards-compatibility with ComboBoxWidget
+       this.input = this;
+
+       // Events
+       this.$indicator.on( {
+               click: this.onIndicatorClick.bind( this ),
+               keypress: this.onIndicatorKeyPress.bind( this )
+       } );
+       this.connect( this, {
+               change: 'onInputChange',
+               enter: 'onInputEnter'
+       } );
+       this.menu.connect( this, {
+               choose: 'onMenuChoose',
+               add: 'onMenuItemsChange',
+               remove: 'onMenuItemsChange'
+       } );
+
+       // Initialization
+       this.$input.attr( {
+               role: 'combobox',
+               'aria-autocomplete': 'list'
+       } );
+       // Do not override options set via config.menu.items
+       if ( config.options !== undefined ) {
+               this.setOptions( config.options );
+       }
+       // Extra class for backwards-compatibility with ComboBoxWidget
+       this.$element.addClass( 'oo-ui-comboBoxInputWidget oo-ui-comboBoxWidget' );
+       this.$overlay.append( this.menu.$element );
+       this.onMenuItemsChange();
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
+
+/* Methods */
+
+/**
+ * Get the combobox's menu.
+ * @return {OO.ui.FloatingMenuSelectWidget} Menu widget
+ */
+OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
+       return this.menu;
+};
+
+/**
+ * Get the combobox's text input widget.
+ * @return {OO.ui.TextInputWidget} Text input widget
+ */
+OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
+       return this;
+};
+
+/**
+ * Handle input change events.
+ *
+ * @private
+ * @param {string} value New value
+ */
+OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
+       var match = this.menu.getItemFromData( value );
+
+       this.menu.selectItem( match );
+       if ( this.menu.getHighlightedItem() ) {
+               this.menu.highlightItem( match );
+       }
+
+       if ( !this.isDisabled() ) {
+               this.menu.toggle( true );
+       }
+};
+
+/**
+ * Handle mouse click events.
+ *
+ * @private
+ * @param {jQuery.Event} e Mouse click event
+ */
+OO.ui.ComboBoxInputWidget.prototype.onIndicatorClick = function ( e ) {
+       if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
+               this.menu.toggle();
+               this.$input[ 0 ].focus();
+       }
+       return false;
+};
+
+/**
+ * Handle key press events.
+ *
+ * @private
+ * @param {jQuery.Event} e Key press event
+ */
+OO.ui.ComboBoxInputWidget.prototype.onIndicatorKeyPress = function ( e ) {
+       if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
+               this.menu.toggle();
+               this.$input[ 0 ].focus();
+               return false;
+       }
+};
+
+/**
+ * Handle input enter events.
+ *
+ * @private
+ */
+OO.ui.ComboBoxInputWidget.prototype.onInputEnter = function () {
+       if ( !this.isDisabled() ) {
+               this.menu.toggle( false );
+       }
+};
+
+/**
+ * Handle menu choose events.
+ *
+ * @private
+ * @param {OO.ui.OptionWidget} item Chosen item
+ */
+OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) {
+       this.setValue( item.getData() );
+};
+
+/**
+ * Handle menu item change events.
+ *
+ * @private
+ */
+OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () {
+       var match = this.menu.getItemFromData( this.getValue() );
+       this.menu.selectItem( match );
+       if ( this.menu.getHighlightedItem() ) {
+               this.menu.highlightItem( match );
+       }
+       this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.ComboBoxInputWidget.prototype.setDisabled = function ( disabled ) {
+       // Parent method
+       OO.ui.ComboBoxInputWidget.parent.prototype.setDisabled.call( this, disabled );
+
+       if ( this.menu ) {
+               this.menu.setDisabled( this.isDisabled() );
+       }
+
+       return this;
+};
+
+/**
+ * Set the options available for this input.
+ *
+ * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
+ * @chainable
+ */
+OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
+       this.getMenu()
+               .clearItems()
+               .addItems( options.map( function ( opt ) {
+                       return new OO.ui.MenuOptionWidget( {
+                               data: opt.data,
+                               label: opt.label !== undefined ? opt.label : opt.data
+                       } );
+               } ) );
+
+       return this;
+};
+
+/**
+ * @class
+ * @deprecated Use OO.ui.ComboBoxInputWidget instead.
+ */
+OO.ui.ComboBoxWidget = OO.ui.ComboBoxInputWidget;
+
+/**
+ * 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.mixin.LabelElement
+ * @mixins OO.ui.mixin.TitledElement
+ *
+ * @constructor
+ * @param {OO.ui.Widget} fieldWidget Field widget
+ * @param {Object} [config] Configuration options
+ * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top' or 'inline'
+ * @cfg {Array} [errors] Error messages about the widget, which will be displayed below the widget.
+ *  The array may contain strings or OO.ui.HtmlSnippet instances.
+ * @cfg {Array} [notices] Notices about the widget, which will be displayed below the widget.
+ *  The array may contain strings or OO.ui.HtmlSnippet instances.
+ * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
+ *  in the upper-right corner of the rendered field; clicking it will display the text in a popup.
+ *  For important messages, you are advised to use `notices`, as they are always shown.
+ *
+ * @throws {Error} An error is thrown if no widget is specified
+ */
+OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
+       var hasInputWidget, div;
+
+       // Allow passing positional parameters inside the config object
+       if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
+               config = fieldWidget;
+               fieldWidget = config.fieldWidget;
+       }
+
+       // Make sure we have required constructor arguments
+       if ( fieldWidget === undefined ) {
+               throw new Error( 'Widget not found' );
+       }
+
+       hasInputWidget = fieldWidget.constructor.static.supportsSimpleLabel;
+
+       // Configuration initialization
+       config = $.extend( { align: 'left' }, config );
+
+       // Parent constructor
+       OO.ui.FieldLayout.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.LabelElement.call( this, config );
+       OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
+
+       // Properties
+       this.fieldWidget = fieldWidget;
+       this.errors = [];
+       this.notices = [];
+       this.$field = $( '<div>' );
+       this.$messages = $( '<ul>' );
+       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'
+               } );
+
+               div = $( '<div>' );
+               if ( config.help instanceof OO.ui.HtmlSnippet ) {
+                       div.html( config.help.toString() );
+               } else {
+                       div.text( config.help );
+               }
+               this.popupButtonWidget.getPopup().$body.append(
+                       div.addClass( 'oo-ui-fieldLayout-help-content' )
+               );
+               this.$help = this.popupButtonWidget.$element;
+       } else {
+               this.$help = $( [] );
+       }
+
+       // Events
+       if ( hasInputWidget ) {
+               this.$label.on( 'click', this.onLabelClick.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.$messages.addClass( 'oo-ui-fieldLayout-messages' );
+       this.$field
+               .addClass( 'oo-ui-fieldLayout-field' )
+               .toggleClass( 'oo-ui-fieldLayout-disable', this.fieldWidget.isDisabled() )
+               .append( this.fieldWidget.$element );
+
+       this.setErrors( config.errors || [] );
+       this.setNotices( config.notices || [] );
+       this.setAlignment( config.align );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
+OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
+OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
+
+/* Methods */
+
+/**
+ * Handle field disable events.
+ *
+ * @private
+ * @param {boolean} value Field is disabled
+ */
+OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
+       this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
+};
+
+/**
+ * Handle label mouse click events.
+ *
+ * @private
+ * @param {jQuery.Event} e Mouse click event
+ */
+OO.ui.FieldLayout.prototype.onLabelClick = function () {
+       this.fieldWidget.simulateLabelClick();
+       return false;
+};
+
+/**
+ * Get the widget contained by the field.
+ *
+ * @return {OO.ui.Widget} Field widget
+ */
+OO.ui.FieldLayout.prototype.getField = function () {
+       return this.fieldWidget;
+};
+
+/**
+ * @protected
+ * @param {string} kind 'error' or 'notice'
+ * @param {string|OO.ui.HtmlSnippet} text
+ * @return {jQuery}
+ */
+OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
+       var $listItem, $icon, message;
+       $listItem = $( '<li>' );
+       if ( kind === 'error' ) {
+               $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'warning' ] } ).$element;
+       } else if ( kind === 'notice' ) {
+               $icon = new OO.ui.IconWidget( { icon: 'info' } ).$element;
+       } else {
+               $icon = '';
+       }
+       message = new OO.ui.LabelWidget( { label: text } );
+       $listItem
+               .append( $icon, message.$element )
+               .addClass( 'oo-ui-fieldLayout-messages-' + kind );
+       return $listItem;
+};
+
+/**
+ * Set the field alignment mode.
+ *
+ * @private
+ * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline'
+ * @chainable
+ */
+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;
+};
+
+/**
+ * Set the list of error messages.
+ *
+ * @param {Array} errors Error messages about the widget, which will be displayed below the widget.
+ *  The array may contain strings or OO.ui.HtmlSnippet instances.
+ * @chainable
+ */
+OO.ui.FieldLayout.prototype.setErrors = function ( errors ) {
+       this.errors = errors.slice();
+       this.updateMessages();
+       return this;
+};
+
+/**
+ * Set the list of notice messages.
+ *
+ * @param {Array} notices Notices about the widget, which will be displayed below the widget.
+ *  The array may contain strings or OO.ui.HtmlSnippet instances.
+ * @chainable
+ */
+OO.ui.FieldLayout.prototype.setNotices = function ( notices ) {
+       this.notices = notices.slice();
+       this.updateMessages();
+       return this;
+};
+
+/**
+ * Update the rendering of error and notice messages.
+ *
+ * @private
+ */
+OO.ui.FieldLayout.prototype.updateMessages = function () {
+       var i;
+       this.$messages.empty();
+
+       if ( this.errors.length || this.notices.length ) {
+               this.$body.after( this.$messages );
+       } else {
+               this.$messages.remove();
+               return;
+       }
+
+       for ( i = 0; i < this.notices.length; i++ ) {
+               this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
+       }
+       for ( i = 0; i < this.errors.length; i++ ) {
+               this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
+       }
+};
+
+/**
+ * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget, a button,
+ * and an optional label and/or help text. The field-widget (e.g., a {@link OO.ui.TextInputWidget TextInputWidget}),
+ * is required and is specified before any optional configuration settings.
+ *
+ * Labels can be 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 when help
+ * text is specified.
+ *
+ *     @example
+ *     // Example of an ActionFieldLayout
+ *     var actionFieldLayout = new OO.ui.ActionFieldLayout(
+ *         new OO.ui.TextInputWidget( {
+ *             placeholder: 'Field widget'
+ *         } ),
+ *         new OO.ui.ButtonWidget( {
+ *             label: 'Button'
+ *         } ),
+ *         {
+ *             label: 'An ActionFieldLayout. This label is aligned top',
+ *             align: 'top',
+ *             help: 'This is help text'
+ *         }
+ *     );
+ *
+ *     $( 'body' ).append( actionFieldLayout.$element );
+ *
+ * @class
+ * @extends OO.ui.FieldLayout
+ *
+ * @constructor
+ * @param {OO.ui.Widget} fieldWidget Field widget
+ * @param {OO.ui.ButtonWidget} buttonWidget Button widget
+ */
+OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) {
+       // Allow passing positional parameters inside the config object
+       if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
+               config = fieldWidget;
+               fieldWidget = config.fieldWidget;
+               buttonWidget = config.buttonWidget;
+       }
+
+       // Parent constructor
+       OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config );
+
+       // Properties
+       this.buttonWidget = buttonWidget;
+       this.$button = $( '<div>' );
+       this.$input = $( '<div>' );
+
+       // Initialization
+       this.$element
+               .addClass( 'oo-ui-actionFieldLayout' );
+       this.$button
+               .addClass( 'oo-ui-actionFieldLayout-button' )
+               .append( this.buttonWidget.$element );
+       this.$input
+               .addClass( 'oo-ui-actionFieldLayout-input' )
+               .append( this.fieldWidget.$element );
+       this.$field
+               .append( this.$input, this.$button );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
+
+/**
+ * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts},
+ * which each contain an individual widget and, optionally, a label. Each Fieldset can be
+ * configured with a label as well. For more information and examples,
+ * please see the [OOjs UI documentation on MediaWiki][1].
+ *
+ *     @example
+ *     // Example of a fieldset layout
+ *     var input1 = new OO.ui.TextInputWidget( {
+ *         placeholder: 'A text input field'
+ *     } );
+ *
+ *     var input2 = new OO.ui.TextInputWidget( {
+ *         placeholder: 'A text input field'
+ *     } );
+ *
+ *     var fieldset = new OO.ui.FieldsetLayout( {
+ *         label: 'Example of a fieldset layout'
+ *     } );
+ *
+ *     fieldset.addItems( [
+ *         new OO.ui.FieldLayout( input1, {
+ *             label: 'Field One'
+ *         } ),
+ *         new OO.ui.FieldLayout( input2, {
+ *             label: 'Field Two'
+ *         } )
+ *     ] );
+ *     $( 'body' ).append( fieldset.$element );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Fields_and_Fieldsets
+ *
+ * @class
+ * @extends OO.ui.Layout
+ * @mixins OO.ui.mixin.IconElement
+ * @mixins OO.ui.mixin.LabelElement
+ * @mixins OO.ui.mixin.GroupElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {OO.ui.FieldLayout[]} [items] An array of fields to add to the fieldset. See OO.ui.FieldLayout for more information about fields.
+ */
+OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.FieldsetLayout.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.IconElement.call( this, config );
+       OO.ui.mixin.LabelElement.call( this, config );
+       OO.ui.mixin.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(
+                       $( '<div>' )
+                               .text( config.help )
+                               .addClass( 'oo-ui-fieldsetLayout-help-content' )
+               );
+               this.$help = this.popupButtonWidget.$element;
+       } else {
+               this.$help = $( [] );
+       }
+
+       // 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.mixin.IconElement );
+OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
+OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
+
+/**
+ * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use browser-based
+ * form submission for the fields instead of handling them in JavaScript. Form layouts can be configured with an
+ * HTML form action, an encoding type, and a method using the #action, #enctype, and #method configs, respectively.
+ * See the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
+ *
+ * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It
+ * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link
+ * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as
+ * some fancier controls. Some controls have both regular and InputWidget variants, for example
+ * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and
+ * often have simplified APIs to match the capabilities of HTML forms.
+ * See the [OOjs UI Inputs documentation on MediaWiki] [2] for more information about InputWidgets.
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Forms
+ * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
+ *
+ *     @example
+ *     // Example of a form layout that wraps a fieldset layout
+ *     var input1 = new OO.ui.TextInputWidget( {
+ *         placeholder: 'Username'
+ *     } );
+ *     var input2 = new OO.ui.TextInputWidget( {
+ *         placeholder: 'Password',
+ *         type: 'password'
+ *     } );
+ *     var submit = new OO.ui.ButtonInputWidget( {
+ *         label: 'Submit'
+ *     } );
+ *
+ *     var fieldset = new OO.ui.FieldsetLayout( {
+ *         label: 'A form layout'
+ *     } );
+ *     fieldset.addItems( [
+ *         new OO.ui.FieldLayout( input1, {
+ *             label: 'Username',
+ *             align: 'top'
+ *         } ),
+ *         new OO.ui.FieldLayout( input2, {
+ *             label: 'Password',
+ *             align: 'top'
+ *         } ),
+ *         new OO.ui.FieldLayout( submit )
+ *     ] );
+ *     var form = new OO.ui.FormLayout( {
+ *         items: [ fieldset ],
+ *         action: '/api/formhandler',
+ *         method: 'get'
+ *     } )
+ *     $( 'body' ).append( form.$element );
+ *
+ * @class
+ * @extends OO.ui.Layout
+ * @mixins OO.ui.mixin.GroupElement
+ *
+ * @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
+ * @cfg {OO.ui.FieldsetLayout[]} [items] Fieldset layouts to add to the form layout.
+ */
+OO.ui.FormLayout = function OoUiFormLayout( config ) {
+       var action;
+
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.FormLayout.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
+
+       // Events
+       this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
+
+       // Make sure the action is safe
+       action = config.action;
+       if ( action !== undefined && !OO.ui.isSafeUrl( action ) ) {
+               action = './' + action;
+       }
+
+       // Initialization
+       this.$element
+               .addClass( 'oo-ui-formLayout' )
+               .attr( {
+                       method: config.method,
+                       action: action,
+                       enctype: config.enctype
+               } );
+       if ( Array.isArray( config.items ) ) {
+               this.addItems( config.items );
+       }
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout );
+OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
+
+/* Events */
+
+/**
+ * A 'submit' event is emitted when the form is submitted.
+ *
+ * @event submit
+ */
+
+/* Static Properties */
+
+OO.ui.FormLayout.static.tagName = 'form';
+
+/* Methods */
+
+/**
+ * Handle form submit events.
+ *
+ * @private
+ * @param {jQuery.Event} e Submit event
+ * @fires submit
+ */
+OO.ui.FormLayout.prototype.onFormSubmit = function () {
+       if ( this.emit( 'submit' ) ) {
+               return false;
+       }
+};
+
+/**
+ * PanelLayouts expand to cover the entire area of their parent. They can be configured with scrolling, padding,
+ * and a frame, and are often used together with {@link OO.ui.StackLayout StackLayouts}.
+ *
+ *     @example
+ *     // Example of a panel layout
+ *     var panel = new OO.ui.PanelLayout( {
+ *         expanded: false,
+ *         framed: true,
+ *         padded: true,
+ *         $content: $( '<p>A panel layout with padding and a frame.</p>' )
+ *     } );
+ *     $( 'body' ).append( panel.$element );
+ *
+ * @class
+ * @extends OO.ui.Layout
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {boolean} [scrollable=false] Allow vertical scrolling
+ * @cfg {boolean} [padded=false] Add padding between the content and the edges of the panel.
+ * @cfg {boolean} [expanded=true] Expand the panel to fill the entire parent element.
+ * @cfg {boolean} [framed=false] Render the panel with a frame to visually separate it from outside content.
+ */
+OO.ui.PanelLayout = function OoUiPanelLayout( config ) {
+       // Configuration initialization
+       config = $.extend( {
+               scrollable: false,
+               padded: false,
+               expanded: true,
+               framed: false
+       }, config );
+
+       // Parent constructor
+       OO.ui.PanelLayout.parent.call( this, config );
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-panelLayout' );
+       if ( config.scrollable ) {
+               this.$element.addClass( 'oo-ui-panelLayout-scrollable' );
+       }
+       if ( config.padded ) {
+               this.$element.addClass( 'oo-ui-panelLayout-padded' );
+       }
+       if ( config.expanded ) {
+               this.$element.addClass( 'oo-ui-panelLayout-expanded' );
+       }
+       if ( config.framed ) {
+               this.$element.addClass( 'oo-ui-panelLayout-framed' );
+       }
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout );
+
+/* Methods */
+
+/**
+ * Focus the panel layout
+ *
+ * The default implementation just focuses the first focusable element in the panel
+ */
+OO.ui.PanelLayout.prototype.focus = function () {
+       OO.ui.findFocusable( this.$element ).focus();
+};
+
+/**
+ * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
+ * items), with small margins between them. Convenient when you need to put a number of block-level
+ * widgets on a single line next to each other.
+ *
+ * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
+ *
+ *     @example
+ *     // HorizontalLayout with a text input and a label
+ *     var layout = new OO.ui.HorizontalLayout( {
+ *       items: [
+ *         new OO.ui.LabelWidget( { label: 'Label' } ),
+ *         new OO.ui.TextInputWidget( { value: 'Text' } )
+ *       ]
+ *     } );
+ *     $( 'body' ).append( layout.$element );
+ *
+ * @class
+ * @extends OO.ui.Layout
+ * @mixins OO.ui.mixin.GroupElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
+ */
+OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.HorizontalLayout.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-horizontalLayout' );
+       if ( Array.isArray( config.items ) ) {
+               this.addItems( config.items );
+       }
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
+OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
+
+}( OO ) );
diff --git a/resources/lib/oojs-ui/oojs-ui-mediawiki-noimages.css b/resources/lib/oojs-ui/oojs-ui-mediawiki-noimages.css
deleted file mode 100644 (file)
index 6a11bdb..0000000
+++ /dev/null
@@ -1,3204 +0,0 @@
-/*!
- * OOjs UI v0.15.2
- * https://www.mediawiki.org/wiki/OOjs_UI
- *
- * Copyright 2011–2016 OOjs UI Team and other contributors.
- * Released under the MIT license
- * http://oojs.mit-license.org
- *
- * Date: 2016-02-02T22:07:06Z
- */
-@-webkit-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@-moz-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-.oo-ui-element-hidden {
-       display: none !important;
-}
-.oo-ui-buttonElement > .oo-ui-buttonElement-button {
-       cursor: pointer;
-       display: inline-block;
-       vertical-align: middle;
-       font: inherit;
-       white-space: nowrap;
-       -webkit-touch-callout: none;
-       -webkit-user-select: none;
-          -moz-user-select: none;
-           -ms-user-select: none;
-               user-select: none;
-}
-.oo-ui-buttonElement > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon,
-.oo-ui-buttonElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
-       display: none;
-}
-.oo-ui-buttonElement.oo-ui-widget-disabled > .oo-ui-buttonElement-button {
-       cursor: default;
-}
-.oo-ui-buttonElement.oo-ui-indicatorElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator,
-.oo-ui-buttonElement.oo-ui-iconElement > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
-       display: inline-block;
-       vertical-align: middle;
-}
-.oo-ui-buttonElement-frameless {
-       display: inline-block;
-       position: relative;
-}
-.oo-ui-buttonElement-frameless.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       display: inline-block;
-       vertical-align: middle;
-}
-.oo-ui-buttonElement-framed > .oo-ui-buttonElement-button {
-       display: inline-block;
-       vertical-align: top;
-       text-align: center;
-}
-.oo-ui-buttonElement-framed.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       display: inline-block;
-       vertical-align: middle;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-disabled > .oo-ui-buttonElement-button,
-.oo-ui-buttonElement-framed.oo-ui-widget-disabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button,
-.oo-ui-buttonElement-framed.oo-ui-widget-disabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
-       cursor: default;
-}
-.oo-ui-buttonElement > .oo-ui-buttonElement-button {
-       font-weight: bold;
-       text-decoration: none;
-}
-.oo-ui-buttonElement.oo-ui-iconElement > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
-       margin-left: 0;
-}
-.oo-ui-buttonElement.oo-ui-indicatorElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
-       width: 0.9375em;
-       height: 0.9375em;
-}
-.oo-ui-buttonElement.oo-ui-iconElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
-       margin-left: 0.46875em;
-}
-.oo-ui-buttonElement.oo-ui-iconElement > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
-       width: 1.875em;
-       height: 1.875em;
-}
-.oo-ui-buttonElement-frameless > .oo-ui-buttonElement-button:focus {
-       box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(0, 0, 0, 0.2);
-       outline: none;
-}
-.oo-ui-buttonElement-frameless > .oo-ui-buttonElement-button .oo-ui-indicatorElement-indicator {
-       margin-right: 0;
-}
-.oo-ui-buttonElement-frameless.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       margin-left: 0.25em;
-       margin-right: 0.25em;
-}
-.oo-ui-buttonElement-frameless > input.oo-ui-buttonElement-button {
-       padding-left: 0.25em;
-       padding-right: 0.25em;
-       color: #333333;
-}
-.oo-ui-buttonElement-frameless.oo-ui-widget-enabled > input.oo-ui-buttonElement-button,
-.oo-ui-buttonElement-frameless.oo-ui-widget-enabled > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       color: #555555;
-}
-.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > input.oo-ui-buttonElement-button,
-.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       color: #444444;
-}
-.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button:hover > .oo-ui-labelElement-label,
-.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button:focus > .oo-ui-labelElement-label {
-       color: #2962cc;
-}
-.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       color: #347bff;
-}
-.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-progressive.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active > .oo-ui-labelElement-label,
-.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-progressive.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       color: #1f4999;
-       box-shadow: none;
-}
-.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button:hover > .oo-ui-labelElement-label,
-.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button:focus > .oo-ui-labelElement-label {
-       color: #008064;
-}
-.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       color: #00af89;
-}
-.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-constructive.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active > .oo-ui-labelElement-label,
-.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-constructive.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       color: #005946;
-       box-shadow: none;
-}
-.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-destructive > .oo-ui-buttonElement-button:hover > .oo-ui-labelElement-label,
-.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-destructive > .oo-ui-buttonElement-button:focus > .oo-ui-labelElement-label {
-       color: #8c130d;
-}
-.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-destructive > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       color: #d11d13;
-}
-.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-destructive.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active > .oo-ui-labelElement-label,
-.oo-ui-buttonElement-frameless.oo-ui-widget-enabled.oo-ui-flaggedElement-destructive.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       color: #73100a;
-       box-shadow: none;
-}
-.oo-ui-buttonElement-frameless.oo-ui-widget-disabled > .oo-ui-buttonElement-button {
-       color: #cccccc;
-}
-.oo-ui-buttonElement-frameless.oo-ui-widget-disabled > .oo-ui-buttonElement-button:focus {
-       box-shadow: none;
-}
-.oo-ui-buttonElement-frameless.oo-ui-widget-disabled > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon,
-.oo-ui-buttonElement-frameless.oo-ui-widget-disabled > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
-       opacity: 0.2;
-}
-.oo-ui-buttonElement-framed.oo-ui-iconElement.oo-ui-labelElement > .oo-ui-buttonElement-button,
-.oo-ui-buttonElement-framed.oo-ui-iconElement.oo-ui-indicatorElement > .oo-ui-buttonElement-button {
-       padding-left: 2.4em;
-}
-.oo-ui-buttonElement-framed > .oo-ui-buttonElement-button {
-       padding: 0.5em 1em;
-       min-height: 1.2em;
-       min-width: 1em;
-       border-radius: 2px;
-       position: relative;
-       -webkit-transition: background 100ms ease, color 100ms ease, border-color 100ms ease, box-shadow 100ms ease;
-          -moz-transition: background 100ms ease, color 100ms ease, border-color 100ms ease, box-shadow 100ms ease;
-               transition: background 100ms ease, color 100ms ease, border-color 100ms ease, box-shadow 100ms ease;
-}
-.oo-ui-buttonElement-framed > .oo-ui-buttonElement-button:hover,
-.oo-ui-buttonElement-framed > .oo-ui-buttonElement-button:focus {
-       outline: none;
-}
-.oo-ui-buttonElement-framed > input.oo-ui-buttonElement-button,
-.oo-ui-buttonElement-framed.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       line-height: 1.2em;
-       display: inline-block;
-}
-.oo-ui-buttonElement-framed.oo-ui-iconElement > .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
-       position: absolute;
-       top: 0.2em;
-       left: 0.5625em;
-}
-.oo-ui-buttonElement-framed.oo-ui-iconElement.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       margin-left: 0.3em;
-}
-.oo-ui-buttonElement-framed.oo-ui-indicatorElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
-       display: inline-block;
-}
-.oo-ui-buttonElement-framed.oo-ui-indicatorElement.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator,
-.oo-ui-buttonElement-framed.oo-ui-indicatorElement.oo-ui-iconElement:not( .oo-ui-labelElement ) > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
-       margin-left: 0.46875em;
-       margin-right: -0.275em;
-}
-.oo-ui-buttonElement-framed.oo-ui-indicatorElement.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-indicatorElement-indicator {
-       position: relative;
-       left: 0.2em;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-disabled > .oo-ui-buttonElement-button {
-       background: #dddddd;
-       color: #ffffff;
-       border: 1px solid #dddddd;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled > .oo-ui-buttonElement-button {
-       color: #555555;
-       background-color: #ffffff;
-       border: 1px solid #cccccc;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled > .oo-ui-buttonElement-button:hover {
-       background-color: #ebebeb;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled > .oo-ui-buttonElement-button:focus {
-       box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.2);
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active,
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
-       background-color: #d9d9d9;
-       border-color: #d9d9d9;
-       box-shadow: none;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button {
-       background-color: #999999;
-       color: #ffffff;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button {
-       color: #347bff;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button:hover {
-       background-color: rgba(52, 123, 255, 0.1);
-       border-color: rgba(31, 73, 153, 0.5);
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button:focus {
-       box-shadow: inset 0 0 0 1px #1f4999;
-       border-color: #1f4999;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-progressive.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active,
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-progressive.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
-       color: #1f4999;
-       border-color: #1f4999;
-       box-shadow: none;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-progressive.oo-ui-widget-enabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button {
-       background-color: #999999;
-       color: #ffffff;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button {
-       color: #00af89;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button:hover {
-       background-color: rgba(0, 171, 137, 0.1);
-       border-color: rgba(0, 89, 70, 0.5);
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button:focus {
-       box-shadow: inset 0 0 0 1px #005946;
-       border-color: #005946;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-constructive.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active,
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-constructive.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
-       color: #005946;
-       border-color: #005946;
-       box-shadow: none;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-constructive.oo-ui-widget-enabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button {
-       background-color: #999999;
-       color: #ffffff;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-destructive > .oo-ui-buttonElement-button {
-       color: #d11d13;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-destructive > .oo-ui-buttonElement-button:hover {
-       background-color: rgba(209, 29, 19, 0.1);
-       border-color: rgba(115, 16, 10, 0.5);
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-destructive > .oo-ui-buttonElement-button:focus {
-       box-shadow: inset 0 0 0 1px #73100a;
-       border-color: #73100a;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-destructive.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active,
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-destructive.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
-       color: #73100a;
-       border-color: #73100a;
-       box-shadow: none;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-destructive.oo-ui-widget-enabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button {
-       background-color: #999999;
-       color: #ffffff;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button {
-       color: #ffffff;
-       background-color: #347bff;
-       border-color: #347bff;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button:hover {
-       background: #2962cc;
-       border-color: #2962cc;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-progressive > .oo-ui-buttonElement-button:focus {
-       box-shadow: inset 0 0 0 1px #ffffff;
-       border-color: #347bff;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-progressive.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active,
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-progressive.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
-       color: #ffffff;
-       background-color: #1f4999;
-       border-color: #1f4999;
-       box-shadow: none;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-progressive.oo-ui-widget-enabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button {
-       background-color: #999999;
-       color: #ffffff;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button {
-       color: #ffffff;
-       background-color: #00af89;
-       border-color: #00af89;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button:hover {
-       background: #008064;
-       border-color: #008064;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-constructive > .oo-ui-buttonElement-button:focus {
-       box-shadow: inset 0 0 0 1px #ffffff;
-       border-color: #00af89;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-constructive.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active,
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-constructive.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
-       color: #ffffff;
-       background-color: #005946;
-       border-color: #005946;
-       box-shadow: none;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-constructive.oo-ui-widget-enabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button {
-       background-color: #999999;
-       color: #ffffff;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-destructive > .oo-ui-buttonElement-button {
-       color: #ffffff;
-       background-color: #d11d13;
-       border-color: #d11d13;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-destructive > .oo-ui-buttonElement-button:hover {
-       background: #8c130d;
-       border-color: #8c130d;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-destructive > .oo-ui-buttonElement-button:focus {
-       box-shadow: inset 0 0 0 1px #ffffff;
-       border-color: #d11d13;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-destructive.oo-ui-widget-enabled > .oo-ui-buttonElement-button:active,
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-destructive.oo-ui-widget-enabled.oo-ui-buttonElement-pressed > .oo-ui-buttonElement-button {
-       color: #ffffff;
-       background-color: #73100a;
-       border-color: #73100a;
-       box-shadow: none;
-}
-.oo-ui-buttonElement-framed.oo-ui-widget-enabled.oo-ui-flaggedElement-primary.oo-ui-flaggedElement-destructive.oo-ui-widget-enabled.oo-ui-buttonElement-active > .oo-ui-buttonElement-button {
-       background-color: #999999;
-       color: #ffffff;
-}
-.oo-ui-clippableElement-clippable {
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-iconElement .oo-ui-iconElement-icon,
-.oo-ui-iconElement.oo-ui-iconElement-icon {
-       background-size: contain;
-       background-position: center center;
-       background-repeat: no-repeat;
-}
-.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator,
-.oo-ui-indicatorElement.oo-ui-indicatorElement-indicator {
-       background-size: contain;
-       background-position: center center;
-       background-repeat: no-repeat;
-}
-.oo-ui-pendingElement-pending {
-       background-image: /* @embed */ url(themes/mediawiki/images/textures/pending.gif);
-}
-.oo-ui-fieldLayout {
-       display: block;
-       margin-bottom: 1em;
-}
-.oo-ui-fieldLayout:before,
-.oo-ui-fieldLayout:after {
-       content: " ";
-       display: table;
-}
-.oo-ui-fieldLayout:after {
-       clear: both;
-}
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-left > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label,
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-right > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label,
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-left > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field,
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-right > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field {
-       display: block;
-       float: left;
-}
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-right > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
-       text-align: right;
-}
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline > .oo-ui-fieldLayout-body {
-       display: table;
-}
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label,
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field {
-       display: table-cell;
-       vertical-align: middle;
-}
-.oo-ui-fieldLayout.oo-ui-labelElement.oo-ui-fieldLayout-align-top > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
-       display: inline-block;
-}
-.oo-ui-fieldLayout > .oo-ui-fieldLayout-help {
-       float: right;
-}
-.oo-ui-fieldLayout > .oo-ui-fieldLayout-help > .oo-ui-popupWidget > .oo-ui-popupWidget-popup {
-       z-index: 1;
-}
-.oo-ui-fieldLayout > .oo-ui-fieldLayout-help .oo-ui-fieldLayout-help-content {
-       padding: 0.5em 0.75em;
-       line-height: 1.5em;
-}
-.oo-ui-fieldLayout:last-child {
-       margin-bottom: 0;
-}
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-left.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label,
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-right.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
-       padding-top: 0.5em;
-       margin-right: 5%;
-       width: 35%;
-}
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-left > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field,
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-right > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field {
-       width: 60%;
-}
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline {
-       margin-bottom: 1.25em;
-}
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
-       padding: 0.25em 0.25em 0.25em 1em;
-}
-.oo-ui-fieldLayout.oo-ui-fieldLayout-align-top.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
-       padding-top: 0.25em;
-       padding-bottom: 0.5em;
-}
-.oo-ui-fieldLayout > .oo-ui-popupButtonWidget {
-       margin-right: 0;
-}
-.oo-ui-fieldLayout > .oo-ui-popupButtonWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-fieldLayout-disabled > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
-       color: #cccccc;
-}
-.oo-ui-fieldLayout-messages {
-       list-style: none none;
-       margin: 0.25em 0 0 0.25em;
-       padding: 0;
-}
-.oo-ui-fieldLayout-messages > li {
-       margin: 0;
-       padding: 0;
-       display: table;
-}
-.oo-ui-fieldLayout-messages .oo-ui-iconWidget {
-       display: table-cell;
-       border-right: 0.5em solid transparent;
-}
-.oo-ui-fieldLayout-messages .oo-ui-labelWidget {
-       display: table-cell;
-       padding: 0;
-       line-height: 1.875em;
-       vertical-align: middle;
-}
-.oo-ui-actionFieldLayout-input,
-.oo-ui-actionFieldLayout-button {
-       display: table-cell;
-       vertical-align: middle;
-}
-.oo-ui-actionFieldLayout-input {
-       padding-right: 1em;
-}
-.oo-ui-actionFieldLayout-button {
-       width: 1%;
-       white-space: nowrap;
-}
-.oo-ui-fieldsetLayout {
-       position: relative;
-       margin: 0;
-       padding: 0;
-       border: 0;
-}
-.oo-ui-fieldsetLayout.oo-ui-iconElement > .oo-ui-iconElement-icon {
-       display: block;
-       position: absolute;
-}
-.oo-ui-fieldsetLayout.oo-ui-labelElement > .oo-ui-labelElement-label {
-       display: inline-block;
-}
-.oo-ui-fieldsetLayout > .oo-ui-fieldsetLayout-help {
-       float: right;
-}
-.oo-ui-fieldsetLayout > .oo-ui-fieldsetLayout-help > .oo-ui-popupWidget > .oo-ui-popupWidget-popup {
-       z-index: 1;
-}
-.oo-ui-fieldsetLayout > .oo-ui-fieldsetLayout-help .oo-ui-fieldsetLayout-help-content {
-       padding: 0.5em 0.75em;
-       line-height: 1.5em;
-}
-.oo-ui-fieldsetLayout + .oo-ui-fieldsetLayout,
-.oo-ui-fieldsetLayout + .oo-ui-formLayout {
-       margin-top: 2em;
-}
-.oo-ui-fieldsetLayout > .oo-ui-labelElement-label {
-       font-size: 1.1em;
-       margin-bottom: 0.5em;
-       padding: 0.25em 0;
-       font-weight: bold;
-}
-.oo-ui-fieldsetLayout.oo-ui-iconElement > .oo-ui-labelElement-label {
-       padding-left: 2em;
-       line-height: 1.8em;
-}
-.oo-ui-fieldsetLayout.oo-ui-iconElement > .oo-ui-iconElement-icon {
-       left: 0;
-       top: 0.25em;
-       width: 1.875em;
-       height: 1.875em;
-}
-.oo-ui-fieldsetLayout > .oo-ui-popupButtonWidget {
-       margin-right: 0;
-}
-.oo-ui-fieldsetLayout > .oo-ui-popupButtonWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-formLayout + .oo-ui-fieldsetLayout,
-.oo-ui-formLayout + .oo-ui-formLayout {
-       margin-top: 2em;
-}
-.oo-ui-panelLayout {
-       position: relative;
-}
-.oo-ui-panelLayout-scrollable {
-       overflow-y: auto;
-}
-.oo-ui-panelLayout-expanded {
-       position: absolute;
-       top: 0;
-       left: 0;
-       right: 0;
-       bottom: 0;
-}
-.oo-ui-panelLayout-padded {
-       padding: 1.25em;
-}
-.oo-ui-panelLayout-framed {
-       border: 1px solid #aaaaaa;
-       border-radius: 2px;
-       box-shadow: 0 0.15em 0 0 rgba(0, 0, 0, 0.15);
-}
-.oo-ui-panelLayout-padded.oo-ui-panelLayout-framed {
-       margin: 1em 0;
-}
-.oo-ui-horizontalLayout > .oo-ui-widget {
-       display: inline-block;
-       vertical-align: middle;
-}
-.oo-ui-horizontalLayout > .oo-ui-layout {
-       display: inline-block;
-}
-.oo-ui-horizontalLayout > .oo-ui-layout,
-.oo-ui-horizontalLayout > .oo-ui-widget {
-       margin-right: 0.5em;
-}
-.oo-ui-horizontalLayout > .oo-ui-layout:last-child,
-.oo-ui-horizontalLayout > .oo-ui-widget:last-child {
-       margin-right: 0;
-}
-.oo-ui-horizontalLayout > .oo-ui-layout {
-       margin-bottom: 0;
-}
-.oo-ui-optionWidget {
-       position: relative;
-       display: block;
-       padding: 0.25em 0.5em;
-       border: 0;
-}
-.oo-ui-optionWidget.oo-ui-widget-enabled {
-       cursor: pointer;
-}
-.oo-ui-optionWidget.oo-ui-labelElement .oo-ui-labelElement-label {
-       display: block;
-       white-space: nowrap;
-       text-overflow: ellipsis;
-       overflow: hidden;
-}
-.oo-ui-optionWidget-highlighted {
-       background-color: #eeeeee;
-}
-.oo-ui-optionWidget .oo-ui-labelElement-label {
-       line-height: 1.5em;
-}
-.oo-ui-selectWidget-depressed .oo-ui-optionWidget-selected,
-.oo-ui-selectWidget-pressed .oo-ui-optionWidget-pressed,
-.oo-ui-selectWidget-pressed .oo-ui-optionWidget-pressed.oo-ui-optionWidget-highlighted,
-.oo-ui-selectWidget-pressed .oo-ui-optionWidget-pressed.oo-ui-optionWidget-highlighted.oo-ui-optionWidget-selected {
-       background-color: #d0d0d0;
-}
-.oo-ui-optionWidget.oo-ui-widget-disabled {
-       color: #cccccc;
-}
-.oo-ui-decoratedOptionWidget {
-       padding: 0.5em 2em 0.5em 3em;
-}
-.oo-ui-decoratedOptionWidget .oo-ui-iconElement-icon,
-.oo-ui-decoratedOptionWidget .oo-ui-indicatorElement-indicator {
-       position: absolute;
-}
-.oo-ui-decoratedOptionWidget.oo-ui-iconElement .oo-ui-iconElement-icon,
-.oo-ui-decoratedOptionWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
-       top: 0;
-       height: 100%;
-}
-.oo-ui-decoratedOptionWidget.oo-ui-iconElement .oo-ui-iconElement-icon {
-       width: 1.875em;
-       left: 0.5em;
-}
-.oo-ui-decoratedOptionWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
-       width: 0.9375em;
-       right: 0.5em;
-}
-.oo-ui-decoratedOptionWidget.oo-ui-widget-disabled .oo-ui-iconElement-icon,
-.oo-ui-decoratedOptionWidget.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator {
-       opacity: 0.2;
-}
-.oo-ui-radioOptionWidget {
-       cursor: default;
-       padding: 0.25em 0;
-       background-color: transparent;
-}
-.oo-ui-radioOptionWidget .oo-ui-radioInputWidget,
-.oo-ui-radioOptionWidget.oo-ui-labelElement .oo-ui-labelElement-label {
-       display: inline-block;
-       vertical-align: middle;
-}
-.oo-ui-radioOptionWidget.oo-ui-optionWidget-selected,
-.oo-ui-radioOptionWidget.oo-ui-optionWidget-pressed,
-.oo-ui-radioOptionWidget.oo-ui-optionWidget-highlighted {
-       background-color: transparent;
-}
-.oo-ui-radioOptionWidget.oo-ui-labelElement .oo-ui-labelElement-label {
-       padding: 0.25em 0.25em 0.25em 1em;
-}
-.oo-ui-radioOptionWidget .oo-ui-radioInputWidget {
-       margin-right: 0;
-}
-.oo-ui-labelWidget {
-       display: inline-block;
-}
-.oo-ui-iconWidget {
-       display: inline-block;
-       vertical-align: middle;
-       line-height: 2.5em;
-       width: 1.875em;
-       height: 1.875em;
-}
-.oo-ui-iconWidget.oo-ui-widget-disabled {
-       opacity: 0.2;
-}
-.oo-ui-indicatorWidget {
-       display: inline-block;
-       vertical-align: middle;
-       line-height: 2.5em;
-       width: 0.9375em;
-       height: 0.9375em;
-       margin: 0.46875em;
-}
-.oo-ui-indicatorWidget.oo-ui-widget-disabled {
-       opacity: 0.2;
-}
-.oo-ui-buttonWidget {
-       display: inline-block;
-       vertical-align: middle;
-       margin-right: 0.5em;
-}
-.oo-ui-buttonWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-buttonGroupWidget {
-       display: inline-block;
-       white-space: nowrap;
-       border-radius: 2px;
-       margin-right: 0.5em;
-}
-.oo-ui-buttonGroupWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-buttonGroupWidget .oo-ui-buttonElement {
-       margin-right: 0;
-}
-.oo-ui-buttonGroupWidget .oo-ui-buttonElement:last-child {
-       margin-right: 0;
-}
-.oo-ui-buttonGroupWidget .oo-ui-buttonElement-framed .oo-ui-buttonElement-button {
-       border-radius: 0;
-       margin-left: -1px;
-}
-.oo-ui-buttonGroupWidget .oo-ui-buttonElement-framed:first-child .oo-ui-buttonElement-button {
-       border-bottom-left-radius: 2px;
-       border-top-left-radius: 2px;
-       margin-left: 0;
-}
-.oo-ui-buttonGroupWidget .oo-ui-buttonElement-framed:last-child .oo-ui-buttonElement-button {
-       border-bottom-right-radius: 2px;
-       border-top-right-radius: 2px;
-}
-.oo-ui-popupWidget {
-       position: absolute;
-       /* @noflip */
-       left: 0;
-}
-.oo-ui-popupWidget-popup {
-       position: relative;
-       overflow: hidden;
-       z-index: 1;
-}
-.oo-ui-popupWidget-anchor {
-       display: none;
-       z-index: 1;
-}
-.oo-ui-popupWidget-anchored .oo-ui-popupWidget-anchor {
-       display: block;
-       position: absolute;
-       top: 0;
-       /* @noflip */
-       left: 0;
-       background-repeat: no-repeat;
-}
-.oo-ui-popupWidget-head {
-       -webkit-touch-callout: none;
-       -webkit-user-select: none;
-          -moz-user-select: none;
-           -ms-user-select: none;
-               user-select: none;
-}
-.oo-ui-popupWidget-head > .oo-ui-buttonWidget {
-       float: right;
-}
-.oo-ui-popupWidget-head > .oo-ui-labelElement-label {
-       float: left;
-       cursor: default;
-}
-.oo-ui-popupWidget-body {
-       clear: both;
-       overflow: hidden;
-}
-.oo-ui-popupWidget-popup {
-       background-color: #ffffff;
-       border: 1px solid #aaaaaa;
-       border-radius: 2px;
-       box-shadow: 0 0.15em 0 0 rgba(0, 0, 0, 0.15);
-}
-.oo-ui-popupWidget-anchored .oo-ui-popupWidget-popup {
-       margin-top: 9px;
-}
-.oo-ui-popupWidget-anchored .oo-ui-popupWidget-anchor:before,
-.oo-ui-popupWidget-anchored .oo-ui-popupWidget-anchor:after {
-       content: "";
-       position: absolute;
-       width: 0;
-       height: 0;
-       border-style: solid;
-       border-color: transparent;
-       border-top: 0;
-}
-.oo-ui-popupWidget-anchored .oo-ui-popupWidget-anchor:before {
-       bottom: -10px;
-       left: -9px;
-       border-bottom-color: #888888;
-       border-width: 10px;
-}
-.oo-ui-popupWidget-anchored .oo-ui-popupWidget-anchor:after {
-       bottom: -10px;
-       left: -8px;
-       border-bottom-color: #ffffff;
-       border-width: 9px;
-}
-.oo-ui-popupWidget-transitioning .oo-ui-popupWidget-popup {
-       -webkit-transition: width 100ms ease, height 100ms ease, left 100ms ease;
-          -moz-transition: width 100ms ease, height 100ms ease, left 100ms ease;
-               transition: width 100ms ease, height 100ms ease, left 100ms ease;
-}
-.oo-ui-popupWidget-head {
-       height: 2.5em;
-}
-.oo-ui-popupWidget-head > .oo-ui-buttonWidget {
-       margin: 0.25em;
-}
-.oo-ui-popupWidget-head > .oo-ui-labelElement-label {
-       margin: 0.75em 1em;
-}
-.oo-ui-popupWidget-body-padded {
-       padding: 0 1em;
-}
-.oo-ui-popupButtonWidget {
-       position: relative;
-}
-.oo-ui-popupButtonWidget .oo-ui-popupWidget {
-       position: absolute;
-       cursor: auto;
-}
-.oo-ui-popupButtonWidget.oo-ui-buttonElement-frameless > .oo-ui-popupWidget {
-       /* @noflip */
-       left: 1em;
-}
-.oo-ui-popupButtonWidget.oo-ui-buttonElement-framed > .oo-ui-popupWidget {
-       /* @noflip */
-       left: 1.75em;
-}
-.oo-ui-inputWidget {
-       margin-right: 0.5em;
-}
-.oo-ui-inputWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-buttonInputWidget {
-       display: inline-block;
-       vertical-align: middle;
-}
-.oo-ui-buttonInputWidget > button,
-.oo-ui-buttonInputWidget > input {
-       border: 0;
-       padding: 0;
-       background-color: transparent;
-}
-.oo-ui-checkboxInputWidget {
-       position: relative;
-       line-height: 1.6em;
-       white-space: nowrap;
-}
-.oo-ui-checkboxInputWidget * {
-       font: inherit;
-       vertical-align: middle;
-}
-.oo-ui-checkboxInputWidget input[type="checkbox"] {
-       opacity: 0;
-       z-index: 1;
-       position: relative;
-       cursor: pointer;
-       margin: 0;
-       width: 1.6em;
-       height: 1.6em;
-       max-width: none;
-}
-.oo-ui-checkboxInputWidget input[type="checkbox"] + span {
-       -webkit-transition: background-size 200ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
-          -moz-transition: background-size 200ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
-               transition: background-size 200ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-       position: absolute;
-       left: 0;
-       border-radius: 2px;
-       width: 1.6em;
-       height: 1.6em;
-       background-color: white;
-       border: 1px solid #777777;
-       background-image: url("themes/mediawiki/images/icons/check-constructive.png");
-       background-image: -webkit-linear-gradient(transparent, transparent), /* @embed */ url("themes/mediawiki/images/icons/check-constructive.svg");
-       background-image:         linear-gradient(transparent, transparent), /* @embed */ url("themes/mediawiki/images/icons/check-constructive.svg");
-       background-image:      -o-linear-gradient(transparent, transparent), url("themes/mediawiki/images/icons/check-constructive.png");
-       background-repeat: no-repeat;
-       background-position: center center;
-       background-origin: border-box;
-       background-size: 0 0;
-}
-.oo-ui-checkboxInputWidget input[type="checkbox"]:checked + span {
-       background-size: 100% 100%;
-}
-.oo-ui-checkboxInputWidget input[type="checkbox"]:active + span {
-       background-color: #cccccc;
-       border-color: #cccccc;
-}
-.oo-ui-checkboxInputWidget input[type="checkbox"]:focus + span {
-       border-width: 2px;
-}
-.oo-ui-checkboxInputWidget input[type="checkbox"]:focus:hover + span,
-.oo-ui-checkboxInputWidget input[type="checkbox"]:hover + span {
-       border-bottom-width: 3px;
-}
-.oo-ui-checkboxInputWidget input[type="checkbox"]:disabled {
-       cursor: default;
-}
-.oo-ui-checkboxInputWidget input[type="checkbox"]:disabled + span {
-       background-color: #dddddd;
-       border-color: #dddddd;
-}
-.oo-ui-checkboxInputWidget input[type="checkbox"]:disabled:checked + span {
-       background-image: url("themes/mediawiki/images/icons/check-invert.png");
-       background-image: -webkit-linear-gradient(transparent, transparent), /* @embed */ url("themes/mediawiki/images/icons/check-invert.svg");
-       background-image:         linear-gradient(transparent, transparent), /* @embed */ url("themes/mediawiki/images/icons/check-invert.svg");
-       background-image:      -o-linear-gradient(transparent, transparent), url("themes/mediawiki/images/icons/check-invert.png");
-}
-.oo-ui-dropdownInputWidget {
-       position: relative;
-       vertical-align: middle;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-       width: 100%;
-       max-width: 50em;
-}
-.oo-ui-dropdownInputWidget select {
-       display: inline-block;
-       width: 100%;
-       resize: none;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-dropdownInputWidget select {
-       background-color: #ffffff;
-       height: 2.275em;
-       font-size: inherit;
-       font-family: inherit;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-       border: 1px solid #cccccc;
-       border-radius: 2px;
-       padding-left: 1em;
-       vertical-align: middle;
-}
-.oo-ui-dropdownInputWidget.oo-ui-widget-enabled select:hover,
-.oo-ui-dropdownInputWidget.oo-ui-widget-enabled select:focus {
-       border-color: #aaaaaa;
-       outline: none;
-}
-.oo-ui-dropdownInputWidget.oo-ui-widget-disabled select {
-       color: #cccccc;
-       border-color: #dddddd;
-       background-color: #f3f3f3;
-}
-.oo-ui-radioInputWidget {
-       position: relative;
-       line-height: 1.6em;
-       white-space: nowrap;
-}
-.oo-ui-radioInputWidget * {
-       font: inherit;
-       vertical-align: middle;
-}
-.oo-ui-radioInputWidget input[type="radio"] {
-       opacity: 0;
-       z-index: 1;
-       position: relative;
-       cursor: pointer;
-       margin: 0;
-       width: 1.6em;
-       height: 1.6em;
-       max-width: none;
-}
-.oo-ui-radioInputWidget input[type="radio"] + span {
-       -webkit-transition: background-size 200ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
-          -moz-transition: background-size 200ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
-               transition: background-size 200ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-       position: absolute;
-       left: 0;
-       border-radius: 100%;
-       width: 1.6em;
-       height: 1.6em;
-       background: white;
-       border: 1px solid #777777;
-       background-image: url("themes/mediawiki/images/icons/circle-constructive.png");
-       background-image: -webkit-linear-gradient(transparent, transparent), /* @embed */ url("themes/mediawiki/images/icons/circle-constructive.svg");
-       background-image:         linear-gradient(transparent, transparent), /* @embed */ url("themes/mediawiki/images/icons/circle-constructive.svg");
-       background-image:      -o-linear-gradient(transparent, transparent), url("themes/mediawiki/images/icons/circle-constructive.png");
-       background-repeat: no-repeat;
-       background-position: center center;
-       background-origin: border-box;
-       background-size: 0 0;
-}
-.oo-ui-radioInputWidget input[type="radio"]:checked + span {
-       background-size: 100% 100%;
-}
-.oo-ui-radioInputWidget input[type="radio"]:active + span {
-       background-color: #cccccc;
-       border-color: #cccccc;
-}
-.oo-ui-radioInputWidget input[type="radio"]:focus + span {
-       border-width: 2px;
-}
-.oo-ui-radioInputWidget input[type="radio"]:focus:hover + span,
-.oo-ui-radioInputWidget input[type="radio"]:hover + span {
-       border-bottom-width: 3px;
-}
-.oo-ui-radioInputWidget input[type="radio"]:disabled {
-       cursor: default;
-}
-.oo-ui-radioInputWidget input[type="radio"]:disabled + span {
-       background-color: #dddddd;
-       border-color: #dddddd;
-}
-.oo-ui-radioInputWidget input[type="radio"]:disabled:checked + span {
-       background-image: url("themes/mediawiki/images/icons/circle-invert.png");
-       background-image: -webkit-linear-gradient(transparent, transparent), /* @embed */ url("themes/mediawiki/images/icons/circle-invert.svg");
-       background-image:         linear-gradient(transparent, transparent), /* @embed */ url("themes/mediawiki/images/icons/circle-invert.svg");
-       background-image:      -o-linear-gradient(transparent, transparent), url("themes/mediawiki/images/icons/circle-invert.png");
-}
-.oo-ui-radioSelectInputWidget .oo-ui-fieldLayout {
-       margin-bottom: 0;
-}
-.oo-ui-textInputWidget {
-       position: relative;
-       vertical-align: middle;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-       width: 100%;
-       max-width: 50em;
-}
-.oo-ui-textInputWidget input,
-.oo-ui-textInputWidget textarea {
-       display: inline-block;
-       width: 100%;
-       resize: none;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-textInputWidget textarea {
-       overflow: auto;
-}
-.oo-ui-textInputWidget input[type="search"] {
-       -webkit-appearance: none;
-}
-.oo-ui-textInputWidget input[type="search"]::-ms-clear {
-       display: none;
-}
-.oo-ui-textInputWidget input[type="search"]::-ms-reveal {
-       display: none;
-}
-.oo-ui-textInputWidget input[type="search"]::-webkit-search-decoration,
-.oo-ui-textInputWidget input[type="search"]::-webkit-search-cancel-button,
-.oo-ui-textInputWidget input[type="search"]::-webkit-search-results-button,
-.oo-ui-textInputWidget input[type="search"]::-webkit-search-results-decoration {
-       display: none;
-}
-.oo-ui-textInputWidget > .oo-ui-iconElement-icon,
-.oo-ui-textInputWidget > .oo-ui-indicatorElement-indicator,
-.oo-ui-textInputWidget > .oo-ui-labelElement-label {
-       display: none;
-}
-.oo-ui-textInputWidget.oo-ui-iconElement > .oo-ui-iconElement-icon,
-.oo-ui-textInputWidget.oo-ui-indicatorElement > .oo-ui-indicatorElement-indicator {
-       display: block;
-       position: absolute;
-       top: 0;
-       height: 100%;
-       -webkit-touch-callout: none;
-       -webkit-user-select: none;
-          -moz-user-select: none;
-           -ms-user-select: none;
-               user-select: none;
-}
-.oo-ui-textInputWidget.oo-ui-widget-enabled > .oo-ui-iconElement-icon,
-.oo-ui-textInputWidget.oo-ui-widget-enabled > .oo-ui-indicatorElement-indicator {
-       cursor: text;
-}
-.oo-ui-textInputWidget.oo-ui-widget-enabled.oo-ui-textInputWidget-type-search > .oo-ui-indicatorElement-indicator {
-       cursor: pointer;
-}
-.oo-ui-textInputWidget.oo-ui-labelElement > .oo-ui-labelElement-label {
-       display: block;
-}
-.oo-ui-textInputWidget > .oo-ui-iconElement-icon {
-       left: 0;
-}
-.oo-ui-textInputWidget > .oo-ui-indicatorElement-indicator {
-       right: 0;
-}
-.oo-ui-textInputWidget > .oo-ui-labelElement-label {
-       position: absolute;
-       top: 0;
-}
-.oo-ui-textInputWidget-labelPosition-after > .oo-ui-labelElement-label {
-       right: 0;
-}
-.oo-ui-textInputWidget-labelPosition-before > .oo-ui-labelElement-label {
-       left: 0;
-}
-.oo-ui-textInputWidget input,
-.oo-ui-textInputWidget textarea {
-       padding: 0.5em;
-       line-height: 1.275em;
-       margin: 0;
-       font-size: inherit;
-       font-family: inherit;
-       background-color: #ffffff;
-       color: #000000;
-       border: 1px solid #cccccc;
-       border-radius: 2px;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-textInputWidget input.oo-ui-pendingElement-pending,
-.oo-ui-textInputWidget textarea.oo-ui-pendingElement-pending {
-       background-color: transparent;
-}
-.oo-ui-textInputWidget.oo-ui-widget-enabled input,
-.oo-ui-textInputWidget.oo-ui-widget-enabled textarea {
-       box-shadow: inset 0 0 0 0.1em #ffffff;
-       -webkit-transition: border 200ms cubic-bezier(0.39, 0.575, 0.565, 1), box-shadow 200ms cubic-bezier(0.39, 0.575, 0.565, 1);
-          -moz-transition: border 200ms cubic-bezier(0.39, 0.575, 0.565, 1), box-shadow 200ms cubic-bezier(0.39, 0.575, 0.565, 1);
-               transition: border 200ms cubic-bezier(0.39, 0.575, 0.565, 1), box-shadow 200ms cubic-bezier(0.39, 0.575, 0.565, 1);
-}
-.oo-ui-textInputWidget.oo-ui-widget-enabled input:focus,
-.oo-ui-textInputWidget.oo-ui-widget-enabled textarea:focus {
-       outline: none;
-       border-color: #347bff;
-       box-shadow: inset 0 0 0 0.1em #347bff;
-}
-.oo-ui-textInputWidget.oo-ui-widget-enabled input[readonly],
-.oo-ui-textInputWidget.oo-ui-widget-enabled textarea[readonly] {
-       color: #777777;
-       text-shadow: 0 1px 1px #ffffff;
-}
-.oo-ui-textInputWidget.oo-ui-widget-enabled input[readonly]:focus,
-.oo-ui-textInputWidget.oo-ui-widget-enabled textarea[readonly]:focus {
-       border-color: #cccccc;
-       box-shadow: inset 0 0 0 0.1em #cccccc;
-}
-.oo-ui-textInputWidget.oo-ui-widget-enabled.oo-ui-flaggedElement-invalid input,
-.oo-ui-textInputWidget.oo-ui-widget-enabled.oo-ui-flaggedElement-invalid textarea {
-       border-color: #ff0000;
-}
-.oo-ui-textInputWidget.oo-ui-widget-enabled.oo-ui-flaggedElement-invalid input:focus,
-.oo-ui-textInputWidget.oo-ui-widget-enabled.oo-ui-flaggedElement-invalid textarea:focus {
-       border-color: #ff0000;
-       box-shadow: inset 0 0 0 0.1em #ff0000;
-}
-.oo-ui-textInputWidget.oo-ui-widget-disabled input,
-.oo-ui-textInputWidget.oo-ui-widget-disabled textarea {
-       color: #cccccc;
-       text-shadow: 0 1px 1px #ffffff;
-       border-color: #dddddd;
-       background-color: #f3f3f3;
-}
-.oo-ui-textInputWidget.oo-ui-widget-disabled .oo-ui-iconElement-icon,
-.oo-ui-textInputWidget.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator {
-       opacity: 0.2;
-}
-.oo-ui-textInputWidget.oo-ui-widget-disabled .oo-ui-labelElement-label {
-       color: #dddddd;
-       text-shadow: 0 1px 1px #ffffff;
-}
-.oo-ui-textInputWidget.oo-ui-iconElement input,
-.oo-ui-textInputWidget.oo-ui-iconElement textarea {
-       padding-left: 2.875em;
-}
-.oo-ui-textInputWidget.oo-ui-iconElement .oo-ui-iconElement-icon {
-       left: 0;
-       width: 1.875em;
-       max-height: 2.375em;
-       margin-left: 0.5em;
-       height: 100%;
-       background-position: right center;
-}
-.oo-ui-textInputWidget.oo-ui-indicatorElement input,
-.oo-ui-textInputWidget.oo-ui-indicatorElement textarea {
-       padding-right: 2.4875em;
-}
-.oo-ui-textInputWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
-       width: 0.9375em;
-       max-height: 2.375em;
-       margin: 0 0.775em;
-       height: 100%;
-}
-.oo-ui-textInputWidget > .oo-ui-labelElement-label {
-       padding: 0.4em;
-       line-height: 1.5em;
-       color: #888888;
-}
-.oo-ui-textInputWidget-labelPosition-after.oo-ui-indicatorElement > .oo-ui-labelElement-label {
-       margin-right: 2.0875em;
-}
-.oo-ui-textInputWidget-labelPosition-before.oo-ui-iconElement > .oo-ui-labelElement-label {
-       margin-left: 2.475em;
-}
-.oo-ui-menuSelectWidget {
-       position: absolute;
-       background-color: #ffffff;
-       margin-top: -1px;
-       border: 1px solid #aaaaaa;
-       border-radius: 0 0 2px 2px;
-       box-shadow: 0 0.15em 0 0 rgba(0, 0, 0, 0.15);
-}
-.oo-ui-menuSelectWidget input {
-       position: absolute;
-       width: 0;
-       height: 0;
-       overflow: hidden;
-       opacity: 0;
-}
-.oo-ui-menuOptionWidget {
-       position: relative;
-       padding: 0.5em 1em;
-}
-.oo-ui-menuOptionWidget .oo-ui-iconElement-icon {
-       display: none;
-}
-.oo-ui-menuOptionWidget.oo-ui-optionWidget-selected {
-       background-color: transparent;
-}
-.oo-ui-menuOptionWidget.oo-ui-optionWidget-selected .oo-ui-iconElement-icon {
-       display: block;
-}
-.oo-ui-menuOptionWidget.oo-ui-optionWidget-selected {
-       background-color: #d8e6fe;
-       color: rgba(0, 0, 0, 0.8);
-}
-.oo-ui-menuOptionWidget.oo-ui-optionWidget-selected .oo-ui-iconElement-icon {
-       display: none;
-}
-.oo-ui-menuOptionWidget.oo-ui-optionWidget-highlighted {
-       background-color: #eeeeee;
-       color: #000000;
-}
-.oo-ui-menuOptionWidget.oo-ui-optionWidget-selected.oo-ui-menuOptionWidget.oo-ui-optionWidget-highlighted {
-       background-color: #d8e6fe;
-}
-.oo-ui-menuSectionOptionWidget {
-       cursor: default;
-       padding: 0.33em 0.75em;
-       color: #888888;
-}
-.oo-ui-dropdownWidget {
-       display: inline-block;
-       position: relative;
-       width: 100%;
-       max-width: 50em;
-       background-color: #ffffff;
-       margin-right: 0.5em;
-}
-.oo-ui-dropdownWidget-handle {
-       width: 100%;
-       display: inline-block;
-       white-space: nowrap;
-       overflow: hidden;
-       text-overflow: ellipsis;
-       -webkit-touch-callout: none;
-       -webkit-user-select: none;
-          -moz-user-select: none;
-           -ms-user-select: none;
-               user-select: none;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-dropdownWidget-handle .oo-ui-indicatorElement-indicator,
-.oo-ui-dropdownWidget-handle .oo-ui-iconElement-icon {
-       position: absolute;
-}
-.oo-ui-dropdownWidget > .oo-ui-menuSelectWidget {
-       z-index: 1;
-       width: 100%;
-}
-.oo-ui-dropdownWidget.oo-ui-widget-enabled .oo-ui-dropdownWidget-handle {
-       cursor: pointer;
-}
-.oo-ui-dropdownWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-dropdownWidget-handle {
-       padding: 0.5em 0;
-       height: 2.275em;
-       line-height: 1.275;
-       border: 1px solid #cccccc;
-       border-radius: 2px;
-}
-.oo-ui-dropdownWidget-handle .oo-ui-indicatorElement-indicator {
-       right: 0;
-}
-.oo-ui-dropdownWidget-handle .oo-ui-iconElement-icon {
-       left: 0.25em;
-}
-.oo-ui-dropdownWidget-handle .oo-ui-labelElement-label {
-       line-height: 1.275em;
-       margin: 0 1em;
-}
-.oo-ui-dropdownWidget-handle .oo-ui-indicatorElement-indicator {
-       top: 0;
-       width: 0.9375em;
-       height: 0.9375em;
-       margin: 0.775em;
-}
-.oo-ui-dropdownWidget-handle .oo-ui-iconElement-icon {
-       top: 0;
-       width: 1.875em;
-       height: 1.875em;
-       margin: 0.3em;
-}
-.oo-ui-dropdownWidget:hover .oo-ui-dropdownWidget-handle {
-       border-color: #aaaaaa;
-}
-.oo-ui-dropdownWidget.oo-ui-widget-disabled .oo-ui-dropdownWidget-handle {
-       color: #cccccc;
-       text-shadow: 0 1px 1px #ffffff;
-       border-color: #dddddd;
-       background-color: #f3f3f3;
-}
-.oo-ui-dropdownWidget.oo-ui-widget-disabled .oo-ui-dropdownWidget-handle:focus {
-       outline: 0;
-}
-.oo-ui-dropdownWidget.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator {
-       opacity: 0.2;
-}
-.oo-ui-dropdownWidget.oo-ui-iconElement .oo-ui-dropdownWidget-handle .oo-ui-labelElement-label {
-       margin-left: 3em;
-}
-.oo-ui-dropdownWidget.oo-ui-indicatorElement .oo-ui-dropdownWidget-handle .oo-ui-labelElement-label {
-       margin-right: 2em;
-}
-.oo-ui-dropdownWidget .oo-ui-selectWidget {
-       border-top-color: #ffffff;
-}
-.oo-ui-comboBoxInputWidget {
-       display: inline-block;
-       position: relative;
-       width: 100%;
-       max-width: 50em;
-       margin-right: 0.5em;
-}
-.oo-ui-comboBoxInputWidget > .oo-ui-menuSelectWidget {
-       z-index: 1;
-       width: 100%;
-}
-.oo-ui-comboBoxInputWidget.oo-ui-widget-enabled > .oo-ui-indicatorElement-indicator {
-       cursor: pointer;
-}
-.oo-ui-comboBoxInputWidget-php input::-webkit-calendar-picker-indicator {
-       opacity: 0 !important;
-       position: absolute;
-       right: 0;
-       top: 0;
-       height: 2.5em;
-       width: 2.5em;
-       padding: 0;
-}
-.oo-ui-comboBoxInputWidget-php > .oo-ui-indicatorElement-indicator {
-       pointer-events: none;
-}
-.oo-ui-comboBoxInputWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-comboBoxInputWidget input,
-.oo-ui-comboBoxInputWidget textarea {
-       height: 2.35em;
-}
-
-/*!
- * OOjs UI v0.15.2
- * https://www.mediawiki.org/wiki/OOjs_UI
- *
- * Copyright 2011–2016 OOjs UI Team and other contributors.
- * Released under the MIT license
- * http://oojs.mit-license.org
- *
- * Date: 2016-02-02T22:07:06Z
- */
-@-webkit-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@-moz-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-.oo-ui-draggableElement {
-       cursor: -webkit-grab -moz-grab, url(images/grab.cur), move;
-}
-.oo-ui-draggableElement-dragging {
-       cursor: -webkit-grabbing -moz-grabbing, url(images/grabbing.cur), move;
-       background: rgba(0, 0, 0, 0.2);
-       opacity: 0.4;
-}
-.oo-ui-draggableGroupElement-horizontal .oo-ui-draggableElement.oo-ui-optionWidget {
-       display: inline-block;
-}
-.oo-ui-draggableGroupElement-placeholder {
-       position: absolute;
-       display: block;
-       background: rgba(0, 0, 0, 0.4);
-}
-.oo-ui-lookupElement > .oo-ui-menuSelectWidget {
-       z-index: 1;
-       width: 100%;
-}
-.oo-ui-bookletLayout-stackLayout.oo-ui-stackLayout-continuous > .oo-ui-panelLayout-scrollable {
-       overflow-y: hidden;
-}
-.oo-ui-bookletLayout-stackLayout > .oo-ui-panelLayout {
-       width: 100%;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-bookletLayout-stackLayout > .oo-ui-panelLayout-scrollable {
-       overflow-y: auto;
-}
-.oo-ui-bookletLayout-stackLayout > .oo-ui-panelLayout-padded {
-       padding: 2em;
-}
-.oo-ui-bookletLayout-outlinePanel-editable > .oo-ui-outlineSelectWidget {
-       position: absolute;
-       top: 0;
-       left: 0;
-       right: 0;
-       bottom: 3em;
-       overflow-y: auto;
-}
-.oo-ui-bookletLayout-outlinePanel > .oo-ui-outlineControlsWidget {
-       position: absolute;
-       bottom: 0;
-       left: 0;
-       right: 0;
-}
-.oo-ui-bookletLayout-stackLayout > .oo-ui-panelLayout {
-       padding: 1.5em;
-}
-.oo-ui-bookletLayout-outlinePanel {
-       border-right: 1px solid #dddddd;
-}
-.oo-ui-bookletLayout-outlinePanel > .oo-ui-outlineControlsWidget {
-       box-shadow: 0 0.15em 0 0 rgba(0, 0, 0, 0.15);
-}
-.oo-ui-indexLayout > .oo-ui-menuLayout-menu {
-       height: 3em;
-}
-.oo-ui-indexLayout > .oo-ui-menuLayout-content {
-       top: 3em;
-}
-.oo-ui-indexLayout-stackLayout > .oo-ui-panelLayout {
-       padding: 1.5em;
-}
-.oo-ui-indexLayout > .oo-ui-menuLayout-menu {
-       height: 2.75em;
-}
-.oo-ui-indexLayout > .oo-ui-menuLayout-content {
-       top: 2.75em;
-}
-.oo-ui-menuLayout {
-       position: absolute;
-       top: 0;
-       left: 0;
-       right: 0;
-       bottom: 0;
-}
-.oo-ui-menuLayout-menu,
-.oo-ui-menuLayout-content {
-       position: absolute;
-       -webkit-transition: all 200ms ease;
-          -moz-transition: all 200ms ease;
-               transition: all 200ms ease;
-}
-.oo-ui-menuLayout-menu {
-       height: 18em;
-       width: 18em;
-}
-.oo-ui-menuLayout-content {
-       top: 18em;
-       left: 18em;
-       right: 18em;
-       bottom: 18em;
-}
-.oo-ui-menuLayout.oo-ui-menuLayout-hideMenu > .oo-ui-menuLayout-menu {
-       width: 0 !important;
-       height: 0 !important;
-       overflow: hidden;
-}
-.oo-ui-menuLayout.oo-ui-menuLayout-hideMenu > .oo-ui-menuLayout-content {
-       top: 0 !important;
-       left: 0 !important;
-       right: 0 !important;
-       bottom: 0 !important;
-}
-.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-top > .oo-ui-menuLayout-menu {
-       width: auto !important;
-       left: 0;
-       top: 0;
-       right: 0;
-}
-.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-top > .oo-ui-menuLayout-content {
-       right: 0 !important;
-       bottom: 0 !important;
-       left: 0 !important;
-}
-.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-after > .oo-ui-menuLayout-menu {
-       height: auto !important;
-       top: 0;
-       right: 0;
-       bottom: 0;
-}
-.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-after > .oo-ui-menuLayout-content {
-       bottom: 0 !important;
-       left: 0 !important;
-       top: 0 !important;
-}
-.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-bottom > .oo-ui-menuLayout-menu {
-       width: auto !important;
-       right: 0;
-       bottom: 0;
-       left: 0;
-}
-.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-bottom > .oo-ui-menuLayout-content {
-       left: 0 !important;
-       top: 0 !important;
-       right: 0 !important;
-}
-.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-before > .oo-ui-menuLayout-menu {
-       height: auto !important;
-       bottom: 0;
-       left: 0;
-       top: 0;
-}
-.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-before > .oo-ui-menuLayout-content {
-       top: 0 !important;
-       right: 0 !important;
-       bottom: 0 !important;
-}
-.oo-ui-stackLayout-continuous > .oo-ui-panelLayout {
-       display: block;
-       position: relative;
-}
-.oo-ui-buttonSelectWidget {
-       display: inline-block;
-       white-space: nowrap;
-       border-radius: 2px;
-       margin-right: 0.5em;
-}
-.oo-ui-buttonSelectWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-buttonSelectWidget .oo-ui-buttonOptionWidget .oo-ui-buttonElement-button {
-       border-radius: 0;
-       margin-left: -1px;
-}
-.oo-ui-buttonSelectWidget .oo-ui-buttonOptionWidget:first-child .oo-ui-buttonElement-button {
-       border-bottom-left-radius: 2px;
-       border-top-left-radius: 2px;
-       margin-left: 0;
-}
-.oo-ui-buttonSelectWidget .oo-ui-buttonOptionWidget:last-child .oo-ui-buttonElement-button {
-       border-bottom-right-radius: 2px;
-       border-top-right-radius: 2px;
-}
-.oo-ui-buttonOptionWidget {
-       display: inline-block;
-       padding: 0;
-       background-color: transparent;
-}
-.oo-ui-buttonOptionWidget .oo-ui-buttonElement-button {
-       position: relative;
-}
-.oo-ui-buttonOptionWidget.oo-ui-iconElement .oo-ui-iconElement-icon,
-.oo-ui-buttonOptionWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
-       position: static;
-       display: inline-block;
-       vertical-align: middle;
-}
-.oo-ui-buttonOptionWidget.oo-ui-iconElement .oo-ui-iconElement-icon {
-       margin-top: 0;
-}
-.oo-ui-buttonOptionWidget.oo-ui-optionWidget-selected,
-.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed,
-.oo-ui-buttonOptionWidget.oo-ui-optionWidget-highlighted {
-       background-color: transparent;
-}
-.oo-ui-buttonOptionWidget.oo-ui-widget-disabled .oo-ui-iconElement-icon,
-.oo-ui-buttonOptionWidget.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator {
-       opacity: 1;
-}
-.oo-ui-toggleButtonWidget {
-       display: inline-block;
-       vertical-align: middle;
-       margin-right: 0.5em;
-}
-.oo-ui-toggleButtonWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-toggleSwitchWidget {
-       position: relative;
-       display: inline-block;
-       vertical-align: middle;
-       overflow: hidden;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-       -webkit-transform: translateZ(0);
-          -moz-transform: translateZ(0);
-           -ms-transform: translateZ(0);
-               transform: translateZ(0);
-       height: 2em;
-       width: 3.5em;
-       border: 1px solid #777777;
-       border-radius: 1em;
-       background-color: #ffffff;
-       margin-right: 0.5em;
-       -webkit-transition: background-color 100ms ease, border-color 100ms ease;
-          -moz-transition: background-color 100ms ease, border-color 100ms ease;
-               transition: background-color 100ms ease, border-color 100ms ease;
-}
-.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled {
-       cursor: pointer;
-}
-.oo-ui-toggleSwitchWidget-grip {
-       position: absolute;
-       display: block;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-toggleSwitchWidget .oo-ui-toggleSwitchWidget-glow {
-       position: absolute;
-       top: 0;
-       bottom: 0;
-       right: 0;
-       left: 0;
-       -webkit-touch-callout: none;
-       -webkit-user-select: none;
-          -moz-user-select: none;
-           -ms-user-select: none;
-               user-select: none;
-}
-.oo-ui-toggleWidget-off .oo-ui-toggleSwitchWidget-glow {
-       display: none;
-}
-.oo-ui-toggleSwitchWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-toggleSwitchWidget:before {
-       content: "";
-       display: block;
-       position: absolute;
-       top: 0;
-       left: 0;
-       bottom: 0;
-       right: 0;
-       border: 1px solid transparent;
-       border-radius: 1em;
-       z-index: 1;
-}
-.oo-ui-toggleSwitchWidget-grip {
-       top: 0.35em;
-       width: 1.2em;
-       height: 1.2em;
-       border-radius: 1.2em;
-       background-color: #555555;
-       -webkit-transition: left 100ms ease, margin-left 100ms ease;
-          -moz-transition: left 100ms ease, margin-left 100ms ease;
-               transition: left 100ms ease, margin-left 100ms ease;
-}
-.oo-ui-toggleSwitchWidget-glow {
-       display: none;
-}
-.oo-ui-toggleSwitchWidget.oo-ui-toggleWidget-on .oo-ui-toggleSwitchWidget-grip {
-       left: 1.9em;
-       margin-left: -2px;
-}
-.oo-ui-toggleSwitchWidget.oo-ui-toggleWidget-off .oo-ui-toggleSwitchWidget-grip {
-       left: 0.4em;
-       margin-left: 0;
-}
-.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled.oo-ui-toggleWidget-on {
-       background-color: #347bff;
-       border-color: #347bff;
-}
-.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled.oo-ui-toggleWidget-on .oo-ui-toggleSwitchWidget-grip {
-       background-color: #ffffff;
-       box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
-}
-.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:hover {
-       border-color: #2962cc;
-}
-.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:hover.oo-ui-toggleWidget-on {
-       background-color: #2962cc;
-       border-color: #2962cc;
-}
-.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:focus {
-       border-color: #347bff;
-       outline: none;
-}
-.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:focus.oo-ui-toggleWidget-on {
-       border-color: #347bff;
-}
-.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:focus.oo-ui-toggleWidget-on:before {
-       border-color: #ffffff;
-}
-.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:active,
-.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:active:hover {
-       background-color: #347bff;
-       border-color: #347bff;
-}
-.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:active .oo-ui-toggleSwitchWidget-grip,
-.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:active:hover .oo-ui-toggleSwitchWidget-grip {
-       background-color: #ffffff;
-       box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
-}
-.oo-ui-toggleSwitchWidget.oo-ui-widget-disabled {
-       background: #dddddd;
-       border-color: #dddddd;
-       outline: 0;
-}
-.oo-ui-toggleSwitchWidget.oo-ui-widget-disabled .oo-ui-toggleSwitchWidget-grip {
-       background: #ffffff;
-}
-.oo-ui-progressBarWidget {
-       max-width: 50em;
-       background-color: #ffffff;
-       border: 1px solid #cccccc;
-       border-radius: 2px;
-       overflow: hidden;
-}
-.oo-ui-progressBarWidget-bar {
-       height: 1em;
-       background: #dddddd;
-       -webkit-transition: width 200ms, margin-left 200ms;
-          -moz-transition: width 200ms, margin-left 200ms;
-               transition: width 200ms, margin-left 200ms;
-}
-.oo-ui-progressBarWidget-indeterminate .oo-ui-progressBarWidget-bar {
-       -webkit-animation: oo-ui-progressBarWidget-slide 2s infinite linear;
-          -moz-animation: oo-ui-progressBarWidget-slide 2s infinite linear;
-               animation: oo-ui-progressBarWidget-slide 2s infinite linear;
-       width: 40%;
-       margin-left: -10%;
-       border-left-width: 1px;
-}
-.oo-ui-progressBarWidget.oo-ui-widget-disabled {
-       opacity: 0.6;
-}
-.oo-ui-selectFileWidget {
-       display: inline-block;
-       vertical-align: middle;
-       width: 100%;
-       max-width: 50em;
-       margin-right: 0.5em;
-}
-.oo-ui-selectFileWidget-selectButton {
-       display: table-cell;
-       vertical-align: middle;
-}
-.oo-ui-selectFileWidget-selectButton > .oo-ui-buttonElement-button {
-       position: relative;
-       overflow: hidden;
-}
-.oo-ui-selectFileWidget-selectButton > .oo-ui-buttonElement-button > input[type="file"] {
-       position: absolute;
-       margin: 0;
-       top: 0;
-       bottom: 0;
-       left: 0;
-       right: 0;
-       width: 100%;
-       height: 100%;
-       opacity: 0;
-       z-index: 1;
-       cursor: pointer;
-       padding-top: 100px;
-}
-.oo-ui-selectFileWidget-selectButton.oo-ui-widget-disabled > .oo-ui-buttonElement-button > input[type="file"] {
-       display: none;
-}
-.oo-ui-selectFileWidget-info {
-       width: 100%;
-       display: table-cell;
-       vertical-align: middle;
-       position: relative;
-       overflow: hidden;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-label {
-       position: absolute;
-       top: 0;
-       bottom: 0;
-       left: 0;
-       right: 0;
-       text-overflow: ellipsis;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-label > .oo-ui-selectFileWidget-fileName {
-       float: left;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-label > .oo-ui-selectFileWidget-fileType {
-       float: right;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-indicatorElement-indicator,
-.oo-ui-selectFileWidget-info > .oo-ui-iconElement-icon,
-.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-clearButton {
-       position: absolute;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-clearButton {
-       z-index: 2;
-}
-.oo-ui-selectFileWidget-dropTarget {
-       cursor: default;
-}
-.oo-ui-selectFileWidget-supported.oo-ui-widget-enabled .oo-ui-selectFileWidget-dropTarget {
-       cursor: pointer;
-}
-.oo-ui-selectFileWidget-empty .oo-ui-selectFileWidget-clearButton,
-.oo-ui-selectFileWidget-notsupported .oo-ui-selectFileWidget-clearButton {
-       display: none;
-}
-.oo-ui-selectFileWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-selectFileWidget-selectButton > .oo-ui-buttonElement-button {
-       margin-left: 0.5em;
-}
-.oo-ui-selectFileWidget-info {
-       height: 2.4em;
-       background-color: #ffffff;
-       border: 1px solid #cccccc;
-       border-radius: 2px;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-indicatorElement-indicator {
-       right: 0;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-iconElement-icon {
-       left: 0;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-label {
-       line-height: 2.3em;
-       margin: 0;
-       overflow: hidden;
-       white-space: nowrap;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-       text-overflow: ellipsis;
-       left: 0.5em;
-       right: 0.5em;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-label > .oo-ui-selectFileWidget-fileType {
-       color: #888888;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-clearButton {
-       top: 0;
-       width: 1.875em;
-       margin-right: 0;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-clearButton .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
-       height: 2.3em;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-indicatorElement-indicator {
-       top: 0;
-       width: 0.9375em;
-       height: 2.3em;
-       margin-right: 0.775em;
-}
-.oo-ui-selectFileWidget-info > .oo-ui-iconElement-icon {
-       top: 0;
-       width: 1.875em;
-       height: 2.3em;
-       margin-left: 0.5em;
-}
-.oo-ui-selectFileWidget.oo-ui-widget-disabled .oo-ui-selectFileWidget-info {
-       color: #cccccc;
-       text-shadow: 0 1px 1px #ffffff;
-       border-color: #dddddd;
-       background-color: #f3f3f3;
-}
-.oo-ui-selectFileWidget.oo-ui-widget-disabled .oo-ui-selectFileWidget-info > .oo-ui-iconElement-icon,
-.oo-ui-selectFileWidget.oo-ui-widget-disabled .oo-ui-selectFileWidget-info > .oo-ui-indicatorElement-indicator {
-       opacity: 0.2;
-}
-.oo-ui-selectFileWidget-empty .oo-ui-selectFileWidget-label {
-       color: #cccccc;
-}
-.oo-ui-selectFileWidget.oo-ui-iconElement .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label {
-       left: 2.875em;
-}
-.oo-ui-selectFileWidget .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label {
-       right: 2.375em;
-}
-.oo-ui-selectFileWidget .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-clearButton {
-       right: 0;
-}
-.oo-ui-selectFileWidget.oo-ui-indicatorElement .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label {
-       right: 4.4625em;
-}
-.oo-ui-selectFileWidget.oo-ui-indicatorElement .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-clearButton {
-       right: 2.0875em;
-}
-.oo-ui-selectFileWidget-empty .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label,
-.oo-ui-selectFileWidget-notsupported .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label {
-       right: 0.5em;
-}
-.oo-ui-selectFileWidget-empty.oo-ui-indicatorElement .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label,
-.oo-ui-selectFileWidget-notsupported.oo-ui-indicatorElement .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label {
-       right: 2em;
-}
-.oo-ui-selectFileWidget-dropTarget {
-       line-height: 3.5em;
-       background-color: #ffffff;
-       border: 1px dashed #cccccc;
-       padding: 0.5em 1em;
-       margin-bottom: 0.5em;
-       text-align: center;
-       vertical-align: middle;
-}
-.oo-ui-selectFileWidget-supported.oo-ui-widget-enabled .oo-ui-selectFileWidget-dropTarget:hover {
-       background-color: #eeeeee;
-}
-.oo-ui-selectFileWidget-supported.oo-ui-widget-enabled.oo-ui-selectFileWidget-canDrop .oo-ui-selectFileWidget-dropTarget {
-       background: rgba(52, 123, 255, 0.1);
-}
-.oo-ui-selectFileWidget.oo-ui-widget-disabled .oo-ui-selectFileWidget-dropTarget,
-.oo-ui-selectFileWidget-notsupported .oo-ui-selectFileWidget-dropTarget {
-       color: #cccccc;
-       text-shadow: 0 1px 1px #ffffff;
-       border-color: #dddddd;
-       background-color: #f3f3f3;
-}
-.oo-ui-outlineOptionWidget {
-       position: relative;
-       cursor: pointer;
-       -webkit-touch-callout: none;
-       -webkit-user-select: none;
-          -moz-user-select: none;
-           -ms-user-select: none;
-               user-select: none;
-       font-size: 1.1em;
-       padding: 0.75em;
-}
-.oo-ui-outlineOptionWidget.oo-ui-indicatorElement .oo-ui-labelElement-label {
-       padding-right: 1.5em;
-}
-.oo-ui-outlineOptionWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
-       opacity: 0.5;
-}
-.oo-ui-outlineOptionWidget-level-0 {
-       padding-left: 3.5em;
-}
-.oo-ui-outlineOptionWidget-level-0 .oo-ui-iconElement-icon {
-       left: 1em;
-}
-.oo-ui-outlineOptionWidget-level-1 {
-       padding-left: 5em;
-}
-.oo-ui-outlineOptionWidget-level-1 .oo-ui-iconElement-icon {
-       left: 2.5em;
-}
-.oo-ui-outlineOptionWidget-level-2 {
-       padding-left: 6.5em;
-}
-.oo-ui-outlineOptionWidget-level-2 .oo-ui-iconElement-icon {
-       left: 4em;
-}
-.oo-ui-selectWidget-depressed .oo-ui-outlineOptionWidget.oo-ui-optionWidget-selected {
-       background-color: #d0d0d0;
-       text-shadow: 0 1px 1px #ffffff;
-}
-.oo-ui-outlineOptionWidget.oo-ui-flaggedElement-important {
-       font-weight: bold;
-}
-.oo-ui-outlineOptionWidget.oo-ui-flaggedElement-placeholder {
-       font-style: italic;
-}
-.oo-ui-outlineOptionWidget.oo-ui-flaggedElement-empty .oo-ui-iconElement-icon {
-       opacity: 0.5;
-}
-.oo-ui-outlineOptionWidget.oo-ui-flaggedElement-empty .oo-ui-labelElement-label {
-       color: #777777;
-}
-.oo-ui-outlineControlsWidget {
-       height: 3em;
-       background-color: #ffffff;
-}
-.oo-ui-outlineControlsWidget-items,
-.oo-ui-outlineControlsWidget-movers {
-       float: left;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-outlineControlsWidget > .oo-ui-iconElement-icon {
-       float: left;
-       background-position: right center;
-}
-.oo-ui-outlineControlsWidget-items {
-       float: left;
-}
-.oo-ui-outlineControlsWidget-items .oo-ui-buttonWidget {
-       float: left;
-}
-.oo-ui-outlineControlsWidget-movers {
-       float: right;
-}
-.oo-ui-outlineControlsWidget-movers .oo-ui-buttonWidget {
-       float: right;
-}
-.oo-ui-outlineControlsWidget-items,
-.oo-ui-outlineControlsWidget-movers {
-       height: 2em;
-       margin: 0.5em 0.5em 0.5em 0;
-       padding: 0;
-}
-.oo-ui-outlineControlsWidget > .oo-ui-iconElement-icon {
-       width: 1.5em;
-       height: 2em;
-       margin: 0.5em 0 0.5em 0.5em;
-       opacity: 0.2;
-}
-.oo-ui-tabSelectWidget {
-       text-align: left;
-       white-space: nowrap;
-       overflow: hidden;
-       background-color: #dddddd;
-}
-.oo-ui-tabOptionWidget {
-       display: inline-block;
-       vertical-align: bottom;
-       padding: 0.35em 1em;
-       margin: 0.5em 0 0 0.75em;
-       border: 1px solid transparent;
-       border-bottom: none;
-       border-top-left-radius: 2px;
-       border-top-right-radius: 2px;
-       color: #555555;
-       font-weight: bold;
-}
-.oo-ui-tabOptionWidget.oo-ui-widget-enabled:hover {
-       background-color: rgba(255, 255, 255, 0.3);
-}
-.oo-ui-tabOptionWidget.oo-ui-widget-enabled:active {
-       background-color: rgba(255, 255, 255, 0.8);
-}
-.oo-ui-tabOptionWidget.oo-ui-indicatorElement .oo-ui-labelElement-label {
-       padding-right: 1.5em;
-}
-.oo-ui-tabOptionWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
-       opacity: 0.5;
-}
-.oo-ui-selectWidget-pressed .oo-ui-tabOptionWidget.oo-ui-optionWidget-selected,
-.oo-ui-selectWidget-depressed .oo-ui-tabOptionWidget.oo-ui-optionWidget-selected,
-.oo-ui-tabOptionWidget.oo-ui-optionWidget-selected:hover {
-       background-color: #ffffff;
-       color: #333333;
-}
-.oo-ui-capsuleMultiSelectWidget {
-       display: inline-block;
-       position: relative;
-       width: 100%;
-       max-width: 50em;
-}
-.oo-ui-capsuleMultiSelectWidget-handle {
-       width: 100%;
-       display: inline-block;
-       position: relative;
-}
-.oo-ui-capsuleMultiSelectWidget-content {
-       position: relative;
-}
-.oo-ui-capsuleMultiSelectWidget.oo-ui-widget-disabled .oo-ui-capsuleMultiSelectWidget-content > input {
-       display: none;
-}
-.oo-ui-capsuleMultiSelectWidget-group {
-       display: inline;
-}
-.oo-ui-capsuleMultiSelectWidget > .oo-ui-menuSelectWidget {
-       z-index: 1;
-       width: 100%;
-}
-.oo-ui-capsuleMultiSelectWidget-handle {
-       background-color: #ffffff;
-       cursor: text;
-       min-height: 2.4em;
-       margin-right: 0.5em;
-       padding: 0.15em 0.25em;
-       border: 1px solid #cccccc;
-       border-radius: 2px;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-capsuleMultiSelectWidget-handle:last-child {
-       margin-right: 0;
-}
-.oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-indicatorElement-indicator,
-.oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-iconElement-icon {
-       position: absolute;
-       background-position: center center;
-       background-repeat: no-repeat;
-}
-.oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-capsuleMultiSelectWidget-content > input {
-       border: 0;
-       line-height: 1.675em;
-       margin: 0 0 0 0.2em;
-       padding: 0;
-       font-size: inherit;
-       font-family: inherit;
-       background-color: transparent;
-       color: #000000;
-       vertical-align: middle;
-}
-.oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-capsuleMultiSelectWidget-content > input:focus {
-       outline: none;
-}
-.oo-ui-capsuleMultiSelectWidget.oo-ui-indicatorElement .oo-ui-capsuleMultiSelectWidget-handle {
-       padding-right: 2.4875em;
-}
-.oo-ui-capsuleMultiSelectWidget.oo-ui-indicatorElement .oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-indicatorElement-indicator {
-       right: 0;
-       top: 0;
-       width: 0.9375em;
-       height: 0.9375em;
-       margin: 0.775em;
-}
-.oo-ui-capsuleMultiSelectWidget.oo-ui-iconElement .oo-ui-capsuleMultiSelectWidget-handle {
-       padding-left: 2.475em;
-}
-.oo-ui-capsuleMultiSelectWidget.oo-ui-iconElement .oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-iconElement-icon {
-       left: 0;
-       top: 0;
-       width: 1.875em;
-       height: 1.875em;
-       margin: 0.3em;
-}
-.oo-ui-capsuleMultiSelectWidget:hover .oo-ui-capsuleMultiSelectWidget-handle {
-       border-color: #aaaaaa;
-}
-.oo-ui-capsuleMultiSelectWidget.oo-ui-widget-disabled .oo-ui-capsuleMultiSelectWidget-handle {
-       color: #cccccc;
-       text-shadow: 0 1px 1px #ffffff;
-       border-color: #dddddd;
-       background-color: #f3f3f3;
-       cursor: default;
-}
-.oo-ui-capsuleMultiSelectWidget.oo-ui-widget-disabled .oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-iconElement-icon,
-.oo-ui-capsuleMultiSelectWidget.oo-ui-widget-disabled .oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-indicatorElement-indicator {
-       opacity: 0.2;
-}
-.oo-ui-capsuleMultiSelectWidget .oo-ui-selectWidget {
-       border-top-color: #ffffff;
-}
-.oo-ui-capsuleItemWidget {
-       position: relative;
-       display: inline-block;
-       cursor: default;
-       white-space: nowrap;
-       width: auto;
-       max-width: 100%;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-       vertical-align: middle;
-       padding: 0 0.4em;
-       margin: 0.1em;
-       height: 1.7em;
-       line-height: 1.7em;
-       background-color: #eeeeee;
-       border: 1px solid #cccccc;
-       color: #555555;
-       border-radius: 2px;
-}
-.oo-ui-capsuleItemWidget > .oo-ui-iconElement-icon {
-       cursor: pointer;
-}
-.oo-ui-capsuleItemWidget.oo-ui-widget-disabled > .oo-ui-iconElement-icon {
-       cursor: default;
-}
-.oo-ui-capsuleItemWidget.oo-ui-labelElement .oo-ui-labelElement-label {
-       display: block;
-       text-overflow: ellipsis;
-       overflow: hidden;
-}
-.oo-ui-capsuleItemWidget.oo-ui-indicatorElement > .oo-ui-labelElement-label {
-       padding-right: 1.3375em;
-}
-.oo-ui-capsuleItemWidget.oo-ui-indicatorElement > .oo-ui-indicatorElement-indicator {
-       position: absolute;
-       right: 0.4em;
-       top: 0;
-       width: 0.9375em;
-       height: 100%;
-       background-repeat: no-repeat;
-}
-.oo-ui-capsuleItemWidget.oo-ui-indicatorElement > .oo-ui-indicator-clear {
-       cursor: pointer;
-}
-.oo-ui-capsuleItemWidget.oo-ui-widget-disabled {
-       color: #cccccc;
-       text-shadow: 0 1px 1px #ffffff;
-       border-color: #dddddd;
-       background-color: #f3f3f3;
-}
-.oo-ui-capsuleItemWidget.oo-ui-widget-disabled > .oo-ui-indicatorElement-indicator {
-       opacity: 0.2;
-}
-.oo-ui-searchWidget-query {
-       position: absolute;
-       top: 0;
-       left: 0;
-       right: 0;
-}
-.oo-ui-searchWidget-query .oo-ui-textInputWidget {
-       width: 100%;
-}
-.oo-ui-searchWidget-results {
-       position: absolute;
-       bottom: 0;
-       left: 0;
-       right: 0;
-       overflow-x: hidden;
-       overflow-y: auto;
-}
-.oo-ui-searchWidget-query {
-       height: 4em;
-       padding: 0 1em;
-       border-bottom: 1px solid #cccccc;
-}
-.oo-ui-searchWidget-query .oo-ui-textInputWidget {
-       margin: 0.75em 0;
-}
-.oo-ui-searchWidget-results {
-       top: 4em;
-       padding: 1em;
-       line-height: 0;
-}
-.oo-ui-numberInputWidget {
-       display: inline-block;
-       position: relative;
-       max-width: 50em;
-}
-.oo-ui-numberInputWidget-field {
-       display: table;
-       table-layout: fixed;
-       width: 100%;
-}
-.oo-ui-numberInputWidget-field > .oo-ui-buttonWidget,
-.oo-ui-numberInputWidget-field > .oo-ui-textInputWidget {
-       display: table-cell;
-       vertical-align: middle;
-}
-.oo-ui-numberInputWidget-field > .oo-ui-textInputWidget {
-       width: 100%;
-}
-.oo-ui-numberInputWidget-field > .oo-ui-buttonWidget {
-       white-space: nowrap;
-}
-.oo-ui-numberInputWidget-field > .oo-ui-buttonWidget > .oo-ui-buttonElement-button {
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-numberInputWidget-field > .oo-ui-buttonWidget {
-       width: 2.5em;
-}
-.oo-ui-numberInputWidget-minusButton.oo-ui-buttonElement-framed.oo-ui-widget-enabled > .oo-ui-buttonElement-button {
-       border-top-right-radius: 0;
-       border-bottom-right-radius: 0;
-       border-right-width: 0;
-}
-.oo-ui-numberInputWidget-plusButton.oo-ui-buttonElement-framed.oo-ui-widget-enabled > .oo-ui-buttonElement-button {
-       border-top-left-radius: 0;
-       border-bottom-left-radius: 0;
-       border-left-width: 0;
-}
-.oo-ui-numberInputWidget .oo-ui-textInputWidget input {
-       border-radius: 0;
-}
-
-/*!
- * OOjs UI v0.15.2
- * https://www.mediawiki.org/wiki/OOjs_UI
- *
- * Copyright 2011–2016 OOjs UI Team and other contributors.
- * Released under the MIT license
- * http://oojs.mit-license.org
- *
- * Date: 2016-02-02T22:07:06Z
- */
-@-webkit-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@-moz-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-.oo-ui-popupTool .oo-ui-popupWidget-popup,
-.oo-ui-popupTool .oo-ui-popupWidget-anchor {
-       z-index: 4;
-}
-.oo-ui-popupTool .oo-ui-popupWidget {
-       /* @noflip */
-       margin-left: 1.25em;
-}
-.oo-ui-toolGroupTool > .oo-ui-popupToolGroup {
-       border: 0;
-       border-radius: 0;
-       margin: 0;
-}
-.oo-ui-toolGroupTool > .oo-ui-toolGroup {
-       border-right: none;
-}
-.oo-ui-toolGroupTool > .oo-ui-popupToolGroup > .oo-ui-popupToolGroup-handle {
-       height: 2.5em;
-       padding: 0.3125em;
-}
-.oo-ui-toolGroupTool > .oo-ui-popupToolGroup > .oo-ui-popupToolGroup-handle .oo-ui-iconElement-icon {
-       height: 2.5em;
-       width: 1.875em;
-}
-.oo-ui-toolGroupTool > .oo-ui-popupToolGroup.oo-ui-labelElement > .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
-       line-height: 2.1em;
-}
-.oo-ui-toolGroup {
-       display: inline-block;
-       vertical-align: middle;
-       border-radius: 0;
-       border-right: 1px solid #dddddd;
-}
-.oo-ui-toolGroup-empty {
-       display: none;
-}
-.oo-ui-toolGroup .oo-ui-tool-link {
-       text-decoration: none;
-}
-.oo-ui-toolbar-narrow .oo-ui-toolGroup + .oo-ui-toolGroup {
-       margin-left: 0;
-}
-.oo-ui-toolGroup .oo-ui-toolGroup .oo-ui-widget-enabled {
-       border-right: none !important;
-}
-.oo-ui-barToolGroup > .oo-ui-iconElement-icon,
-.oo-ui-barToolGroup > .oo-ui-labelElement-label {
-       display: none;
-}
-.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link {
-       cursor: pointer;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool {
-       display: inline-block;
-       position: relative;
-       vertical-align: top;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link {
-       display: block;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link .oo-ui-tool-accel {
-       display: none;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-iconElement > .oo-ui-tool-link .oo-ui-iconElement-icon {
-       display: inline-block;
-       vertical-align: top;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-iconElement > .oo-ui-tool-link .oo-ui-tool-title {
-       display: none;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-iconElement.oo-ui-tool-with-label > .oo-ui-tool-link .oo-ui-tool-title {
-       display: inline;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-disabled > .oo-ui-tool-link {
-       outline: 0;
-       cursor: default;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link {
-       height: 1.875em;
-       padding: 0.625em;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link .oo-ui-iconElement-icon {
-       height: 1.875em;
-       width: 1.875em;
-}
-.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link .oo-ui-tool-title {
-       line-height: 2.1em;
-       padding: 0 0.4em;
-}
-.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-enabled:hover {
-       border-color: rgba(0, 0, 0, 0.2);
-       background-color: #eeeeee;
-}
-.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool > a.oo-ui-tool-link .oo-ui-tool-title {
-       color: #555555;
-}
-.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-tool-active.oo-ui-widget-enabled {
-       border-color: rgba(0, 0, 0, 0.2);
-       box-shadow: inset 0 0.07em 0.07em 0 rgba(0, 0, 0, 0.07);
-       background-color: #e5e5e5;
-}
-.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-tool-active.oo-ui-widget-enabled:hover {
-       background-color: #eeeeee;
-}
-.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-tool-active.oo-ui-widget-enabled + .oo-ui-tool-active.oo-ui-widget-enabled {
-       border-left-color: rgba(0, 0, 0, 0.1);
-}
-.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-disabled > .oo-ui-tool-link .oo-ui-tool-title {
-       color: #cccccc;
-}
-.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-disabled > .oo-ui-tool-link .oo-ui-iconElement-icon {
-       opacity: 0.2;
-}
-.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-enabled > .oo-ui-tool-link .oo-ui-iconElement-icon {
-       opacity: 0.7;
-}
-.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-enabled:hover > .oo-ui-tool-link .oo-ui-iconElement-icon {
-       opacity: 0.9;
-}
-.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-enabled:active {
-       background-color: #e7e7e7;
-}
-.oo-ui-barToolGroup.oo-ui-widget-disabled > .oo-ui-toolGroup-tools > .oo-ui-tool > a.oo-ui-tool-link .oo-ui-tool-title {
-       color: #cccccc;
-}
-.oo-ui-barToolGroup.oo-ui-widget-disabled > .oo-ui-toolGroup-tools > .oo-ui-tool > a.oo-ui-tool-link .oo-ui-iconElement-icon {
-       opacity: 0.2;
-}
-.oo-ui-popupToolGroup {
-       position: relative;
-       height: 3.125em;
-       min-width: 2em;
-}
-.oo-ui-popupToolGroup-handle {
-       display: block;
-       cursor: pointer;
-}
-.oo-ui-popupToolGroup-handle .oo-ui-indicatorElement-indicator,
-.oo-ui-popupToolGroup-handle .oo-ui-iconElement-icon {
-       position: absolute;
-}
-.oo-ui-popupToolGroup.oo-ui-widget-disabled .oo-ui-popupToolGroup-handle {
-       outline: 0;
-       cursor: default;
-}
-.oo-ui-popupToolGroup .oo-ui-toolGroup-tools {
-       display: none;
-       position: absolute;
-       z-index: 4;
-}
-.oo-ui-popupToolGroup-active.oo-ui-widget-enabled > .oo-ui-toolGroup-tools {
-       display: block;
-}
-.oo-ui-popupToolGroup-left > .oo-ui-toolGroup-tools {
-       left: 0;
-}
-.oo-ui-popupToolGroup-right > .oo-ui-toolGroup-tools {
-       right: 0;
-}
-.oo-ui-popupToolGroup .oo-ui-tool-link {
-       display: table;
-       width: 100%;
-       vertical-align: middle;
-       white-space: nowrap;
-}
-.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-iconElement-icon,
-.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-accel,
-.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-title {
-       display: table-cell;
-       vertical-align: middle;
-}
-.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-accel {
-       text-align: right;
-}
-.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-accel:not(:empty) {
-       padding-left: 3em;
-}
-.oo-ui-toolbar-narrow .oo-ui-popupToolGroup {
-       min-width: 1.875em;
-}
-.oo-ui-popupToolGroup.oo-ui-iconElement {
-       min-width: 3.125em;
-}
-.oo-ui-toolbar-narrow .oo-ui-popupToolGroup.oo-ui-iconElement {
-       min-width: 2.5em;
-}
-.oo-ui-popupToolGroup.oo-ui-indicatorElement.oo-ui-iconElement {
-       min-width: 4.375em;
-}
-.oo-ui-toolbar-narrow .oo-ui-popupToolGroup.oo-ui-indicatorElement.oo-ui-iconElement {
-       min-width: 3.75em;
-}
-.oo-ui-popupToolGroup.oo-ui-labelElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
-       line-height: 2.6em;
-       margin: 0 1em;
-}
-.oo-ui-toolbar-narrow .oo-ui-popupToolGroup.oo-ui-labelElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
-       margin: 0 0.5em;
-}
-.oo-ui-popupToolGroup.oo-ui-labelElement.oo-ui-iconElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
-       margin-left: 3em;
-}
-.oo-ui-toolbar-narrow .oo-ui-popupToolGroup.oo-ui-labelElement.oo-ui-iconElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
-       margin-left: 2.5em;
-}
-.oo-ui-popupToolGroup.oo-ui-labelElement.oo-ui-indicatorElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
-       margin-right: 2em;
-}
-.oo-ui-toolbar-narrow .oo-ui-popupToolGroup.oo-ui-labelElement.oo-ui-indicatorElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
-       margin-right: 1.75em;
-}
-.oo-ui-popupToolGroup.oo-ui-widget-enabled .oo-ui-popupToolGroup-handle:hover {
-       background-color: #eeeeee;
-}
-.oo-ui-popupToolGroup.oo-ui-widget-enabled .oo-ui-popupToolGroup-handle:active {
-       background-color: #e5e5e5;
-}
-.oo-ui-popupToolGroup-handle {
-       padding: 0.3125em;
-       height: 2.5em;
-}
-.oo-ui-popupToolGroup-handle .oo-ui-indicatorElement-indicator {
-       width: 0.9375em;
-       height: 1.625em;
-       margin: 0.78125em 0.5em;
-       top: 0;
-       right: 0;
-       opacity: 0.3;
-}
-.oo-ui-toolbar-narrow .oo-ui-popupToolGroup-handle .oo-ui-indicatorElement-indicator {
-       right: -0.3125em;
-}
-.oo-ui-popupToolGroup-handle .oo-ui-iconElement-icon {
-       width: 1.875em;
-       height: 2.6em;
-       margin: 0.25em;
-       top: 0;
-       left: 0.3125em;
-       opacity: 0.7;
-}
-.oo-ui-toolbar-narrow .oo-ui-popupToolGroup-handle .oo-ui-iconElement-icon {
-       left: 0;
-}
-.oo-ui-popupToolGroup-header {
-       line-height: 2.6em;
-       margin: 0 0.6em;
-       font-weight: bold;
-}
-.oo-ui-popupToolGroup-active.oo-ui-widget-enabled {
-       border-bottom-left-radius: 0;
-       border-bottom-right-radius: 0;
-       box-shadow: inset 0 0.07em 0.07em 0 rgba(0, 0, 0, 0.07);
-       background-color: #eeeeee;
-}
-.oo-ui-popupToolGroup .oo-ui-toolGroup-tools {
-       top: 3.125em;
-       margin: 0 -1px;
-       border: 1px solid #cccccc;
-       background-color: #ffffff;
-       box-shadow: 0 2px 3px rgba(0, 0, 0, 0.2);
-       min-width: 16em;
-}
-.oo-ui-popupToolGroup .oo-ui-tool-link {
-       padding: 0.4em 0.625em;
-       box-sizing: border-box;
-}
-.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-iconElement-icon {
-       height: 2.5em;
-       width: 1.875em;
-       min-width: 1.875em;
-}
-.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-title {
-       padding-left: 0.5em;
-       color: #555555;
-}
-.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-accel,
-.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-title {
-       line-height: 2em;
-}
-.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-accel {
-       color: #888888;
-}
-.oo-ui-listToolGroup .oo-ui-tool {
-       display: block;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-listToolGroup .oo-ui-tool-link {
-       cursor: pointer;
-}
-.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link {
-       cursor: default;
-}
-.oo-ui-listToolGroup.oo-ui-popupToolGroup-active {
-       border-color: rgba(0, 0, 0, 0.2);
-}
-.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-enabled:hover {
-       border-color: rgba(0, 0, 0, 0.2);
-       background-color: #eeeeee;
-}
-.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-enabled:active {
-       background-color: #e7e7e7;
-}
-.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-enabled:hover .oo-ui-tool-link .oo-ui-iconElement-icon {
-       opacity: 0.9;
-}
-.oo-ui-listToolGroup .oo-ui-tool-active.oo-ui-widget-enabled {
-       border-color: rgba(0, 0, 0, 0.1);
-       box-shadow: inset 0 0.07em 0.07em 0 rgba(0, 0, 0, 0.07);
-       background-color: #e5e5e5;
-}
-.oo-ui-listToolGroup .oo-ui-tool-active.oo-ui-widget-enabled + .oo-ui-tool-active.oo-ui-widget-enabled {
-       border-top-color: rgba(0, 0, 0, 0.1);
-}
-.oo-ui-listToolGroup .oo-ui-tool-active.oo-ui-widget-enabled:hover {
-       border-color: rgba(0, 0, 0, 0.2);
-       background-color: #eeeeee;
-}
-.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link .oo-ui-tool-title {
-       color: #cccccc;
-}
-.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link .oo-ui-tool-accel {
-       color: #dddddd;
-}
-.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link .oo-ui-iconElement-icon {
-       opacity: 0.2;
-}
-.oo-ui-listToolGroup.oo-ui-widget-disabled {
-       color: #cccccc;
-}
-.oo-ui-listToolGroup.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator,
-.oo-ui-listToolGroup.oo-ui-widget-disabled .oo-ui-iconElement-icon {
-       opacity: 0.2;
-}
-.oo-ui-menuToolGroup .oo-ui-tool {
-       display: block;
-}
-.oo-ui-menuToolGroup .oo-ui-tool-link {
-       cursor: pointer;
-}
-.oo-ui-menuToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link {
-       cursor: default;
-}
-.oo-ui-menuToolGroup .oo-ui-popupToolGroup-handle {
-       min-width: 10em;
-}
-.oo-ui-toolbar-narrow .oo-ui-menuToolGroup .oo-ui-popupToolGroup-handle {
-       min-width: 8.125em;
-}
-.oo-ui-menuToolGroup .oo-ui-tool-link .oo-ui-iconElement-icon {
-       background-image: none;
-}
-.oo-ui-menuToolGroup .oo-ui-tool-active .oo-ui-tool-link .oo-ui-iconElement-icon {
-       background-image: url("themes/mediawiki/images/icons/check.png");
-       background-image: -webkit-linear-gradient(transparent, transparent), /* @embed */ url("themes/mediawiki/images/icons/check.svg");
-       background-image:         linear-gradient(transparent, transparent), /* @embed */ url("themes/mediawiki/images/icons/check.svg");
-       background-image:      -o-linear-gradient(transparent, transparent), url("themes/mediawiki/images/icons/check.png");
-       background-size: contain;
-       background-position: center center;
-       background-repeat: no-repeat;
-}
-.oo-ui-menuToolGroup .oo-ui-tool.oo-ui-widget-enabled:hover {
-       background-color: #eeeeee;
-}
-.oo-ui-menuToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link .oo-ui-tool-title {
-       color: #cccccc;
-}
-.oo-ui-menuToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link .oo-ui-iconElement-icon {
-       opacity: 0.2;
-}
-.oo-ui-menuToolGroup.oo-ui-widget-disabled {
-       color: #cccccc;
-}
-.oo-ui-menuToolGroup.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator,
-.oo-ui-menuToolGroup.oo-ui-widget-disabled .oo-ui-iconElement-icon {
-       opacity: 0.2;
-}
-.oo-ui-toolbar {
-       clear: both;
-}
-.oo-ui-toolbar-bar {
-       line-height: 1em;
-       position: relative;
-}
-.oo-ui-toolbar-actions {
-       float: right;
-}
-.oo-ui-toolbar-actions .oo-ui-toolbar {
-       display: inline-block;
-}
-.oo-ui-toolbar-tools {
-       display: inline;
-       white-space: nowrap;
-}
-.oo-ui-toolbar-narrow .oo-ui-toolbar-tools {
-       white-space: normal;
-}
-.oo-ui-toolbar-tools .oo-ui-tool {
-       white-space: normal;
-}
-.oo-ui-toolbar-tools,
-.oo-ui-toolbar-actions,
-.oo-ui-toolbar-shadow {
-       -webkit-touch-callout: none;
-       -webkit-user-select: none;
-          -moz-user-select: none;
-           -ms-user-select: none;
-               user-select: none;
-}
-.oo-ui-toolbar-actions .oo-ui-popupWidget {
-       -webkit-touch-callout: default;
-       -webkit-user-select: all;
-          -moz-user-select: all;
-           -ms-user-select: all;
-               user-select: all;
-}
-.oo-ui-toolbar-shadow {
-       background-position: left top;
-       background-repeat: repeat-x;
-       position: absolute;
-       width: 100%;
-       pointer-events: none;
-}
-.oo-ui-toolbar-bar {
-       border-bottom: 1px solid #cccccc;
-       background-color: #ffffff;
-       box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
-       font-weight: 500;
-       color: #555555;
-}
-.oo-ui-toolbar-bar .oo-ui-toolbar-bar {
-       border: 0;
-       background: none;
-       box-shadow: none;
-}
-.oo-ui-toolbar-actions > .oo-ui-buttonElement.oo-ui-labelElement {
-       margin: 0;
-}
-.oo-ui-toolbar-actions > .oo-ui-buttonElement.oo-ui-labelElement > .oo-ui-buttonElement-button {
-       border: 0;
-       border-radius: 0;
-       margin: 0;
-       padding: 0 0.3125em;
-}
-.oo-ui-toolbar-actions > .oo-ui-buttonElement.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
-       margin: 0 1em;
-       line-height: 3.125em;
-}
-
-/*!
- * OOjs UI v0.15.2
- * https://www.mediawiki.org/wiki/OOjs_UI
- *
- * Copyright 2011–2016 OOjs UI Team and other contributors.
- * Released under the MIT license
- * http://oojs.mit-license.org
- *
- * Date: 2016-02-02T22:07:06Z
- */
-@-webkit-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@-moz-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-@keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
-.oo-ui-window {
-       background: transparent;
-}
-.oo-ui-window-frame {
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-window-content:focus {
-       outline: none;
-}
-.oo-ui-window-head,
-.oo-ui-window-foot {
-       -webkit-touch-callout: none;
-       -webkit-user-select: none;
-          -moz-user-select: none;
-           -ms-user-select: none;
-               user-select: none;
-}
-.oo-ui-window-body {
-       margin: 0;
-       padding: 0;
-       background: none;
-}
-.oo-ui-window-overlay {
-       position: absolute;
-       top: 0;
-       /* @noflip */
-       left: 0;
-}
-.oo-ui-dialog-content > .oo-ui-window-head,
-.oo-ui-dialog-content > .oo-ui-window-body,
-.oo-ui-dialog-content > .oo-ui-window-foot {
-       position: absolute;
-       left: 0;
-       right: 0;
-       -webkit-box-sizing: border-box;
-          -moz-box-sizing: border-box;
-               box-sizing: border-box;
-}
-.oo-ui-dialog-content > .oo-ui-window-head {
-       overflow: hidden;
-       z-index: 1;
-       top: 0;
-}
-.oo-ui-dialog-content > .oo-ui-window-body {
-       overflow: auto;
-       z-index: 2;
-       top: 0;
-       bottom: 0;
-}
-.oo-ui-dialog-content > .oo-ui-window-foot {
-       overflow: hidden;
-       z-index: 1;
-       bottom: 0;
-}
-.oo-ui-dialog-content > .oo-ui-window-body {
-       outline: 1px solid #aaaaaa;
-}
-.oo-ui-messageDialog-actions-horizontal {
-       display: table;
-       table-layout: fixed;
-       width: 100%;
-}
-.oo-ui-messageDialog-actions-horizontal .oo-ui-actionWidget {
-       display: table-cell;
-       width: 1%;
-}
-.oo-ui-messageDialog-actions-vertical {
-       display: block;
-}
-.oo-ui-messageDialog-actions-vertical .oo-ui-actionWidget {
-       display: block;
-       overflow: hidden;
-       text-overflow: ellipsis;
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget {
-       position: relative;
-       text-align: center;
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget .oo-ui-buttonElement-button {
-       display: block;
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget .oo-ui-labelElement-label {
-       position: relative;
-       top: auto;
-       bottom: auto;
-       display: inline;
-       white-space: nowrap;
-}
-.oo-ui-messageDialog-title,
-.oo-ui-messageDialog-message {
-       display: block;
-       text-align: center;
-}
-.oo-ui-messageDialog-title.oo-ui-labelElement,
-.oo-ui-messageDialog-message.oo-ui-labelElement {
-       padding-top: 0.5em;
-}
-.oo-ui-messageDialog-title {
-       font-size: 1.5em;
-       line-height: 1em;
-       color: #000000;
-}
-.oo-ui-messageDialog-message {
-       font-size: 0.9em;
-       line-height: 1.25em;
-       color: #555555;
-}
-.oo-ui-messageDialog-message-verbose {
-       font-size: 1.1em;
-       line-height: 1.5em;
-       text-align: left;
-}
-.oo-ui-messageDialog-actions-horizontal .oo-ui-actionWidget {
-       border-right: 1px solid #e5e5e5;
-       margin: 0;
-}
-.oo-ui-messageDialog-actions-horizontal .oo-ui-actionWidget:last-child {
-       border-right-width: 0;
-}
-.oo-ui-messageDialog-actions-vertical .oo-ui-actionWidget {
-       border-bottom: 1px solid #e5e5e5;
-       margin: 0;
-}
-.oo-ui-messageDialog-actions-vertical .oo-ui-actionWidget:last-child {
-       border-bottom-width: 0;
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget {
-       height: 3.4em;
-       margin-right: 0;
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget:last-child {
-       margin-right: 0;
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-labelElement .oo-ui-labelElement-label {
-       text-align: center;
-       line-height: 3.4em;
-       padding: 0 2em;
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget:hover {
-       background-color: rgba(0, 0, 0, 0.05);
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget:active {
-       background-color: rgba(0, 0, 0, 0.1);
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-progressive:hover {
-       background-color: rgba(8, 126, 204, 0.05);
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-progressive:active {
-       background-color: rgba(8, 126, 204, 0.1);
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-progressive .oo-ui-labelElement-label {
-       font-weight: bold;
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-constructive:hover {
-       background-color: rgba(118, 171, 54, 0.05);
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-constructive:active {
-       background-color: rgba(118, 171, 54, 0.1);
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-destructive:hover {
-       background-color: rgba(212, 83, 83, 0.05);
-}
-.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-destructive:active {
-       background-color: rgba(212, 83, 83, 0.1);
-}
-.oo-ui-processDialog-location {
-       overflow: hidden;
-       text-overflow: ellipsis;
-       white-space: nowrap;
-}
-.oo-ui-processDialog-title {
-       display: inline;
-       padding: 0;
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget,
-.oo-ui-processDialog-actions-other .oo-ui-actionWidget {
-       white-space: nowrap;
-}
-.oo-ui-processDialog-actions-safe,
-.oo-ui-processDialog-actions-primary {
-       position: absolute;
-       top: 0;
-       bottom: 0;
-}
-.oo-ui-processDialog-actions-safe {
-       left: 0;
-}
-.oo-ui-processDialog-actions-primary {
-       right: 0;
-}
-.oo-ui-processDialog-errors {
-       position: absolute;
-       top: 0;
-       left: 0;
-       right: 0;
-       bottom: 0;
-       z-index: 2;
-       overflow-x: hidden;
-       overflow-y: auto;
-}
-.oo-ui-processDialog-content .oo-ui-window-head {
-       height: 3.4em;
-}
-.oo-ui-processDialog-content .oo-ui-window-body {
-       top: 3.4em;
-       outline: 1px solid rgba(0, 0, 0, 0.2);
-}
-.oo-ui-processDialog-navigation {
-       position: relative;
-       height: 3.4em;
-       padding: 0 1em;
-}
-.oo-ui-processDialog-location {
-       padding: 0.75em 0;
-       height: 1.875em;
-       cursor: default;
-       text-align: center;
-}
-.oo-ui-processDialog-title {
-       font-weight: bold;
-       line-height: 1.875em;
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-framed,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-framed,
-.oo-ui-processDialog-actions-other .oo-ui-actionWidget.oo-ui-buttonElement-framed {
-       margin: 0.5em;
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless,
-.oo-ui-processDialog-actions-other .oo-ui-actionWidget.oo-ui-buttonElement-frameless {
-       margin: 0;
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless .oo-ui-buttonElement-button,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless .oo-ui-buttonElement-button,
-.oo-ui-processDialog-actions-other .oo-ui-actionWidget.oo-ui-buttonElement-frameless .oo-ui-buttonElement-button {
-       padding: 0.75em 1em;
-       vertical-align: middle;
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless .oo-ui-labelElement-label,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless .oo-ui-labelElement-label,
-.oo-ui-processDialog-actions-other .oo-ui-actionWidget.oo-ui-buttonElement-frameless .oo-ui-labelElement-label {
-       line-height: 1.875em;
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless:hover,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless:hover {
-       background-color: rgba(0, 0, 0, 0.05);
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless:active,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless:active {
-       background-color: rgba(0, 0, 0, 0.1);
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-progressive:hover,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-progressive:hover {
-       background-color: rgba(8, 126, 204, 0.05);
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-progressive:active,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-progressive:active {
-       background-color: rgba(8, 126, 204, 0.1);
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-progressive .oo-ui-labelElement-label,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-progressive .oo-ui-labelElement-label {
-       font-weight: bold;
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-constructive:hover,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-constructive:hover {
-       background-color: rgba(118, 171, 54, 0.05);
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-constructive:active,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-constructive:active {
-       background-color: rgba(118, 171, 54, 0.1);
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-destructive:hover,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-destructive:hover {
-       background-color: rgba(212, 83, 83, 0.05);
-}
-.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-destructive:active,
-.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-destructive:active {
-       background-color: rgba(212, 83, 83, 0.1);
-}
-.oo-ui-processDialog-actions-other .oo-ui-actionWidget.oo-ui-buttonElement {
-       margin-right: 0;
-}
-.oo-ui-processDialog > .oo-ui-window-frame {
-       min-height: 5em;
-}
-.oo-ui-processDialog-errors {
-       background-color: rgba(255, 255, 255, 0.9);
-       padding: 3em 3em 1.5em 3em;
-       text-align: center;
-}
-.oo-ui-processDialog-errors .oo-ui-buttonWidget {
-       margin: 2em 1em 2em 1em;
-}
-.oo-ui-processDialog-errors-title {
-       font-size: 1.5em;
-       color: #000000;
-       margin-bottom: 2em;
-}
-.oo-ui-processDialog-error {
-       text-align: left;
-       margin: 1em;
-       padding: 1em;
-       border: 1px solid #ff9e9e;
-       background-color: #fff7f7;
-       border-radius: 2px;
-}
-.oo-ui-windowManager-modal > .oo-ui-dialog {
-       position: fixed;
-       width: 0;
-       height: 0;
-       overflow: hidden;
-}
-.oo-ui-windowManager-modal > .oo-ui-dialog.oo-ui-window-active {
-       width: auto;
-       height: auto;
-       top: 0;
-       right: 0;
-       bottom: 0;
-       left: 0;
-       padding: 1em;
-}
-.oo-ui-windowManager-modal > .oo-ui-dialog.oo-ui-window-setup > .oo-ui-window-frame {
-       position: absolute;
-       right: 0;
-       left: 0;
-       margin: auto;
-       overflow: hidden;
-       max-width: 100%;
-       max-height: 100%;
-}
-.oo-ui-windowManager-fullscreen > .oo-ui-dialog > .oo-ui-window-frame {
-       width: 100%;
-       height: 100%;
-       top: 0;
-       bottom: 0;
-}
-.oo-ui-windowManager-modal > .oo-ui-dialog {
-       background-color: rgba(255, 255, 255, 0.5);
-       opacity: 0;
-       -webkit-transition: opacity 250ms ease;
-          -moz-transition: opacity 250ms ease;
-               transition: opacity 250ms ease;
-}
-.oo-ui-windowManager-modal > .oo-ui-dialog > .oo-ui-window-frame {
-       background-color: #ffffff;
-       opacity: 0;
-       -webkit-transform: scale(0.5);
-          -moz-transform: scale(0.5);
-           -ms-transform: scale(0.5);
-               transform: scale(0.5);
-       -webkit-transition: all 250ms ease;
-          -moz-transition: all 250ms ease;
-               transition: all 250ms ease;
-}
-.oo-ui-windowManager-modal > .oo-ui-dialog.oo-ui-window-setup {
-       opacity: 1;
-}
-.oo-ui-windowManager-modal > .oo-ui-dialog.oo-ui-window-ready > .oo-ui-window-frame {
-       opacity: 1;
-       -webkit-transform: scale(1);
-          -moz-transform: scale(1);
-           -ms-transform: scale(1);
-               transform: scale(1);
-}
-.oo-ui-windowManager-modal.oo-ui-windowManager-floating > .oo-ui-dialog > .oo-ui-window-frame {
-       top: 1em;
-       bottom: 1em;
-       border: 1px solid #aaaaaa;
-       border-radius: 2px;
-       box-shadow: 0 0.15em 0 0 rgba(0, 0, 0, 0.15);
-}
diff --git a/resources/lib/oojs-ui/oojs-ui-toolbars-apex.css b/resources/lib/oojs-ui/oojs-ui-toolbars-apex.css
new file mode 100644 (file)
index 0000000..f433b15
--- /dev/null
@@ -0,0 +1,555 @@
+/*!
+ * OOjs UI v0.15.2
+ * https://www.mediawiki.org/wiki/OOjs_UI
+ *
+ * Copyright 2011–2016 OOjs UI Team and other contributors.
+ * Released under the MIT license
+ * http://oojs.mit-license.org
+ *
+ * Date: 2016-02-02T22:07:06Z
+ */
+@-webkit-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@-moz-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@-ms-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@-o-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+.oo-ui-popupTool .oo-ui-popupWidget-popup,
+.oo-ui-popupTool .oo-ui-popupWidget-anchor {
+       z-index: 4;
+}
+.oo-ui-popupTool .oo-ui-popupWidget {
+       /* @noflip */
+       margin-left: 1.25em;
+}
+.oo-ui-toolGroupTool > .oo-ui-popupToolGroup {
+       border: 0;
+       border-radius: 0;
+       margin: 0;
+}
+.oo-ui-toolGroupTool:first-child > .oo-ui-popupToolGroup {
+       border-top-left-radius: 0.3125em;
+       border-bottom-left-radius: 0.3125em;
+}
+.oo-ui-toolGroupTool:last-child > .oo-ui-popupToolGroup {
+       border-top-right-radius: 0.3125em;
+       border-bottom-right-radius: 0.3125em;
+}
+.oo-ui-toolGroupTool > .oo-ui-popupToolGroup > .oo-ui-popupToolGroup-handle {
+       height: 1.875em;
+       padding: 0.3125em;
+}
+.oo-ui-toolGroupTool > .oo-ui-popupToolGroup > .oo-ui-popupToolGroup-handle .oo-ui-iconElement-icon {
+       height: 1.875em;
+       width: 1.875em;
+}
+.oo-ui-toolGroupTool > .oo-ui-popupToolGroup.oo-ui-labelElement > .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
+       line-height: 2.1em;
+}
+.oo-ui-toolGroup {
+       display: inline-block;
+       vertical-align: middle;
+       margin: 0.375em;
+       border-radius: 0.3125em;
+       border: 1px solid transparent;
+       -webkit-transition: border-color 250ms ease;
+          -moz-transition: border-color 250ms ease;
+               transition: border-color 250ms ease;
+}
+.oo-ui-toolGroup-empty {
+       display: none;
+}
+.oo-ui-toolGroup .oo-ui-tool-link {
+       text-decoration: none;
+}
+.oo-ui-toolbar-narrow .oo-ui-toolGroup + .oo-ui-toolGroup {
+       margin-left: 0;
+}
+.oo-ui-toolGroup.oo-ui-widget-enabled:hover {
+       border-color: rgba(0, 0, 0, 0.1);
+}
+.oo-ui-toolGroup.oo-ui-widget-enabled .oo-ui-tool-link .oo-ui-tool-title {
+       color: #000000;
+}
+.oo-ui-barToolGroup > .oo-ui-iconElement-icon,
+.oo-ui-barToolGroup > .oo-ui-labelElement-label {
+       display: none;
+}
+.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link {
+       cursor: pointer;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool {
+       display: inline-block;
+       position: relative;
+       vertical-align: top;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link {
+       display: block;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link .oo-ui-tool-accel {
+       display: none;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-iconElement > .oo-ui-tool-link .oo-ui-iconElement-icon {
+       display: inline-block;
+       vertical-align: top;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-iconElement > .oo-ui-tool-link .oo-ui-tool-title {
+       display: none;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-iconElement.oo-ui-tool-with-label > .oo-ui-tool-link .oo-ui-tool-title {
+       display: inline;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-disabled > .oo-ui-tool-link {
+       outline: 0;
+       cursor: default;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool {
+       margin: -1px 0 -1px -1px;
+       border: 1px solid transparent;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool:first-child {
+       border-top-left-radius: 0.3125em;
+       border-bottom-left-radius: 0.3125em;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool:last-child {
+       margin-right: -1px;
+       border-top-right-radius: 0.3125em;
+       border-bottom-right-radius: 0.3125em;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link {
+       height: 1.875em;
+       padding: 0.3125em;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link .oo-ui-iconElement-icon {
+       height: 1.875em;
+       width: 1.875em;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link .oo-ui-tool-title {
+       line-height: 2.1em;
+}
+.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-enabled:hover {
+       border-color: rgba(0, 0, 0, 0.2);
+}
+.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-tool-active.oo-ui-widget-enabled {
+       border-color: rgba(0, 0, 0, 0.2);
+       box-shadow: inset 0 0.0875em 0.0875em 0 rgba(0, 0, 0, 0.07);
+       background-color: #f8fbfd;
+       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #f1f7fb), color-stop(100%, #ffffff));
+       background-image: -webkit-linear-gradient(top, #f1f7fb 0, #ffffff 100%);
+       background-image:    -moz-linear-gradient(top, #f1f7fb 0, #ffffff 100%);
+       background-image:         linear-gradient(to bottom, #f1f7fb 0, #ffffff 100%);
+       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#fff1f7fb', endColorstr='#ffffffff' )";
+}
+.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-tool-active.oo-ui-widget-enabled + .oo-ui-tool-active.oo-ui-widget-enabled {
+       border-left-color: rgba(0, 0, 0, 0.1);
+}
+.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-disabled > .oo-ui-tool-link:focus {
+       outline: 0;
+}
+.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-disabled > .oo-ui-tool-link .oo-ui-tool-title {
+       color: #cccccc;
+}
+.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-disabled > .oo-ui-tool-link .oo-ui-iconElement-icon {
+       opacity: 0.2;
+}
+.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-enabled:hover > .oo-ui-tool-link .oo-ui-iconElement-icon {
+       opacity: 1;
+}
+.oo-ui-barToolGroup.oo-ui-widget-disabled > .oo-ui-toolGroup-tools > .oo-ui-tool:focus {
+       outline: 0;
+}
+.oo-ui-barToolGroup.oo-ui-widget-disabled > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link:focus {
+       outline: 0;
+}
+.oo-ui-barToolGroup.oo-ui-widget-disabled > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link .oo-ui-tool-title {
+       color: #cccccc;
+}
+.oo-ui-barToolGroup.oo-ui-widget-disabled > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link .oo-ui-iconElement-icon {
+       opacity: 0.2;
+}
+.oo-ui-popupToolGroup {
+       position: relative;
+       height: 2.5em;
+       min-width: 2.5em;
+}
+.oo-ui-popupToolGroup-handle {
+       display: block;
+       cursor: pointer;
+}
+.oo-ui-popupToolGroup-handle .oo-ui-indicatorElement-indicator,
+.oo-ui-popupToolGroup-handle .oo-ui-iconElement-icon {
+       position: absolute;
+}
+.oo-ui-popupToolGroup.oo-ui-widget-disabled .oo-ui-popupToolGroup-handle {
+       outline: 0;
+       cursor: default;
+}
+.oo-ui-popupToolGroup .oo-ui-toolGroup-tools {
+       display: none;
+       position: absolute;
+       z-index: 4;
+}
+.oo-ui-popupToolGroup-active.oo-ui-widget-enabled > .oo-ui-toolGroup-tools {
+       display: block;
+}
+.oo-ui-popupToolGroup-left > .oo-ui-toolGroup-tools {
+       left: 0;
+}
+.oo-ui-popupToolGroup-right > .oo-ui-toolGroup-tools {
+       right: 0;
+}
+.oo-ui-popupToolGroup .oo-ui-tool-link {
+       display: table;
+       width: 100%;
+       vertical-align: middle;
+       white-space: nowrap;
+}
+.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-iconElement-icon,
+.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-accel,
+.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-title {
+       display: table-cell;
+       vertical-align: middle;
+}
+.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-accel {
+       text-align: right;
+}
+.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-accel:not(:empty) {
+       padding-left: 3em;
+}
+.oo-ui-toolbar-narrow .oo-ui-popupToolGroup {
+       min-width: 1.875em;
+}
+.oo-ui-popupToolGroup.oo-ui-iconElement {
+       min-width: 3.125em;
+}
+.oo-ui-toolbar-narrow .oo-ui-popupToolGroup.oo-ui-iconElement {
+       min-width: 2.5em;
+}
+.oo-ui-popupToolGroup.oo-ui-indicatorElement.oo-ui-iconElement {
+       min-width: 4.375em;
+}
+.oo-ui-toolbar-narrow .oo-ui-popupToolGroup.oo-ui-indicatorElement.oo-ui-iconElement {
+       min-width: 3.75em;
+}
+.oo-ui-popupToolGroup.oo-ui-labelElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
+       line-height: 2.6em;
+       margin: 0 1em;
+}
+.oo-ui-toolbar-narrow .oo-ui-popupToolGroup.oo-ui-labelElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
+       margin: 0 0.5em;
+}
+.oo-ui-popupToolGroup.oo-ui-labelElement.oo-ui-iconElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
+       margin-left: 3em;
+}
+.oo-ui-toolbar-narrow .oo-ui-popupToolGroup.oo-ui-labelElement.oo-ui-iconElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
+       margin-left: 2.5em;
+}
+.oo-ui-popupToolGroup.oo-ui-labelElement.oo-ui-indicatorElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
+       margin-right: 2.25em;
+}
+.oo-ui-toolbar-narrow .oo-ui-popupToolGroup.oo-ui-labelElement.oo-ui-indicatorElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
+       margin-right: 1.75em;
+}
+.oo-ui-popupToolGroup-handle .oo-ui-indicatorElement-indicator {
+       width: 0.9375em;
+       height: 0.9375em;
+       margin: 0.78125em;
+       top: 0;
+       right: 0;
+}
+.oo-ui-toolbar-narrow .oo-ui-popupToolGroup-handle .oo-ui-indicatorElement-indicator {
+       right: -0.3125em;
+}
+.oo-ui-popupToolGroup-handle .oo-ui-iconElement-icon {
+       width: 1.875em;
+       height: 1.875em;
+       margin: 0.3125em;
+       top: 0;
+       left: 0.3125em;
+}
+.oo-ui-toolbar-narrow .oo-ui-popupToolGroup-handle .oo-ui-iconElement-icon {
+       left: 0;
+}
+.oo-ui-popupToolGroup-header {
+       line-height: 2.6em;
+       margin: 0 0.6em;
+       font-weight: bold;
+}
+.oo-ui-popupToolGroup-active.oo-ui-widget-enabled {
+       border-bottom-left-radius: 0;
+       border-bottom-right-radius: 0;
+       box-shadow: inset 0 0.0875em 0.0875em 0 rgba(0, 0, 0, 0.07);
+       background-color: #f8fbfd;
+       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #f1f7fb), color-stop(100%, #ffffff));
+       background-image: -webkit-linear-gradient(top, #f1f7fb 0, #ffffff 100%);
+       background-image:    -moz-linear-gradient(top, #f1f7fb 0, #ffffff 100%);
+       background-image:         linear-gradient(to bottom, #f1f7fb 0, #ffffff 100%);
+       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#fff1f7fb', endColorstr='#ffffffff' )";
+}
+.oo-ui-popupToolGroup .oo-ui-toolGroup-tools {
+       top: 2.5em;
+       margin: 0 -1px;
+       border: 1px solid #cccccc;
+       background-color: white;
+       box-shadow: 0 0.3125em 1.25em rgba(0, 0, 0, 0.25);
+}
+.oo-ui-popupToolGroup .oo-ui-tool-link {
+       padding: 0.3125em 0 0.3125em 0.3125em;
+}
+.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-iconElement-icon {
+       height: 1.875em;
+       width: 1.875em;
+       min-width: 1.875em;
+}
+.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-title {
+       padding-left: 0.5em;
+}
+.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-accel,
+.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-title {
+       line-height: 2em;
+}
+.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-accel {
+       color: #888888;
+}
+.oo-ui-listToolGroup .oo-ui-tool {
+       display: block;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-listToolGroup .oo-ui-tool-link {
+       cursor: pointer;
+}
+.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link {
+       cursor: default;
+}
+.oo-ui-listToolGroup .oo-ui-toolGroup-tools {
+       padding: 0.3125em;
+}
+.oo-ui-listToolGroup.oo-ui-popupToolGroup-active {
+       border-color: rgba(0, 0, 0, 0.2);
+}
+.oo-ui-listToolGroup .oo-ui-tool {
+       border: 1px solid transparent;
+       margin: -1px 0;
+       padding: 0 0.625em 0 0;
+}
+.oo-ui-listToolGroup .oo-ui-tool-active.oo-ui-widget-enabled {
+       border-color: rgba(0, 0, 0, 0.1);
+       box-shadow: inset 0 0.0875em 0.0875em 0 rgba(0, 0, 0, 0.07);
+       background-color: #f8fbfd;
+       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #f1f7fb), color-stop(100%, #ffffff));
+       background-image: -webkit-linear-gradient(top, #f1f7fb 0, #ffffff 100%);
+       background-image:    -moz-linear-gradient(top, #f1f7fb 0, #ffffff 100%);
+       background-image:         linear-gradient(to bottom, #f1f7fb 0, #ffffff 100%);
+       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#fff1f7fb', endColorstr='#ffffffff' )";
+}
+.oo-ui-listToolGroup .oo-ui-tool-active.oo-ui-widget-enabled + .oo-ui-tool-active.oo-ui-widget-enabled {
+       border-top-color: rgba(0, 0, 0, 0.1);
+}
+.oo-ui-listToolGroup .oo-ui-tool-active.oo-ui-widget-enabled:hover {
+       border-color: rgba(0, 0, 0, 0.2);
+}
+.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-enabled:hover {
+       border-color: rgba(0, 0, 0, 0.2);
+}
+.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-enabled:hover .oo-ui-tool-link .oo-ui-iconElement-icon {
+       opacity: 1;
+}
+.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link .oo-ui-tool-title {
+       color: #cccccc;
+}
+.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link .oo-ui-tool-accel {
+       color: #dddddd;
+}
+.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link .oo-ui-iconElement-icon {
+       opacity: 0.2;
+}
+.oo-ui-listToolGroup.oo-ui-widget-disabled {
+       color: #cccccc;
+}
+.oo-ui-listToolGroup.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator,
+.oo-ui-listToolGroup.oo-ui-widget-disabled .oo-ui-iconElement-icon {
+       opacity: 0.2;
+}
+.oo-ui-menuToolGroup {
+       border-color: rgba(0, 0, 0, 0.1);
+}
+.oo-ui-menuToolGroup .oo-ui-tool {
+       display: block;
+}
+.oo-ui-menuToolGroup .oo-ui-tool-link {
+       cursor: pointer;
+}
+.oo-ui-menuToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link {
+       cursor: default;
+}
+.oo-ui-menuToolGroup .oo-ui-popupToolGroup-handle {
+       min-width: 10em;
+}
+.oo-ui-toolbar-narrow .oo-ui-menuToolGroup .oo-ui-popupToolGroup-handle {
+       min-width: 8.125em;
+}
+.oo-ui-menuToolGroup .oo-ui-toolGroup-tools {
+       padding: 0.3125em 0 0.3125em 0;
+}
+.oo-ui-menuToolGroup.oo-ui-widget-enabled:hover {
+       border-color: rgba(0, 0, 0, 0.2);
+}
+.oo-ui-menuToolGroup.oo-ui-popupToolGroup-active {
+       border-color: rgba(0, 0, 0, 0.25);
+}
+.oo-ui-menuToolGroup .oo-ui-tool {
+       padding: 0 1.25em 0 0.3125em;
+}
+.oo-ui-menuToolGroup .oo-ui-tool-link .oo-ui-iconElement-icon {
+       background-image: none;
+}
+.oo-ui-menuToolGroup .oo-ui-tool-active .oo-ui-tool-link .oo-ui-iconElement-icon {
+       background-image: url("themes/apex/images/icons/check.png");
+       background-image: -webkit-linear-gradient(transparent, transparent), /* @embed */ url("themes/apex/images/icons/check.svg");
+       background-image:         linear-gradient(transparent, transparent), /* @embed */ url("themes/apex/images/icons/check.svg");
+       background-image:      -o-linear-gradient(transparent, transparent), url("themes/apex/images/icons/check.png");
+       background-size: contain;
+       background-position: center center;
+       background-repeat: no-repeat;
+}
+.oo-ui-menuToolGroup .oo-ui-tool.oo-ui-widget-enabled:hover {
+       background-color: #e1f3ff;
+}
+.oo-ui-menuToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link .oo-ui-tool-title {
+       color: #cccccc;
+}
+.oo-ui-menuToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link .oo-ui-iconElement-icon {
+       opacity: 0.2;
+}
+.oo-ui-menuToolGroup.oo-ui-widget-disabled {
+       color: #cccccc;
+       border-color: rgba(0, 0, 0, 0.05);
+}
+.oo-ui-menuToolGroup.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator,
+.oo-ui-menuToolGroup.oo-ui-widget-disabled .oo-ui-iconElement-icon {
+       opacity: 0.2;
+}
+.oo-ui-toolbar {
+       clear: both;
+}
+.oo-ui-toolbar-bar {
+       line-height: 1em;
+       position: relative;
+}
+.oo-ui-toolbar-actions {
+       float: right;
+}
+.oo-ui-toolbar-actions .oo-ui-toolbar {
+       display: inline-block;
+}
+.oo-ui-toolbar-tools {
+       display: inline;
+       white-space: nowrap;
+}
+.oo-ui-toolbar-narrow .oo-ui-toolbar-tools {
+       white-space: normal;
+}
+.oo-ui-toolbar-tools .oo-ui-tool {
+       white-space: normal;
+}
+.oo-ui-toolbar-tools,
+.oo-ui-toolbar-actions,
+.oo-ui-toolbar-shadow {
+       -webkit-touch-callout: none;
+       -webkit-user-select: none;
+          -moz-user-select: none;
+           -ms-user-select: none;
+               user-select: none;
+}
+.oo-ui-toolbar-actions .oo-ui-popupWidget {
+       -webkit-touch-callout: default;
+       -webkit-user-select: all;
+          -moz-user-select: all;
+           -ms-user-select: all;
+               user-select: all;
+}
+.oo-ui-toolbar-shadow {
+       background-position: left top;
+       background-repeat: repeat-x;
+       position: absolute;
+       width: 100%;
+       pointer-events: none;
+}
+.oo-ui-toolbar-bar {
+       border-bottom: 1px solid #cccccc;
+       background-color: #f8fbfd;
+       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #ffffff), color-stop(100%, #f1f7fb));
+       background-image: -webkit-linear-gradient(top, #ffffff 0, #f1f7fb 100%);
+       background-image:    -moz-linear-gradient(top, #ffffff 0, #f1f7fb 100%);
+       background-image:         linear-gradient(to bottom, #ffffff 0, #f1f7fb 100%);
+       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffffffff', endColorstr='#fff1f7fb' )";
+}
+.oo-ui-toolbar-bar .oo-ui-toolbar-bar {
+       border: none;
+       background: none;
+}
+.oo-ui-toolbar-actions > .oo-ui-buttonElement-framed,
+.oo-ui-toolbar-actions > .oo-ui-buttonElement-framed:last-child {
+       margin-top: 0.4em;
+       margin-bottom: 0.4em;
+       margin-right: 0.5em;
+}
+.oo-ui-toolbar-actions > .oo-ui-buttonElement-frameless.oo-ui-labelElement,
+.oo-ui-toolbar-actions > .oo-ui-buttonElement-frameless:last-child.oo-ui-labelElement {
+       margin: 0;
+}
+.oo-ui-toolbar-actions > .oo-ui-buttonElement-frameless.oo-ui-labelElement > .oo-ui-buttonElement-button,
+.oo-ui-toolbar-actions > .oo-ui-buttonElement-frameless:last-child.oo-ui-labelElement > .oo-ui-buttonElement-button {
+       margin: 0;
+       padding: 0 0.3125em;
+}
+.oo-ui-toolbar-actions > .oo-ui-buttonElement-frameless.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label,
+.oo-ui-toolbar-actions > .oo-ui-buttonElement-frameless:last-child.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       margin: 0 1em;
+       line-height: 3.40625em;
+}
+.oo-ui-toolbar-shadow {
+       background-image: /* @embed */ url(themes/apex/images/toolbar-shadow.png);
+       bottom: -9px;
+       height: 9px;
+       opacity: 0.5;
+       -webkit-transition: opacity 500ms ease;
+          -moz-transition: opacity 500ms ease;
+               transition: opacity 500ms ease;
+}
diff --git a/resources/lib/oojs-ui/oojs-ui-toolbars-mediawiki.css b/resources/lib/oojs-ui/oojs-ui-toolbars-mediawiki.css
new file mode 100644 (file)
index 0000000..1f4262c
--- /dev/null
@@ -0,0 +1,483 @@
+/*!
+ * OOjs UI v0.15.2
+ * https://www.mediawiki.org/wiki/OOjs_UI
+ *
+ * Copyright 2011–2016 OOjs UI Team and other contributors.
+ * Released under the MIT license
+ * http://oojs.mit-license.org
+ *
+ * Date: 2016-02-02T22:07:06Z
+ */
+@-webkit-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@-moz-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+.oo-ui-popupTool .oo-ui-popupWidget-popup,
+.oo-ui-popupTool .oo-ui-popupWidget-anchor {
+       z-index: 4;
+}
+.oo-ui-popupTool .oo-ui-popupWidget {
+       /* @noflip */
+       margin-left: 1.25em;
+}
+.oo-ui-toolGroupTool > .oo-ui-popupToolGroup {
+       border: 0;
+       border-radius: 0;
+       margin: 0;
+}
+.oo-ui-toolGroupTool > .oo-ui-toolGroup {
+       border-right: none;
+}
+.oo-ui-toolGroupTool > .oo-ui-popupToolGroup > .oo-ui-popupToolGroup-handle {
+       height: 2.5em;
+       padding: 0.3125em;
+}
+.oo-ui-toolGroupTool > .oo-ui-popupToolGroup > .oo-ui-popupToolGroup-handle .oo-ui-iconElement-icon {
+       height: 2.5em;
+       width: 1.875em;
+}
+.oo-ui-toolGroupTool > .oo-ui-popupToolGroup.oo-ui-labelElement > .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
+       line-height: 2.1em;
+}
+.oo-ui-toolGroup {
+       display: inline-block;
+       vertical-align: middle;
+       border-radius: 0;
+       border-right: 1px solid #dddddd;
+}
+.oo-ui-toolGroup-empty {
+       display: none;
+}
+.oo-ui-toolGroup .oo-ui-tool-link {
+       text-decoration: none;
+}
+.oo-ui-toolbar-narrow .oo-ui-toolGroup + .oo-ui-toolGroup {
+       margin-left: 0;
+}
+.oo-ui-toolGroup .oo-ui-toolGroup .oo-ui-widget-enabled {
+       border-right: none !important;
+}
+.oo-ui-barToolGroup > .oo-ui-iconElement-icon,
+.oo-ui-barToolGroup > .oo-ui-labelElement-label {
+       display: none;
+}
+.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link {
+       cursor: pointer;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool {
+       display: inline-block;
+       position: relative;
+       vertical-align: top;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link {
+       display: block;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link .oo-ui-tool-accel {
+       display: none;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-iconElement > .oo-ui-tool-link .oo-ui-iconElement-icon {
+       display: inline-block;
+       vertical-align: top;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-iconElement > .oo-ui-tool-link .oo-ui-tool-title {
+       display: none;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-iconElement.oo-ui-tool-with-label > .oo-ui-tool-link .oo-ui-tool-title {
+       display: inline;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-disabled > .oo-ui-tool-link {
+       outline: 0;
+       cursor: default;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link {
+       height: 1.875em;
+       padding: 0.625em;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link .oo-ui-iconElement-icon {
+       height: 1.875em;
+       width: 1.875em;
+}
+.oo-ui-barToolGroup > .oo-ui-toolGroup-tools > .oo-ui-tool > .oo-ui-tool-link .oo-ui-tool-title {
+       line-height: 2.1em;
+       padding: 0 0.4em;
+}
+.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-enabled:hover {
+       border-color: rgba(0, 0, 0, 0.2);
+       background-color: #eeeeee;
+}
+.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool > a.oo-ui-tool-link .oo-ui-tool-title {
+       color: #555555;
+}
+.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-tool-active.oo-ui-widget-enabled {
+       border-color: rgba(0, 0, 0, 0.2);
+       box-shadow: inset 0 0.07em 0.07em 0 rgba(0, 0, 0, 0.07);
+       background-color: #e5e5e5;
+}
+.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-tool-active.oo-ui-widget-enabled:hover {
+       background-color: #eeeeee;
+}
+.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-tool-active.oo-ui-widget-enabled + .oo-ui-tool-active.oo-ui-widget-enabled {
+       border-left-color: rgba(0, 0, 0, 0.1);
+}
+.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-disabled > .oo-ui-tool-link .oo-ui-tool-title {
+       color: #cccccc;
+}
+.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-disabled > .oo-ui-tool-link .oo-ui-iconElement-icon {
+       opacity: 0.2;
+}
+.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-enabled > .oo-ui-tool-link .oo-ui-iconElement-icon {
+       opacity: 0.7;
+}
+.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-enabled:hover > .oo-ui-tool-link .oo-ui-iconElement-icon {
+       opacity: 0.9;
+}
+.oo-ui-barToolGroup.oo-ui-widget-enabled > .oo-ui-toolGroup-tools > .oo-ui-tool.oo-ui-widget-enabled:active {
+       background-color: #e7e7e7;
+}
+.oo-ui-barToolGroup.oo-ui-widget-disabled > .oo-ui-toolGroup-tools > .oo-ui-tool > a.oo-ui-tool-link .oo-ui-tool-title {
+       color: #cccccc;
+}
+.oo-ui-barToolGroup.oo-ui-widget-disabled > .oo-ui-toolGroup-tools > .oo-ui-tool > a.oo-ui-tool-link .oo-ui-iconElement-icon {
+       opacity: 0.2;
+}
+.oo-ui-popupToolGroup {
+       position: relative;
+       height: 3.125em;
+       min-width: 2em;
+}
+.oo-ui-popupToolGroup-handle {
+       display: block;
+       cursor: pointer;
+}
+.oo-ui-popupToolGroup-handle .oo-ui-indicatorElement-indicator,
+.oo-ui-popupToolGroup-handle .oo-ui-iconElement-icon {
+       position: absolute;
+}
+.oo-ui-popupToolGroup.oo-ui-widget-disabled .oo-ui-popupToolGroup-handle {
+       outline: 0;
+       cursor: default;
+}
+.oo-ui-popupToolGroup .oo-ui-toolGroup-tools {
+       display: none;
+       position: absolute;
+       z-index: 4;
+}
+.oo-ui-popupToolGroup-active.oo-ui-widget-enabled > .oo-ui-toolGroup-tools {
+       display: block;
+}
+.oo-ui-popupToolGroup-left > .oo-ui-toolGroup-tools {
+       left: 0;
+}
+.oo-ui-popupToolGroup-right > .oo-ui-toolGroup-tools {
+       right: 0;
+}
+.oo-ui-popupToolGroup .oo-ui-tool-link {
+       display: table;
+       width: 100%;
+       vertical-align: middle;
+       white-space: nowrap;
+}
+.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-iconElement-icon,
+.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-accel,
+.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-title {
+       display: table-cell;
+       vertical-align: middle;
+}
+.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-accel {
+       text-align: right;
+}
+.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-accel:not(:empty) {
+       padding-left: 3em;
+}
+.oo-ui-toolbar-narrow .oo-ui-popupToolGroup {
+       min-width: 1.875em;
+}
+.oo-ui-popupToolGroup.oo-ui-iconElement {
+       min-width: 3.125em;
+}
+.oo-ui-toolbar-narrow .oo-ui-popupToolGroup.oo-ui-iconElement {
+       min-width: 2.5em;
+}
+.oo-ui-popupToolGroup.oo-ui-indicatorElement.oo-ui-iconElement {
+       min-width: 4.375em;
+}
+.oo-ui-toolbar-narrow .oo-ui-popupToolGroup.oo-ui-indicatorElement.oo-ui-iconElement {
+       min-width: 3.75em;
+}
+.oo-ui-popupToolGroup.oo-ui-labelElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
+       line-height: 2.6em;
+       margin: 0 1em;
+}
+.oo-ui-toolbar-narrow .oo-ui-popupToolGroup.oo-ui-labelElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
+       margin: 0 0.5em;
+}
+.oo-ui-popupToolGroup.oo-ui-labelElement.oo-ui-iconElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
+       margin-left: 3em;
+}
+.oo-ui-toolbar-narrow .oo-ui-popupToolGroup.oo-ui-labelElement.oo-ui-iconElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
+       margin-left: 2.5em;
+}
+.oo-ui-popupToolGroup.oo-ui-labelElement.oo-ui-indicatorElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
+       margin-right: 2em;
+}
+.oo-ui-toolbar-narrow .oo-ui-popupToolGroup.oo-ui-labelElement.oo-ui-indicatorElement .oo-ui-popupToolGroup-handle .oo-ui-labelElement-label {
+       margin-right: 1.75em;
+}
+.oo-ui-popupToolGroup.oo-ui-widget-enabled .oo-ui-popupToolGroup-handle:hover {
+       background-color: #eeeeee;
+}
+.oo-ui-popupToolGroup.oo-ui-widget-enabled .oo-ui-popupToolGroup-handle:active {
+       background-color: #e5e5e5;
+}
+.oo-ui-popupToolGroup-handle {
+       padding: 0.3125em;
+       height: 2.5em;
+}
+.oo-ui-popupToolGroup-handle .oo-ui-indicatorElement-indicator {
+       width: 0.9375em;
+       height: 1.625em;
+       margin: 0.78125em 0.5em;
+       top: 0;
+       right: 0;
+       opacity: 0.3;
+}
+.oo-ui-toolbar-narrow .oo-ui-popupToolGroup-handle .oo-ui-indicatorElement-indicator {
+       right: -0.3125em;
+}
+.oo-ui-popupToolGroup-handle .oo-ui-iconElement-icon {
+       width: 1.875em;
+       height: 2.6em;
+       margin: 0.25em;
+       top: 0;
+       left: 0.3125em;
+       opacity: 0.7;
+}
+.oo-ui-toolbar-narrow .oo-ui-popupToolGroup-handle .oo-ui-iconElement-icon {
+       left: 0;
+}
+.oo-ui-popupToolGroup-header {
+       line-height: 2.6em;
+       margin: 0 0.6em;
+       font-weight: bold;
+}
+.oo-ui-popupToolGroup-active.oo-ui-widget-enabled {
+       border-bottom-left-radius: 0;
+       border-bottom-right-radius: 0;
+       box-shadow: inset 0 0.07em 0.07em 0 rgba(0, 0, 0, 0.07);
+       background-color: #eeeeee;
+}
+.oo-ui-popupToolGroup .oo-ui-toolGroup-tools {
+       top: 3.125em;
+       margin: 0 -1px;
+       border: 1px solid #cccccc;
+       background-color: #ffffff;
+       box-shadow: 0 2px 3px rgba(0, 0, 0, 0.2);
+       min-width: 16em;
+}
+.oo-ui-popupToolGroup .oo-ui-tool-link {
+       padding: 0.4em 0.625em;
+       box-sizing: border-box;
+}
+.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-iconElement-icon {
+       height: 2.5em;
+       width: 1.875em;
+       min-width: 1.875em;
+}
+.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-title {
+       padding-left: 0.5em;
+       color: #555555;
+}
+.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-accel,
+.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-title {
+       line-height: 2em;
+}
+.oo-ui-popupToolGroup .oo-ui-tool-link .oo-ui-tool-accel {
+       color: #888888;
+}
+.oo-ui-listToolGroup .oo-ui-tool {
+       display: block;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-listToolGroup .oo-ui-tool-link {
+       cursor: pointer;
+}
+.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link {
+       cursor: default;
+}
+.oo-ui-listToolGroup.oo-ui-popupToolGroup-active {
+       border-color: rgba(0, 0, 0, 0.2);
+}
+.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-enabled:hover {
+       border-color: rgba(0, 0, 0, 0.2);
+       background-color: #eeeeee;
+}
+.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-enabled:active {
+       background-color: #e7e7e7;
+}
+.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-enabled:hover .oo-ui-tool-link .oo-ui-iconElement-icon {
+       opacity: 0.9;
+}
+.oo-ui-listToolGroup .oo-ui-tool-active.oo-ui-widget-enabled {
+       border-color: rgba(0, 0, 0, 0.1);
+       box-shadow: inset 0 0.07em 0.07em 0 rgba(0, 0, 0, 0.07);
+       background-color: #e5e5e5;
+}
+.oo-ui-listToolGroup .oo-ui-tool-active.oo-ui-widget-enabled + .oo-ui-tool-active.oo-ui-widget-enabled {
+       border-top-color: rgba(0, 0, 0, 0.1);
+}
+.oo-ui-listToolGroup .oo-ui-tool-active.oo-ui-widget-enabled:hover {
+       border-color: rgba(0, 0, 0, 0.2);
+       background-color: #eeeeee;
+}
+.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link .oo-ui-tool-title {
+       color: #cccccc;
+}
+.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link .oo-ui-tool-accel {
+       color: #dddddd;
+}
+.oo-ui-listToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link .oo-ui-iconElement-icon {
+       opacity: 0.2;
+}
+.oo-ui-listToolGroup.oo-ui-widget-disabled {
+       color: #cccccc;
+}
+.oo-ui-listToolGroup.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator,
+.oo-ui-listToolGroup.oo-ui-widget-disabled .oo-ui-iconElement-icon {
+       opacity: 0.2;
+}
+.oo-ui-menuToolGroup .oo-ui-tool {
+       display: block;
+}
+.oo-ui-menuToolGroup .oo-ui-tool-link {
+       cursor: pointer;
+}
+.oo-ui-menuToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link {
+       cursor: default;
+}
+.oo-ui-menuToolGroup .oo-ui-popupToolGroup-handle {
+       min-width: 10em;
+}
+.oo-ui-toolbar-narrow .oo-ui-menuToolGroup .oo-ui-popupToolGroup-handle {
+       min-width: 8.125em;
+}
+.oo-ui-menuToolGroup .oo-ui-tool-link .oo-ui-iconElement-icon {
+       background-image: none;
+}
+.oo-ui-menuToolGroup .oo-ui-tool-active .oo-ui-tool-link .oo-ui-iconElement-icon {
+       background-image: url("themes/mediawiki/images/icons/check.png");
+       background-image: -webkit-linear-gradient(transparent, transparent), /* @embed */ url("themes/mediawiki/images/icons/check.svg");
+       background-image:         linear-gradient(transparent, transparent), /* @embed */ url("themes/mediawiki/images/icons/check.svg");
+       background-image:      -o-linear-gradient(transparent, transparent), url("themes/mediawiki/images/icons/check.png");
+       background-size: contain;
+       background-position: center center;
+       background-repeat: no-repeat;
+}
+.oo-ui-menuToolGroup .oo-ui-tool.oo-ui-widget-enabled:hover {
+       background-color: #eeeeee;
+}
+.oo-ui-menuToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link .oo-ui-tool-title {
+       color: #cccccc;
+}
+.oo-ui-menuToolGroup .oo-ui-tool.oo-ui-widget-disabled .oo-ui-tool-link .oo-ui-iconElement-icon {
+       opacity: 0.2;
+}
+.oo-ui-menuToolGroup.oo-ui-widget-disabled {
+       color: #cccccc;
+}
+.oo-ui-menuToolGroup.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator,
+.oo-ui-menuToolGroup.oo-ui-widget-disabled .oo-ui-iconElement-icon {
+       opacity: 0.2;
+}
+.oo-ui-toolbar {
+       clear: both;
+}
+.oo-ui-toolbar-bar {
+       line-height: 1em;
+       position: relative;
+}
+.oo-ui-toolbar-actions {
+       float: right;
+}
+.oo-ui-toolbar-actions .oo-ui-toolbar {
+       display: inline-block;
+}
+.oo-ui-toolbar-tools {
+       display: inline;
+       white-space: nowrap;
+}
+.oo-ui-toolbar-narrow .oo-ui-toolbar-tools {
+       white-space: normal;
+}
+.oo-ui-toolbar-tools .oo-ui-tool {
+       white-space: normal;
+}
+.oo-ui-toolbar-tools,
+.oo-ui-toolbar-actions,
+.oo-ui-toolbar-shadow {
+       -webkit-touch-callout: none;
+       -webkit-user-select: none;
+          -moz-user-select: none;
+           -ms-user-select: none;
+               user-select: none;
+}
+.oo-ui-toolbar-actions .oo-ui-popupWidget {
+       -webkit-touch-callout: default;
+       -webkit-user-select: all;
+          -moz-user-select: all;
+           -ms-user-select: all;
+               user-select: all;
+}
+.oo-ui-toolbar-shadow {
+       background-position: left top;
+       background-repeat: repeat-x;
+       position: absolute;
+       width: 100%;
+       pointer-events: none;
+}
+.oo-ui-toolbar-bar {
+       border-bottom: 1px solid #cccccc;
+       background-color: #ffffff;
+       box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
+       font-weight: 500;
+       color: #555555;
+}
+.oo-ui-toolbar-bar .oo-ui-toolbar-bar {
+       border: 0;
+       background: none;
+       box-shadow: none;
+}
+.oo-ui-toolbar-actions > .oo-ui-buttonElement.oo-ui-labelElement {
+       margin: 0;
+}
+.oo-ui-toolbar-actions > .oo-ui-buttonElement.oo-ui-labelElement > .oo-ui-buttonElement-button {
+       border: 0;
+       border-radius: 0;
+       margin: 0;
+       padding: 0 0.3125em;
+}
+.oo-ui-toolbar-actions > .oo-ui-buttonElement.oo-ui-labelElement > .oo-ui-buttonElement-button > .oo-ui-labelElement-label {
+       margin: 0 1em;
+       line-height: 3.125em;
+}
diff --git a/resources/lib/oojs-ui/oojs-ui-toolbars.js b/resources/lib/oojs-ui/oojs-ui-toolbars.js
new file mode 100644 (file)
index 0000000..a88984a
--- /dev/null
@@ -0,0 +1,2304 @@
+/*!
+ * OOjs UI v0.15.2
+ * https://www.mediawiki.org/wiki/OOjs_UI
+ *
+ * Copyright 2011–2016 OOjs UI Team and other contributors.
+ * Released under the MIT license
+ * http://oojs.mit-license.org
+ *
+ * Date: 2016-02-02T22:07:00Z
+ */
+( function ( OO ) {
+
+'use strict';
+
+/**
+ * Toolbars are complex interface components that permit users to easily access a variety
+ * of {@link OO.ui.Tool tools} (e.g., formatting commands) and actions, which are additional commands that are
+ * part of the toolbar, but not configured as tools.
+ *
+ * Individual tools are customized and then registered with a {@link OO.ui.ToolFactory tool factory}, which creates
+ * the tools on demand. Each tool has a symbolic name (used when registering the tool), a title (e.g., ‘Insert
+ * image’), and an icon.
+ *
+ * Individual tools are organized in {@link OO.ui.ToolGroup toolgroups}, which can be {@link OO.ui.MenuToolGroup menus}
+ * of tools, {@link OO.ui.ListToolGroup lists} of tools, or a single {@link OO.ui.BarToolGroup bar} of tools.
+ * The arrangement and order of the toolgroups is customized when the toolbar is set up. Tools can be presented in
+ * any order, but each can only appear once in the toolbar.
+ *
+ * The toolbar can be synchronized with the state of the external "application", like a text
+ * editor's editing area, marking tools as active/inactive (e.g. a 'bold' tool would be shown as
+ * active when the text cursor was inside bolded text) or enabled/disabled (e.g. a table caption
+ * tool would be disabled while the user is not editing a table). A state change is signalled by
+ * emitting the {@link #event-updateState 'updateState' event}, which calls Tools'
+ * {@link OO.ui.Tool#onUpdateState onUpdateState method}.
+ *
+ * The following is an example of a basic toolbar.
+ *
+ *     @example
+ *     // Example of a toolbar
+ *     // Create the toolbar
+ *     var toolFactory = new OO.ui.ToolFactory();
+ *     var toolGroupFactory = new OO.ui.ToolGroupFactory();
+ *     var toolbar = new OO.ui.Toolbar( toolFactory, toolGroupFactory );
+ *
+ *     // We will be placing status text in this element when tools are used
+ *     var $area = $( '<p>' ).text( 'Toolbar example' );
+ *
+ *     // Define the tools that we're going to place in our toolbar
+ *
+ *     // Create a class inheriting from OO.ui.Tool
+ *     function SearchTool() {
+ *         SearchTool.parent.apply( this, arguments );
+ *     }
+ *     OO.inheritClass( SearchTool, OO.ui.Tool );
+ *     // Each tool must have a 'name' (used as an internal identifier, see later) and at least one
+ *     // of 'icon' and 'title' (displayed icon and text).
+ *     SearchTool.static.name = 'search';
+ *     SearchTool.static.icon = 'search';
+ *     SearchTool.static.title = 'Search...';
+ *     // Defines the action that will happen when this tool is selected (clicked).
+ *     SearchTool.prototype.onSelect = function () {
+ *         $area.text( 'Search tool clicked!' );
+ *         // Never display this tool as "active" (selected).
+ *         this.setActive( false );
+ *     };
+ *     SearchTool.prototype.onUpdateState = function () {};
+ *     // Make this tool available in our toolFactory and thus our toolbar
+ *     toolFactory.register( SearchTool );
+ *
+ *     // Register two more tools, nothing interesting here
+ *     function SettingsTool() {
+ *         SettingsTool.parent.apply( this, arguments );
+ *     }
+ *     OO.inheritClass( SettingsTool, OO.ui.Tool );
+ *     SettingsTool.static.name = 'settings';
+ *     SettingsTool.static.icon = 'settings';
+ *     SettingsTool.static.title = 'Change settings';
+ *     SettingsTool.prototype.onSelect = function () {
+ *         $area.text( 'Settings tool clicked!' );
+ *         this.setActive( false );
+ *     };
+ *     SettingsTool.prototype.onUpdateState = function () {};
+ *     toolFactory.register( SettingsTool );
+ *
+ *     // Register two more tools, nothing interesting here
+ *     function StuffTool() {
+ *         StuffTool.parent.apply( this, arguments );
+ *     }
+ *     OO.inheritClass( StuffTool, OO.ui.Tool );
+ *     StuffTool.static.name = 'stuff';
+ *     StuffTool.static.icon = 'ellipsis';
+ *     StuffTool.static.title = 'More stuff';
+ *     StuffTool.prototype.onSelect = function () {
+ *         $area.text( 'More stuff tool clicked!' );
+ *         this.setActive( false );
+ *     };
+ *     StuffTool.prototype.onUpdateState = function () {};
+ *     toolFactory.register( StuffTool );
+ *
+ *     // This is a PopupTool. Rather than having a custom 'onSelect' action, it will display a
+ *     // little popup window (a PopupWidget).
+ *     function HelpTool( toolGroup, config ) {
+ *         OO.ui.PopupTool.call( this, toolGroup, $.extend( { popup: {
+ *             padded: true,
+ *             label: 'Help',
+ *             head: true
+ *         } }, config ) );
+ *         this.popup.$body.append( '<p>I am helpful!</p>' );
+ *     }
+ *     OO.inheritClass( HelpTool, OO.ui.PopupTool );
+ *     HelpTool.static.name = 'help';
+ *     HelpTool.static.icon = 'help';
+ *     HelpTool.static.title = 'Help';
+ *     toolFactory.register( HelpTool );
+ *
+ *     // Finally define which tools and in what order appear in the toolbar. Each tool may only be
+ *     // used once (but not all defined tools must be used).
+ *     toolbar.setup( [
+ *         {
+ *             // 'bar' tool groups display tools' icons only, side-by-side.
+ *             type: 'bar',
+ *             include: [ 'search', 'help' ]
+ *         },
+ *         {
+ *             // 'list' tool groups display both the titles and icons, in a dropdown list.
+ *             type: 'list',
+ *             indicator: 'down',
+ *             label: 'More',
+ *             include: [ 'settings', 'stuff' ]
+ *         }
+ *         // Note how the tools themselves are toolgroup-agnostic - the same tool can be displayed
+ *         // either in a 'list' or a 'bar'. There is a 'menu' tool group too, not showcased here,
+ *         // since it's more complicated to use. (See the next example snippet on this page.)
+ *     ] );
+ *
+ *     // Create some UI around the toolbar and place it in the document
+ *     var frame = new OO.ui.PanelLayout( {
+ *         expanded: false,
+ *         framed: true
+ *     } );
+ *     var contentFrame = new OO.ui.PanelLayout( {
+ *         expanded: false,
+ *         padded: true
+ *     } );
+ *     frame.$element.append(
+ *         toolbar.$element,
+ *         contentFrame.$element.append( $area )
+ *     );
+ *     $( 'body' ).append( frame.$element );
+ *
+ *     // Here is where the toolbar is actually built. This must be done after inserting it into the
+ *     // document.
+ *     toolbar.initialize();
+ *     toolbar.emit( 'updateState' );
+ *
+ * The following example extends the previous one to illustrate 'menu' toolgroups and the usage of
+ * {@link #event-updateState 'updateState' event}.
+ *
+ *     @example
+ *     // Create the toolbar
+ *     var toolFactory = new OO.ui.ToolFactory();
+ *     var toolGroupFactory = new OO.ui.ToolGroupFactory();
+ *     var toolbar = new OO.ui.Toolbar( toolFactory, toolGroupFactory );
+ *
+ *     // We will be placing status text in this element when tools are used
+ *     var $area = $( '<p>' ).text( 'Toolbar example' );
+ *
+ *     // Define the tools that we're going to place in our toolbar
+ *
+ *     // Create a class inheriting from OO.ui.Tool
+ *     function SearchTool() {
+ *         SearchTool.parent.apply( this, arguments );
+ *     }
+ *     OO.inheritClass( SearchTool, OO.ui.Tool );
+ *     // Each tool must have a 'name' (used as an internal identifier, see later) and at least one
+ *     // of 'icon' and 'title' (displayed icon and text).
+ *     SearchTool.static.name = 'search';
+ *     SearchTool.static.icon = 'search';
+ *     SearchTool.static.title = 'Search...';
+ *     // Defines the action that will happen when this tool is selected (clicked).
+ *     SearchTool.prototype.onSelect = function () {
+ *         $area.text( 'Search tool clicked!' );
+ *         // Never display this tool as "active" (selected).
+ *         this.setActive( false );
+ *     };
+ *     SearchTool.prototype.onUpdateState = function () {};
+ *     // Make this tool available in our toolFactory and thus our toolbar
+ *     toolFactory.register( SearchTool );
+ *
+ *     // Register two more tools, nothing interesting here
+ *     function SettingsTool() {
+ *         SettingsTool.parent.apply( this, arguments );
+ *         this.reallyActive = false;
+ *     }
+ *     OO.inheritClass( SettingsTool, OO.ui.Tool );
+ *     SettingsTool.static.name = 'settings';
+ *     SettingsTool.static.icon = 'settings';
+ *     SettingsTool.static.title = 'Change settings';
+ *     SettingsTool.prototype.onSelect = function () {
+ *         $area.text( 'Settings tool clicked!' );
+ *         // Toggle the active state on each click
+ *         this.reallyActive = !this.reallyActive;
+ *         this.setActive( this.reallyActive );
+ *         // To update the menu label
+ *         this.toolbar.emit( 'updateState' );
+ *     };
+ *     SettingsTool.prototype.onUpdateState = function () {};
+ *     toolFactory.register( SettingsTool );
+ *
+ *     // Register two more tools, nothing interesting here
+ *     function StuffTool() {
+ *         StuffTool.parent.apply( this, arguments );
+ *         this.reallyActive = false;
+ *     }
+ *     OO.inheritClass( StuffTool, OO.ui.Tool );
+ *     StuffTool.static.name = 'stuff';
+ *     StuffTool.static.icon = 'ellipsis';
+ *     StuffTool.static.title = 'More stuff';
+ *     StuffTool.prototype.onSelect = function () {
+ *         $area.text( 'More stuff tool clicked!' );
+ *         // Toggle the active state on each click
+ *         this.reallyActive = !this.reallyActive;
+ *         this.setActive( this.reallyActive );
+ *         // To update the menu label
+ *         this.toolbar.emit( 'updateState' );
+ *     };
+ *     StuffTool.prototype.onUpdateState = function () {};
+ *     toolFactory.register( StuffTool );
+ *
+ *     // This is a PopupTool. Rather than having a custom 'onSelect' action, it will display a
+ *     // little popup window (a PopupWidget). 'onUpdateState' is also already implemented.
+ *     function HelpTool( toolGroup, config ) {
+ *         OO.ui.PopupTool.call( this, toolGroup, $.extend( { popup: {
+ *             padded: true,
+ *             label: 'Help',
+ *             head: true
+ *         } }, config ) );
+ *         this.popup.$body.append( '<p>I am helpful!</p>' );
+ *     }
+ *     OO.inheritClass( HelpTool, OO.ui.PopupTool );
+ *     HelpTool.static.name = 'help';
+ *     HelpTool.static.icon = 'help';
+ *     HelpTool.static.title = 'Help';
+ *     toolFactory.register( HelpTool );
+ *
+ *     // Finally define which tools and in what order appear in the toolbar. Each tool may only be
+ *     // used once (but not all defined tools must be used).
+ *     toolbar.setup( [
+ *         {
+ *             // 'bar' tool groups display tools' icons only, side-by-side.
+ *             type: 'bar',
+ *             include: [ 'search', 'help' ]
+ *         },
+ *         {
+ *             // 'menu' tool groups display both the titles and icons, in a dropdown menu.
+ *             // Menu label indicates which items are selected.
+ *             type: 'menu',
+ *             indicator: 'down',
+ *             include: [ 'settings', 'stuff' ]
+ *         }
+ *     ] );
+ *
+ *     // Create some UI around the toolbar and place it in the document
+ *     var frame = new OO.ui.PanelLayout( {
+ *         expanded: false,
+ *         framed: true
+ *     } );
+ *     var contentFrame = new OO.ui.PanelLayout( {
+ *         expanded: false,
+ *         padded: true
+ *     } );
+ *     frame.$element.append(
+ *         toolbar.$element,
+ *         contentFrame.$element.append( $area )
+ *     );
+ *     $( 'body' ).append( frame.$element );
+ *
+ *     // Here is where the toolbar is actually built. This must be done after inserting it into the
+ *     // document.
+ *     toolbar.initialize();
+ *     toolbar.emit( 'updateState' );
+ *
+ * @class
+ * @extends OO.ui.Element
+ * @mixins OO.EventEmitter
+ * @mixins OO.ui.mixin.GroupElement
+ *
+ * @constructor
+ * @param {OO.ui.ToolFactory} toolFactory Factory for creating tools
+ * @param {OO.ui.ToolGroupFactory} toolGroupFactory Factory for creating toolgroups
+ * @param {Object} [config] Configuration options
+ * @cfg {boolean} [actions] Add an actions section to the toolbar. Actions are commands that are included
+ *  in the toolbar, but are not configured as tools. By default, actions are displayed on the right side of
+ *  the toolbar.
+ * @cfg {boolean} [shadow] Add a shadow below the toolbar.
+ */
+OO.ui.Toolbar = function OoUiToolbar( toolFactory, toolGroupFactory, config ) {
+       // Allow passing positional parameters inside the config object
+       if ( OO.isPlainObject( toolFactory ) && config === undefined ) {
+               config = toolFactory;
+               toolFactory = config.toolFactory;
+               toolGroupFactory = config.toolGroupFactory;
+       }
+
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.Toolbar.parent.call( this, config );
+
+       // Mixin constructors
+       OO.EventEmitter.call( this );
+       OO.ui.mixin.GroupElement.call( this, config );
+
+       // Properties
+       this.toolFactory = toolFactory;
+       this.toolGroupFactory = toolGroupFactory;
+       this.groups = [];
+       this.tools = {};
+       this.$bar = $( '<div>' );
+       this.$actions = $( '<div>' );
+       this.initialized = false;
+       this.onWindowResizeHandler = this.onWindowResize.bind( this );
+
+       // Events
+       this.$element
+               .add( this.$bar ).add( this.$group ).add( this.$actions )
+               .on( 'mousedown keydown', this.onPointerDown.bind( this ) );
+
+       // Initialization
+       this.$group.addClass( 'oo-ui-toolbar-tools' );
+       if ( config.actions ) {
+               this.$bar.append( this.$actions.addClass( 'oo-ui-toolbar-actions' ) );
+       }
+       this.$bar
+               .addClass( 'oo-ui-toolbar-bar' )
+               .append( this.$group, '<div style="clear:both"></div>' );
+       if ( config.shadow ) {
+               this.$bar.append( '<div class="oo-ui-toolbar-shadow"></div>' );
+       }
+       this.$element.addClass( 'oo-ui-toolbar' ).append( this.$bar );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.Toolbar, OO.ui.Element );
+OO.mixinClass( OO.ui.Toolbar, OO.EventEmitter );
+OO.mixinClass( OO.ui.Toolbar, OO.ui.mixin.GroupElement );
+
+/* Events */
+
+/**
+ * @event updateState
+ *
+ * An 'updateState' event must be emitted on the Toolbar (by calling `toolbar.emit( 'updateState' )`)
+ * every time the state of the application using the toolbar changes, and an update to the state of
+ * tools is required.
+ *
+ * @param {Mixed...} data Application-defined parameters
+ */
+
+/* Methods */
+
+/**
+ * Get the tool factory.
+ *
+ * @return {OO.ui.ToolFactory} Tool factory
+ */
+OO.ui.Toolbar.prototype.getToolFactory = function () {
+       return this.toolFactory;
+};
+
+/**
+ * Get the toolgroup factory.
+ *
+ * @return {OO.Factory} Toolgroup factory
+ */
+OO.ui.Toolbar.prototype.getToolGroupFactory = function () {
+       return this.toolGroupFactory;
+};
+
+/**
+ * Handles mouse down events.
+ *
+ * @private
+ * @param {jQuery.Event} e Mouse down event
+ */
+OO.ui.Toolbar.prototype.onPointerDown = function ( e ) {
+       var $closestWidgetToEvent = $( e.target ).closest( '.oo-ui-widget' ),
+               $closestWidgetToToolbar = this.$element.closest( '.oo-ui-widget' );
+       if ( !$closestWidgetToEvent.length || $closestWidgetToEvent[ 0 ] === $closestWidgetToToolbar[ 0 ] ) {
+               return false;
+       }
+};
+
+/**
+ * Handle window resize event.
+ *
+ * @private
+ * @param {jQuery.Event} e Window resize event
+ */
+OO.ui.Toolbar.prototype.onWindowResize = function () {
+       this.$element.toggleClass(
+               'oo-ui-toolbar-narrow',
+               this.$bar.width() <= this.narrowThreshold
+       );
+};
+
+/**
+ * Sets up handles and preloads required information for the toolbar to work.
+ * This must be called after it is attached to a visible document and before doing anything else.
+ */
+OO.ui.Toolbar.prototype.initialize = function () {
+       if ( !this.initialized ) {
+               this.initialized = true;
+               this.narrowThreshold = this.$group.width() + this.$actions.width();
+               $( this.getElementWindow() ).on( 'resize', this.onWindowResizeHandler );
+               this.onWindowResize();
+       }
+};
+
+/**
+ * Set up the toolbar.
+ *
+ * The toolbar is set up with a list of toolgroup configurations that specify the type of
+ * toolgroup ({@link OO.ui.BarToolGroup bar}, {@link OO.ui.MenuToolGroup menu}, or {@link OO.ui.ListToolGroup list})
+ * to add and which tools to include, exclude, promote, or demote within that toolgroup. Please
+ * see {@link OO.ui.ToolGroup toolgroups} for more information about including tools in toolgroups.
+ *
+ * @param {Object.<string,Array>} groups List of toolgroup configurations
+ * @param {Array|string} [groups.include] Tools to include in the toolgroup
+ * @param {Array|string} [groups.exclude] Tools to exclude from the toolgroup
+ * @param {Array|string} [groups.promote] Tools to promote to the beginning of the toolgroup
+ * @param {Array|string} [groups.demote] Tools to demote to the end of the toolgroup
+ */
+OO.ui.Toolbar.prototype.setup = function ( groups ) {
+       var i, len, type, group,
+               items = [],
+               defaultType = 'bar';
+
+       // Cleanup previous groups
+       this.reset();
+
+       // Build out new groups
+       for ( i = 0, len = groups.length; i < len; i++ ) {
+               group = groups[ i ];
+               if ( group.include === '*' ) {
+                       // Apply defaults to catch-all groups
+                       if ( group.type === undefined ) {
+                               group.type = 'list';
+                       }
+                       if ( group.label === undefined ) {
+                               group.label = OO.ui.msg( 'ooui-toolbar-more' );
+                       }
+               }
+               // Check type has been registered
+               type = this.getToolGroupFactory().lookup( group.type ) ? group.type : defaultType;
+               items.push(
+                       this.getToolGroupFactory().create( type, this, group )
+               );
+       }
+       this.addItems( items );
+};
+
+/**
+ * Remove all tools and toolgroups from the toolbar.
+ */
+OO.ui.Toolbar.prototype.reset = function () {
+       var i, len;
+
+       this.groups = [];
+       this.tools = {};
+       for ( i = 0, len = this.items.length; i < len; i++ ) {
+               this.items[ i ].destroy();
+       }
+       this.clearItems();
+};
+
+/**
+ * Destroy the toolbar.
+ *
+ * Destroying the toolbar removes all event handlers and DOM elements that constitute the toolbar. Call
+ * this method whenever you are done using a toolbar.
+ */
+OO.ui.Toolbar.prototype.destroy = function () {
+       $( this.getElementWindow() ).off( 'resize', this.onWindowResizeHandler );
+       this.reset();
+       this.$element.remove();
+};
+
+/**
+ * Check if the tool is available.
+ *
+ * Available tools are ones that have not yet been added to the toolbar.
+ *
+ * @param {string} name Symbolic name of tool
+ * @return {boolean} Tool is available
+ */
+OO.ui.Toolbar.prototype.isToolAvailable = function ( name ) {
+       return !this.tools[ name ];
+};
+
+/**
+ * Prevent tool from being used again.
+ *
+ * @param {OO.ui.Tool} tool Tool to reserve
+ */
+OO.ui.Toolbar.prototype.reserveTool = function ( tool ) {
+       this.tools[ tool.getName() ] = tool;
+};
+
+/**
+ * Allow tool to be used again.
+ *
+ * @param {OO.ui.Tool} tool Tool to release
+ */
+OO.ui.Toolbar.prototype.releaseTool = function ( tool ) {
+       delete this.tools[ tool.getName() ];
+};
+
+/**
+ * Get accelerator label for tool.
+ *
+ * The OOjs UI library does not contain an accelerator system, but this is the hook for one. To
+ * use an accelerator system, subclass the toolbar and override this method, which is meant to return a label
+ * that describes the accelerator keys for the tool passed (by symbolic name) to the method.
+ *
+ * @param {string} name Symbolic name of tool
+ * @return {string|undefined} Tool accelerator label if available
+ */
+OO.ui.Toolbar.prototype.getToolAccelerator = function () {
+       return undefined;
+};
+
+/**
+ * Tools, together with {@link OO.ui.ToolGroup toolgroups}, constitute {@link OO.ui.Toolbar toolbars}.
+ * Each tool is configured with a static name, title, and icon and is customized with the command to carry
+ * out when the tool is selected. Tools must also be registered with a {@link OO.ui.ToolFactory tool factory},
+ * which creates the tools on demand.
+ *
+ * Every Tool subclass must implement two methods:
+ *
+ * - {@link #onUpdateState}
+ * - {@link #onSelect}
+ *
+ * Tools are added to toolgroups ({@link OO.ui.ListToolGroup ListToolGroup},
+ * {@link OO.ui.BarToolGroup BarToolGroup}, or {@link OO.ui.MenuToolGroup MenuToolGroup}), which determine how
+ * the tool is displayed in the toolbar. See {@link OO.ui.Toolbar toolbars} for an example.
+ *
+ * For more information, please see the [OOjs UI documentation on MediaWiki][1].
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
+ *
+ * @abstract
+ * @class
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.IconElement
+ * @mixins OO.ui.mixin.FlaggedElement
+ * @mixins OO.ui.mixin.TabIndexedElement
+ *
+ * @constructor
+ * @param {OO.ui.ToolGroup} toolGroup
+ * @param {Object} [config] Configuration options
+ * @cfg {string|Function} [title] Title text or a function that returns text. If this config is omitted, the value of
+ *  the {@link #static-title static title} property is used.
+ *
+ *  The title is used in different ways depending on the type of toolgroup that contains the tool. The
+ *  title is used as a tooltip if the tool is part of a {@link OO.ui.BarToolGroup bar} toolgroup, or as the label text if the tool is
+ *  part of a {@link OO.ui.ListToolGroup list} or {@link OO.ui.MenuToolGroup menu} toolgroup.
+ *
+ *  For bar toolgroups, a description of the accelerator key is appended to the title if an accelerator key
+ *  is associated with an action by the same name as the tool and accelerator functionality has been added to the application.
+ *  To add accelerator key functionality, you must subclass OO.ui.Toolbar and override the {@link OO.ui.Toolbar#getToolAccelerator getToolAccelerator} method.
+ */
+OO.ui.Tool = function OoUiTool( toolGroup, config ) {
+       // Allow passing positional parameters inside the config object
+       if ( OO.isPlainObject( toolGroup ) && config === undefined ) {
+               config = toolGroup;
+               toolGroup = config.toolGroup;
+       }
+
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.Tool.parent.call( this, config );
+
+       // Properties
+       this.toolGroup = toolGroup;
+       this.toolbar = this.toolGroup.getToolbar();
+       this.active = false;
+       this.$title = $( '<span>' );
+       this.$accel = $( '<span>' );
+       this.$link = $( '<a>' );
+       this.title = null;
+
+       // Mixin constructors
+       OO.ui.mixin.IconElement.call( this, config );
+       OO.ui.mixin.FlaggedElement.call( this, config );
+       OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$link } ) );
+
+       // Events
+       this.toolbar.connect( this, { updateState: 'onUpdateState' } );
+
+       // Initialization
+       this.$title.addClass( 'oo-ui-tool-title' );
+       this.$accel
+               .addClass( 'oo-ui-tool-accel' )
+               .prop( {
+                       // This may need to be changed if the key names are ever localized,
+                       // but for now they are essentially written in English
+                       dir: 'ltr',
+                       lang: 'en'
+               } );
+       this.$link
+               .addClass( 'oo-ui-tool-link' )
+               .append( this.$icon, this.$title, this.$accel )
+               .attr( 'role', 'button' );
+       this.$element
+               .data( 'oo-ui-tool', this )
+               .addClass(
+                       'oo-ui-tool ' + 'oo-ui-tool-name-' +
+                       this.constructor.static.name.replace( /^([^\/]+)\/([^\/]+).*$/, '$1-$2' )
+               )
+               .toggleClass( 'oo-ui-tool-with-label', this.constructor.static.displayBothIconAndLabel )
+               .append( this.$link );
+       this.setTitle( config.title || this.constructor.static.title );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.Tool, OO.ui.Widget );
+OO.mixinClass( OO.ui.Tool, OO.ui.mixin.IconElement );
+OO.mixinClass( OO.ui.Tool, OO.ui.mixin.FlaggedElement );
+OO.mixinClass( OO.ui.Tool, OO.ui.mixin.TabIndexedElement );
+
+/* Static Properties */
+
+/**
+ * @static
+ * @inheritdoc
+ */
+OO.ui.Tool.static.tagName = 'span';
+
+/**
+ * Symbolic name of tool.
+ *
+ * The symbolic name is used internally to register the tool with a {@link OO.ui.ToolFactory ToolFactory}. It can
+ * also be used when adding tools to toolgroups.
+ *
+ * @abstract
+ * @static
+ * @inheritable
+ * @property {string}
+ */
+OO.ui.Tool.static.name = '';
+
+/**
+ * Symbolic name of the group.
+ *
+ * The group name is used to associate tools with each other so that they can be selected later by
+ * a {@link OO.ui.ToolGroup toolgroup}.
+ *
+ * @abstract
+ * @static
+ * @inheritable
+ * @property {string}
+ */
+OO.ui.Tool.static.group = '';
+
+/**
+ * Tool title text or a function that returns title text. The value of the static property is overridden if the #title config option is used.
+ *
+ * @abstract
+ * @static
+ * @inheritable
+ * @property {string|Function}
+ */
+OO.ui.Tool.static.title = '';
+
+/**
+ * Display both icon and label when the tool is used in a {@link OO.ui.BarToolGroup bar} toolgroup.
+ * Normally only the icon is displayed, or only the label if no icon is given.
+ *
+ * @static
+ * @inheritable
+ * @property {boolean}
+ */
+OO.ui.Tool.static.displayBothIconAndLabel = false;
+
+/**
+ * Add tool to catch-all groups automatically.
+ *
+ * A catch-all group, which contains all tools that do not currently belong to a toolgroup,
+ * can be included in a toolgroup using the wildcard selector, an asterisk (*).
+ *
+ * @static
+ * @inheritable
+ * @property {boolean}
+ */
+OO.ui.Tool.static.autoAddToCatchall = true;
+
+/**
+ * Add tool to named groups automatically.
+ *
+ * By default, tools that are configured with a static ‘group’ property are added
+ * to that group and will be selected when the symbolic name of the group is specified (e.g., when
+ * toolgroups include tools by group name).
+ *
+ * @static
+ * @property {boolean}
+ * @inheritable
+ */
+OO.ui.Tool.static.autoAddToGroup = true;
+
+/**
+ * Check if this tool is compatible with given data.
+ *
+ * This is a stub that can be overridden to provide support for filtering tools based on an
+ * arbitrary piece of information  (e.g., where the cursor is in a document). The implementation
+ * must also call this method so that the compatibility check can be performed.
+ *
+ * @static
+ * @inheritable
+ * @param {Mixed} data Data to check
+ * @return {boolean} Tool can be used with data
+ */
+OO.ui.Tool.static.isCompatibleWith = function () {
+       return false;
+};
+
+/* Methods */
+
+/**
+ * Handle the toolbar state being updated. This method is called when the
+ * {@link OO.ui.Toolbar#event-updateState 'updateState' event} is emitted on the
+ * {@link OO.ui.Toolbar Toolbar} that uses this tool, and should set the state of this tool
+ * depending on application state (usually by calling #setDisabled to enable or disable the tool,
+ * or #setActive to mark is as currently in-use or not).
+ *
+ * This is an abstract method that must be overridden in a concrete subclass.
+ *
+ * @method
+ * @protected
+ * @abstract
+ */
+OO.ui.Tool.prototype.onUpdateState = null;
+
+/**
+ * Handle the tool being selected. This method is called when the user triggers this tool,
+ * usually by clicking on its label/icon.
+ *
+ * This is an abstract method that must be overridden in a concrete subclass.
+ *
+ * @method
+ * @protected
+ * @abstract
+ */
+OO.ui.Tool.prototype.onSelect = null;
+
+/**
+ * Check if the tool is active.
+ *
+ * Tools become active when their #onSelect or #onUpdateState handlers change them to appear pressed
+ * with the #setActive method. Additional CSS is applied to the tool to reflect the active state.
+ *
+ * @return {boolean} Tool is active
+ */
+OO.ui.Tool.prototype.isActive = function () {
+       return this.active;
+};
+
+/**
+ * Make the tool appear active or inactive.
+ *
+ * This method should be called within #onSelect or #onUpdateState event handlers to make the tool
+ * appear pressed or not.
+ *
+ * @param {boolean} state Make tool appear active
+ */
+OO.ui.Tool.prototype.setActive = function ( state ) {
+       this.active = !!state;
+       if ( this.active ) {
+               this.$element.addClass( 'oo-ui-tool-active' );
+       } else {
+               this.$element.removeClass( 'oo-ui-tool-active' );
+       }
+};
+
+/**
+ * Set the tool #title.
+ *
+ * @param {string|Function} title Title text or a function that returns text
+ * @chainable
+ */
+OO.ui.Tool.prototype.setTitle = function ( title ) {
+       this.title = OO.ui.resolveMsg( title );
+       this.updateTitle();
+       return this;
+};
+
+/**
+ * Get the tool #title.
+ *
+ * @return {string} Title text
+ */
+OO.ui.Tool.prototype.getTitle = function () {
+       return this.title;
+};
+
+/**
+ * Get the tool's symbolic name.
+ *
+ * @return {string} Symbolic name of tool
+ */
+OO.ui.Tool.prototype.getName = function () {
+       return this.constructor.static.name;
+};
+
+/**
+ * Update the title.
+ */
+OO.ui.Tool.prototype.updateTitle = function () {
+       var titleTooltips = this.toolGroup.constructor.static.titleTooltips,
+               accelTooltips = this.toolGroup.constructor.static.accelTooltips,
+               accel = this.toolbar.getToolAccelerator( this.constructor.static.name ),
+               tooltipParts = [];
+
+       this.$title.text( this.title );
+       this.$accel.text( accel );
+
+       if ( titleTooltips && typeof this.title === 'string' && this.title.length ) {
+               tooltipParts.push( this.title );
+       }
+       if ( accelTooltips && typeof accel === 'string' && accel.length ) {
+               tooltipParts.push( accel );
+       }
+       if ( tooltipParts.length ) {
+               this.$link.attr( 'title', tooltipParts.join( ' ' ) );
+       } else {
+               this.$link.removeAttr( 'title' );
+       }
+};
+
+/**
+ * Destroy tool.
+ *
+ * Destroying the tool removes all event handlers and the tool’s DOM elements.
+ * Call this method whenever you are done using a tool.
+ */
+OO.ui.Tool.prototype.destroy = function () {
+       this.toolbar.disconnect( this );
+       this.$element.remove();
+};
+
+/**
+ * ToolGroups are collections of {@link OO.ui.Tool tools} that are used in a {@link OO.ui.Toolbar toolbar}.
+ * The type of toolgroup ({@link OO.ui.ListToolGroup list}, {@link OO.ui.BarToolGroup bar}, or {@link OO.ui.MenuToolGroup menu})
+ * to which a tool belongs determines how the tool is arranged and displayed in the toolbar. Toolgroups
+ * themselves are created on demand with a {@link OO.ui.ToolGroupFactory toolgroup factory}.
+ *
+ * Toolgroups can contain individual tools, groups of tools, or all available tools, as specified
+ * using the `include` config option. See OO.ui.ToolFactory#extract on documentation of the format.
+ * The options `exclude`, `promote`, and `demote` support the same formats.
+ *
+ * See {@link OO.ui.Toolbar toolbars} for a full example. For more information about toolbars in general,
+ * please see the [OOjs UI documentation on MediaWiki][1].
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
+ *
+ * @abstract
+ * @class
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.GroupElement
+ *
+ * @constructor
+ * @param {OO.ui.Toolbar} toolbar
+ * @param {Object} [config] Configuration options
+ * @cfg {Array|string} [include] List of tools to include in the toolgroup, see above.
+ * @cfg {Array|string} [exclude] List of tools to exclude from the toolgroup, see above.
+ * @cfg {Array|string} [promote] List of tools to promote to the beginning of the toolgroup, see above.
+ * @cfg {Array|string} [demote] List of tools to demote to the end of the toolgroup, see above.
+ *  This setting is particularly useful when tools have been added to the toolgroup
+ *  en masse (e.g., via the catch-all selector).
+ */
+OO.ui.ToolGroup = function OoUiToolGroup( toolbar, config ) {
+       // Allow passing positional parameters inside the config object
+       if ( OO.isPlainObject( toolbar ) && config === undefined ) {
+               config = toolbar;
+               toolbar = config.toolbar;
+       }
+
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.ToolGroup.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.GroupElement.call( this, config );
+
+       // Properties
+       this.toolbar = toolbar;
+       this.tools = {};
+       this.pressed = null;
+       this.autoDisabled = false;
+       this.include = config.include || [];
+       this.exclude = config.exclude || [];
+       this.promote = config.promote || [];
+       this.demote = config.demote || [];
+       this.onCapturedMouseKeyUpHandler = this.onCapturedMouseKeyUp.bind( this );
+
+       // Events
+       this.$element.on( {
+               mousedown: this.onMouseKeyDown.bind( this ),
+               mouseup: this.onMouseKeyUp.bind( this ),
+               keydown: this.onMouseKeyDown.bind( this ),
+               keyup: this.onMouseKeyUp.bind( this ),
+               focus: this.onMouseOverFocus.bind( this ),
+               blur: this.onMouseOutBlur.bind( this ),
+               mouseover: this.onMouseOverFocus.bind( this ),
+               mouseout: this.onMouseOutBlur.bind( this )
+       } );
+       this.toolbar.getToolFactory().connect( this, { register: 'onToolFactoryRegister' } );
+       this.aggregate( { disable: 'itemDisable' } );
+       this.connect( this, { itemDisable: 'updateDisabled' } );
+
+       // Initialization
+       this.$group.addClass( 'oo-ui-toolGroup-tools' );
+       this.$element
+               .addClass( 'oo-ui-toolGroup' )
+               .append( this.$group );
+       this.populate();
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.ToolGroup, OO.ui.Widget );
+OO.mixinClass( OO.ui.ToolGroup, OO.ui.mixin.GroupElement );
+
+/* Events */
+
+/**
+ * @event update
+ */
+
+/* Static Properties */
+
+/**
+ * Show labels in tooltips.
+ *
+ * @static
+ * @inheritable
+ * @property {boolean}
+ */
+OO.ui.ToolGroup.static.titleTooltips = false;
+
+/**
+ * Show acceleration labels in tooltips.
+ *
+ * Note: The OOjs UI library does not include an accelerator system, but does contain
+ * a hook for one. To use an accelerator system, subclass the {@link OO.ui.Toolbar toolbar} and
+ * override the {@link OO.ui.Toolbar#getToolAccelerator getToolAccelerator} method, which is
+ * meant to return a label that describes the accelerator keys for a given tool (e.g., 'Ctrl + M').
+ *
+ * @static
+ * @inheritable
+ * @property {boolean}
+ */
+OO.ui.ToolGroup.static.accelTooltips = false;
+
+/**
+ * Automatically disable the toolgroup when all tools are disabled
+ *
+ * @static
+ * @inheritable
+ * @property {boolean}
+ */
+OO.ui.ToolGroup.static.autoDisable = true;
+
+/* Methods */
+
+/**
+ * @inheritdoc
+ */
+OO.ui.ToolGroup.prototype.isDisabled = function () {
+       return this.autoDisabled || OO.ui.ToolGroup.parent.prototype.isDisabled.apply( this, arguments );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.ToolGroup.prototype.updateDisabled = function () {
+       var i, item, allDisabled = true;
+
+       if ( this.constructor.static.autoDisable ) {
+               for ( i = this.items.length - 1; i >= 0; i-- ) {
+                       item = this.items[ i ];
+                       if ( !item.isDisabled() ) {
+                               allDisabled = false;
+                               break;
+                       }
+               }
+               this.autoDisabled = allDisabled;
+       }
+       OO.ui.ToolGroup.parent.prototype.updateDisabled.apply( this, arguments );
+};
+
+/**
+ * Handle mouse down and key down events.
+ *
+ * @protected
+ * @param {jQuery.Event} e Mouse down or key down event
+ */
+OO.ui.ToolGroup.prototype.onMouseKeyDown = function ( e ) {
+       if (
+               !this.isDisabled() &&
+               ( e.which === OO.ui.MouseButtons.LEFT || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
+       ) {
+               this.pressed = this.getTargetTool( e );
+               if ( this.pressed ) {
+                       this.pressed.setActive( true );
+                       this.getElementDocument().addEventListener( 'mouseup', this.onCapturedMouseKeyUpHandler, true );
+                       this.getElementDocument().addEventListener( 'keyup', this.onCapturedMouseKeyUpHandler, true );
+               }
+               return false;
+       }
+};
+
+/**
+ * Handle captured mouse up and key up events.
+ *
+ * @protected
+ * @param {Event} e Mouse up or key up event
+ */
+OO.ui.ToolGroup.prototype.onCapturedMouseKeyUp = function ( e ) {
+       this.getElementDocument().removeEventListener( 'mouseup', this.onCapturedMouseKeyUpHandler, true );
+       this.getElementDocument().removeEventListener( 'keyup', this.onCapturedMouseKeyUpHandler, true );
+       // onMouseKeyUp may be called a second time, depending on where the mouse is when the button is
+       // released, but since `this.pressed` will no longer be true, the second call will be ignored.
+       this.onMouseKeyUp( e );
+};
+
+/**
+ * Handle mouse up and key up events.
+ *
+ * @protected
+ * @param {jQuery.Event} e Mouse up or key up event
+ */
+OO.ui.ToolGroup.prototype.onMouseKeyUp = function ( e ) {
+       var tool = this.getTargetTool( e );
+
+       if (
+               !this.isDisabled() && this.pressed && this.pressed === tool &&
+               ( e.which === OO.ui.MouseButtons.LEFT || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
+       ) {
+               this.pressed.onSelect();
+               this.pressed = null;
+               return false;
+       }
+
+       this.pressed = null;
+};
+
+/**
+ * Handle mouse over and focus events.
+ *
+ * @protected
+ * @param {jQuery.Event} e Mouse over or focus event
+ */
+OO.ui.ToolGroup.prototype.onMouseOverFocus = function ( e ) {
+       var tool = this.getTargetTool( e );
+
+       if ( this.pressed && this.pressed === tool ) {
+               this.pressed.setActive( true );
+       }
+};
+
+/**
+ * Handle mouse out and blur events.
+ *
+ * @protected
+ * @param {jQuery.Event} e Mouse out or blur event
+ */
+OO.ui.ToolGroup.prototype.onMouseOutBlur = function ( e ) {
+       var tool = this.getTargetTool( e );
+
+       if ( this.pressed && this.pressed === tool ) {
+               this.pressed.setActive( false );
+       }
+};
+
+/**
+ * Get the closest tool to a jQuery.Event.
+ *
+ * Only tool links are considered, which prevents other elements in the tool such as popups from
+ * triggering tool group interactions.
+ *
+ * @private
+ * @param {jQuery.Event} e
+ * @return {OO.ui.Tool|null} Tool, `null` if none was found
+ */
+OO.ui.ToolGroup.prototype.getTargetTool = function ( e ) {
+       var tool,
+               $item = $( e.target ).closest( '.oo-ui-tool-link' );
+
+       if ( $item.length ) {
+               tool = $item.parent().data( 'oo-ui-tool' );
+       }
+
+       return tool && !tool.isDisabled() ? tool : null;
+};
+
+/**
+ * Handle tool registry register events.
+ *
+ * If a tool is registered after the group is created, we must repopulate the list to account for:
+ *
+ * - a tool being added that may be included
+ * - a tool already included being overridden
+ *
+ * @protected
+ * @param {string} name Symbolic name of tool
+ */
+OO.ui.ToolGroup.prototype.onToolFactoryRegister = function () {
+       this.populate();
+};
+
+/**
+ * Get the toolbar that contains the toolgroup.
+ *
+ * @return {OO.ui.Toolbar} Toolbar that contains the toolgroup
+ */
+OO.ui.ToolGroup.prototype.getToolbar = function () {
+       return this.toolbar;
+};
+
+/**
+ * Add and remove tools based on configuration.
+ */
+OO.ui.ToolGroup.prototype.populate = function () {
+       var i, len, name, tool,
+               toolFactory = this.toolbar.getToolFactory(),
+               names = {},
+               add = [],
+               remove = [],
+               list = this.toolbar.getToolFactory().getTools(
+                       this.include, this.exclude, this.promote, this.demote
+               );
+
+       // Build a list of needed tools
+       for ( i = 0, len = list.length; i < len; 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 ] )
+               ) {
+                       // Hack to prevent infinite recursion via ToolGroupTool. We need to reserve the tool before
+                       // creating it, but we can't call reserveTool() yet because we haven't created the tool.
+                       this.toolbar.tools[ name ] = true;
+                       tool = this.tools[ name ];
+                       if ( !tool ) {
+                               // Auto-initialize tools on first use
+                               this.tools[ name ] = tool = toolFactory.create( name, this );
+                               tool.updateTitle();
+                       }
+                       this.toolbar.reserveTool( tool );
+                       add.push( tool );
+                       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 ( remove.length ) {
+               this.removeItems( remove );
+       }
+       // Update emptiness state
+       if ( add.length ) {
+               this.$element.removeClass( 'oo-ui-toolGroup-empty' );
+       } else {
+               this.$element.addClass( 'oo-ui-toolGroup-empty' );
+       }
+       // Re-add tools (moving existing ones to new locations)
+       this.addItems( add );
+       // Disabled state may depend on items
+       this.updateDisabled();
+};
+
+/**
+ * Destroy toolgroup.
+ */
+OO.ui.ToolGroup.prototype.destroy = function () {
+       var name;
+
+       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.$element.remove();
+};
+
+/**
+ * A ToolFactory creates tools on demand. All tools ({@link OO.ui.Tool Tools}, {@link OO.ui.PopupTool PopupTools},
+ * and {@link OO.ui.ToolGroupTool ToolGroupTools}) must be registered with a tool factory. Tools are
+ * registered by their symbolic name. See {@link OO.ui.Toolbar toolbars} for an example.
+ *
+ * For more information about toolbars in general, please see the [OOjs UI documentation on MediaWiki][1].
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
+ *
+ * @class
+ * @extends OO.Factory
+ * @constructor
+ */
+OO.ui.ToolFactory = function OoUiToolFactory() {
+       // Parent constructor
+       OO.ui.ToolFactory.parent.call( this );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.ToolFactory, OO.Factory );
+
+/* Methods */
+
+/**
+ * Get tools from the factory
+ *
+ * @param {Array|string} [include] Included tools, see #extract for format
+ * @param {Array|string} [exclude] Excluded tools, see #extract for format
+ * @param {Array|string} [promote] Promoted tools, see #extract for format
+ * @param {Array|string} [demote] Demoted tools, see #extract for format
+ * @return {string[]} List of tools
+ */
+OO.ui.ToolFactory.prototype.getTools = function ( include, exclude, promote, demote ) {
+       var i, len, included, promoted, demoted,
+               auto = [],
+               used = {};
+
+       // Collect included and not excluded tools
+       included = OO.simpleArrayDifference( this.extract( include ), this.extract( exclude ) );
+
+       // Promotion
+       promoted = this.extract( promote, used );
+       demoted = this.extract( demote, used );
+
+       // Auto
+       for ( i = 0, len = included.length; i < len; i++ ) {
+               if ( !used[ included[ i ] ] ) {
+                       auto.push( included[ i ] );
+               }
+       }
+
+       return promoted.concat( auto ).concat( demoted );
+};
+
+/**
+ * Get a flat list of names from a list of names or groups.
+ *
+ * Normally, `collection` is an array of tool specifications. Tools can be specified in the
+ * following ways:
+ *
+ * - To include an individual tool, use the symbolic name: `{ name: 'tool-name' }` or `'tool-name'`.
+ * - To include all tools in a group, use the group name: `{ group: 'group-name' }`. (To assign the
+ *   tool to a group, use OO.ui.Tool.static.group.)
+ *
+ * Alternatively, to include all tools that are not yet assigned to any other toolgroup, use the
+ * catch-all selector `'*'`.
+ *
+ * If `used` is passed, tool names that appear as properties in this object will be considered
+ * already assigned, and will not be returned even if specified otherwise. The tool names extracted
+ * by this function call will be added as new properties in the object.
+ *
+ * @private
+ * @param {Array|string} collection List of tools, see above
+ * @param {Object} [used] Object containing information about used tools, see above
+ * @return {string[]} List of extracted tool names
+ */
+OO.ui.ToolFactory.prototype.extract = function ( collection, used ) {
+       var i, len, item, name, tool,
+               names = [];
+
+       if ( collection === '*' ) {
+               for ( name in this.registry ) {
+                       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 ] )
+                       ) {
+                               names.push( name );
+                               if ( used ) {
+                                       used[ name ] = true;
+                               }
+                       }
+               }
+       } else if ( Array.isArray( collection ) ) {
+               for ( i = 0, len = collection.length; i < len; i++ ) {
+                       item = collection[ i ];
+                       // Allow plain strings as shorthand for named tools
+                       if ( typeof item === 'string' ) {
+                               item = { name: item };
+                       }
+                       if ( OO.isPlainObject( item ) ) {
+                               if ( item.group ) {
+                                       for ( name in this.registry ) {
+                                               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 ] )
+                                               ) {
+                                                       names.push( name );
+                                                       if ( used ) {
+                                                               used[ name ] = true;
+                                                       }
+                                               }
+                                       }
+                               // Include tools with matching name and exclude already used tools
+                               } else if ( item.name && ( !used || !used[ item.name ] ) ) {
+                                       names.push( item.name );
+                                       if ( used ) {
+                                               used[ item.name ] = true;
+                                       }
+                               }
+                       }
+               }
+       }
+       return names;
+};
+
+/**
+ * ToolGroupFactories create {@link OO.ui.ToolGroup toolgroups} on demand. The toolgroup classes must
+ * specify a symbolic name and be registered with the factory. The following classes are registered by
+ * default:
+ *
+ * - {@link OO.ui.BarToolGroup BarToolGroups} (‘bar’)
+ * - {@link OO.ui.MenuToolGroup MenuToolGroups} (‘menu’)
+ * - {@link OO.ui.ListToolGroup ListToolGroups} (‘list’)
+ *
+ * See {@link OO.ui.Toolbar toolbars} for an example.
+ *
+ * For more information about toolbars in general, please see the [OOjs UI documentation on MediaWiki][1].
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
+ * @class
+ * @extends OO.Factory
+ * @constructor
+ */
+OO.ui.ToolGroupFactory = function OoUiToolGroupFactory() {
+       var i, l, defaultClasses;
+       // Parent constructor
+       OO.Factory.call( this );
+
+       defaultClasses = this.constructor.static.getDefaultClasses();
+
+       // Register default toolgroups
+       for ( i = 0, l = defaultClasses.length; i < l; i++ ) {
+               this.register( defaultClasses[ i ] );
+       }
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.ToolGroupFactory, OO.Factory );
+
+/* Static Methods */
+
+/**
+ * Get a default set of classes to be registered on construction.
+ *
+ * @return {Function[]} Default classes
+ */
+OO.ui.ToolGroupFactory.static.getDefaultClasses = function () {
+       return [
+               OO.ui.BarToolGroup,
+               OO.ui.ListToolGroup,
+               OO.ui.MenuToolGroup
+       ];
+};
+
+/**
+ * Popup tools open a popup window when they are selected from the {@link OO.ui.Toolbar toolbar}. Each popup tool is configured
+ * with a static name, title, and icon, as well with as any popup configurations. Unlike other tools, popup tools do not require that developers specify
+ * an #onSelect or #onUpdateState method, as these methods have been implemented already.
+ *
+ *     // Example of a popup tool. When selected, a popup tool displays
+ *     // a popup window.
+ *     function HelpTool( toolGroup, config ) {
+ *        OO.ui.PopupTool.call( this, toolGroup, $.extend( { popup: {
+ *            padded: true,
+ *            label: 'Help',
+ *            head: true
+ *        } }, config ) );
+ *        this.popup.$body.append( '<p>I am helpful!</p>' );
+ *     };
+ *     OO.inheritClass( HelpTool, OO.ui.PopupTool );
+ *     HelpTool.static.name = 'help';
+ *     HelpTool.static.icon = 'help';
+ *     HelpTool.static.title = 'Help';
+ *     toolFactory.register( HelpTool );
+ *
+ * For an example of a toolbar that contains a popup tool, see {@link OO.ui.Toolbar toolbars}. For more information about
+ * toolbars in genreral, please see the [OOjs UI documentation on MediaWiki][1].
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
+ *
+ * @abstract
+ * @class
+ * @extends OO.ui.Tool
+ * @mixins OO.ui.mixin.PopupElement
+ *
+ * @constructor
+ * @param {OO.ui.ToolGroup} toolGroup
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.PopupTool = function OoUiPopupTool( toolGroup, config ) {
+       // Allow passing positional parameters inside the config object
+       if ( OO.isPlainObject( toolGroup ) && config === undefined ) {
+               config = toolGroup;
+               toolGroup = config.toolGroup;
+       }
+
+       // Parent constructor
+       OO.ui.PopupTool.parent.call( this, toolGroup, config );
+
+       // Mixin constructors
+       OO.ui.mixin.PopupElement.call( this, config );
+
+       // Initialization
+       this.$element
+               .addClass( 'oo-ui-popupTool' )
+               .append( this.popup.$element );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.PopupTool, OO.ui.Tool );
+OO.mixinClass( OO.ui.PopupTool, OO.ui.mixin.PopupElement );
+
+/* Methods */
+
+/**
+ * Handle the tool being selected.
+ *
+ * @inheritdoc
+ */
+OO.ui.PopupTool.prototype.onSelect = function () {
+       if ( !this.isDisabled() ) {
+               this.popup.toggle();
+       }
+       this.setActive( false );
+       return false;
+};
+
+/**
+ * Handle the toolbar state being updated.
+ *
+ * @inheritdoc
+ */
+OO.ui.PopupTool.prototype.onUpdateState = function () {
+       this.setActive( false );
+};
+
+/**
+ * A ToolGroupTool is a special sort of tool that can contain other {@link OO.ui.Tool tools}
+ * and {@link OO.ui.ToolGroup toolgroups}. The ToolGroupTool was specifically designed to be used
+ * inside a {@link OO.ui.BarToolGroup bar} toolgroup to provide access to additional tools from
+ * the bar item. Included tools will be displayed in a dropdown {@link OO.ui.ListToolGroup list}
+ * when the ToolGroupTool is selected.
+ *
+ *     // Example: ToolGroupTool with two nested tools, 'setting1' and 'setting2', defined elsewhere.
+ *
+ *     function SettingsTool() {
+ *         SettingsTool.parent.apply( this, arguments );
+ *     };
+ *     OO.inheritClass( SettingsTool, OO.ui.ToolGroupTool );
+ *     SettingsTool.static.name = 'settings';
+ *     SettingsTool.static.title = 'Change settings';
+ *     SettingsTool.static.groupConfig = {
+ *         icon: 'settings',
+ *         label: 'ToolGroupTool',
+ *         include: [  'setting1', 'setting2'  ]
+ *     };
+ *     toolFactory.register( SettingsTool );
+ *
+ * For more information, please see the [OOjs UI documentation on MediaWiki][1].
+ *
+ * Please note that this implementation is subject to change per [T74159] [2].
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars#ToolGroupTool
+ * [2]: https://phabricator.wikimedia.org/T74159
+ *
+ * @abstract
+ * @class
+ * @extends OO.ui.Tool
+ *
+ * @constructor
+ * @param {OO.ui.ToolGroup} toolGroup
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.ToolGroupTool = function OoUiToolGroupTool( toolGroup, config ) {
+       // Allow passing positional parameters inside the config object
+       if ( OO.isPlainObject( toolGroup ) && config === undefined ) {
+               config = toolGroup;
+               toolGroup = config.toolGroup;
+       }
+
+       // Parent constructor
+       OO.ui.ToolGroupTool.parent.call( this, toolGroup, config );
+
+       // Properties
+       this.innerToolGroup = this.createGroup( this.constructor.static.groupConfig );
+
+       // Events
+       this.innerToolGroup.connect( this, { disable: 'onToolGroupDisable' } );
+
+       // Initialization
+       this.$link.remove();
+       this.$element
+               .addClass( 'oo-ui-toolGroupTool' )
+               .append( this.innerToolGroup.$element );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.ToolGroupTool, OO.ui.Tool );
+
+/* Static Properties */
+
+/**
+ * Toolgroup configuration.
+ *
+ * The toolgroup configuration consists of the tools to include, as well as an icon and label
+ * to use for the bar item. Tools can be included by symbolic name, group, or with the
+ * wildcard selector. Please see {@link OO.ui.ToolGroup toolgroup} for more information.
+ *
+ * @property {Object.<string,Array>}
+ */
+OO.ui.ToolGroupTool.static.groupConfig = {};
+
+/* Methods */
+
+/**
+ * Handle the tool being selected.
+ *
+ * @inheritdoc
+ */
+OO.ui.ToolGroupTool.prototype.onSelect = function () {
+       this.innerToolGroup.setActive( !this.innerToolGroup.active );
+       return false;
+};
+
+/**
+ * Synchronize disabledness state of the tool with the inner toolgroup.
+ *
+ * @private
+ * @param {boolean} disabled Element is disabled
+ */
+OO.ui.ToolGroupTool.prototype.onToolGroupDisable = function ( disabled ) {
+       this.setDisabled( disabled );
+};
+
+/**
+ * Handle the toolbar state being updated.
+ *
+ * @inheritdoc
+ */
+OO.ui.ToolGroupTool.prototype.onUpdateState = function () {
+       this.setActive( false );
+};
+
+/**
+ * Build a {@link OO.ui.ToolGroup toolgroup} from the specified configuration.
+ *
+ * @param {Object.<string,Array>} group Toolgroup configuration. Please see {@link OO.ui.ToolGroup toolgroup} for
+ *  more information.
+ * @return {OO.ui.ListToolGroup}
+ */
+OO.ui.ToolGroupTool.prototype.createGroup = function ( group ) {
+       if ( group.include === '*' ) {
+               // Apply defaults to catch-all groups
+               if ( group.label === undefined ) {
+                       group.label = OO.ui.msg( 'ooui-toolbar-more' );
+               }
+       }
+
+       return this.toolbar.getToolGroupFactory().create( 'list', this.toolbar, group );
+};
+
+/**
+ * BarToolGroups are one of three types of {@link OO.ui.ToolGroup toolgroups} that are used to
+ * create {@link OO.ui.Toolbar toolbars} (the other types of groups are {@link OO.ui.MenuToolGroup MenuToolGroup}
+ * and {@link OO.ui.ListToolGroup ListToolGroup}). The {@link OO.ui.Tool tools} in a BarToolGroup are
+ * displayed by icon in a single row. The title of the tool is displayed when users move the mouse over
+ * the tool.
+ *
+ * BarToolGroups are created by a {@link OO.ui.ToolGroupFactory tool group factory} when the toolbar is
+ * set up.
+ *
+ *     @example
+ *     // Example of a BarToolGroup with two tools
+ *     var toolFactory = new OO.ui.ToolFactory();
+ *     var toolGroupFactory = new OO.ui.ToolGroupFactory();
+ *     var toolbar = new OO.ui.Toolbar( toolFactory, toolGroupFactory );
+ *
+ *     // We will be placing status text in this element when tools are used
+ *     var $area = $( '<p>' ).text( 'Example of a BarToolGroup with two tools.' );
+ *
+ *     // Define the tools that we're going to place in our toolbar
+ *
+ *     // Create a class inheriting from OO.ui.Tool
+ *     function SearchTool() {
+ *         SearchTool.parent.apply( this, arguments );
+ *     }
+ *     OO.inheritClass( SearchTool, OO.ui.Tool );
+ *     // Each tool must have a 'name' (used as an internal identifier, see later) and at least one
+ *     // of 'icon' and 'title' (displayed icon and text).
+ *     SearchTool.static.name = 'search';
+ *     SearchTool.static.icon = 'search';
+ *     SearchTool.static.title = 'Search...';
+ *     // Defines the action that will happen when this tool is selected (clicked).
+ *     SearchTool.prototype.onSelect = function () {
+ *         $area.text( 'Search tool clicked!' );
+ *         // Never display this tool as "active" (selected).
+ *         this.setActive( false );
+ *     };
+ *     SearchTool.prototype.onUpdateState = function () {};
+ *     // Make this tool available in our toolFactory and thus our toolbar
+ *     toolFactory.register( SearchTool );
+ *
+ *     // This is a PopupTool. Rather than having a custom 'onSelect' action, it will display a
+ *     // little popup window (a PopupWidget).
+ *     function HelpTool( toolGroup, config ) {
+ *         OO.ui.PopupTool.call( this, toolGroup, $.extend( { popup: {
+ *             padded: true,
+ *             label: 'Help',
+ *             head: true
+ *         } }, config ) );
+ *         this.popup.$body.append( '<p>I am helpful!</p>' );
+ *     }
+ *     OO.inheritClass( HelpTool, OO.ui.PopupTool );
+ *     HelpTool.static.name = 'help';
+ *     HelpTool.static.icon = 'help';
+ *     HelpTool.static.title = 'Help';
+ *     toolFactory.register( HelpTool );
+ *
+ *     // Finally define which tools and in what order appear in the toolbar. Each tool may only be
+ *     // used once (but not all defined tools must be used).
+ *     toolbar.setup( [
+ *         {
+ *             // 'bar' tool groups display tools by icon only
+ *             type: 'bar',
+ *             include: [ 'search', 'help' ]
+ *         }
+ *     ] );
+ *
+ *     // Create some UI around the toolbar and place it in the document
+ *     var frame = new OO.ui.PanelLayout( {
+ *         expanded: false,
+ *         framed: true
+ *     } );
+ *     var contentFrame = new OO.ui.PanelLayout( {
+ *         expanded: false,
+ *         padded: true
+ *     } );
+ *     frame.$element.append(
+ *         toolbar.$element,
+ *         contentFrame.$element.append( $area )
+ *     );
+ *     $( 'body' ).append( frame.$element );
+ *
+ *     // Here is where the toolbar is actually built. This must be done after inserting it into the
+ *     // document.
+ *     toolbar.initialize();
+ *
+ * For more information about how to add tools to a bar tool group, please see {@link OO.ui.ToolGroup toolgroup}.
+ * For more information about toolbars in general, please see the [OOjs UI documentation on MediaWiki][1].
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
+ *
+ * @class
+ * @extends OO.ui.ToolGroup
+ *
+ * @constructor
+ * @param {OO.ui.Toolbar} toolbar
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.BarToolGroup = function OoUiBarToolGroup( toolbar, config ) {
+       // Allow passing positional parameters inside the config object
+       if ( OO.isPlainObject( toolbar ) && config === undefined ) {
+               config = toolbar;
+               toolbar = config.toolbar;
+       }
+
+       // Parent constructor
+       OO.ui.BarToolGroup.parent.call( this, toolbar, config );
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-barToolGroup' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.BarToolGroup, OO.ui.ToolGroup );
+
+/* Static Properties */
+
+OO.ui.BarToolGroup.static.titleTooltips = true;
+
+OO.ui.BarToolGroup.static.accelTooltips = true;
+
+OO.ui.BarToolGroup.static.name = 'bar';
+
+/**
+ * PopupToolGroup is an abstract base class used by both {@link OO.ui.MenuToolGroup MenuToolGroup}
+ * and {@link OO.ui.ListToolGroup ListToolGroup} to provide a popup--an overlaid menu or list of tools with an
+ * optional icon and label. This class can be used for other base classes that also use this functionality.
+ *
+ * @abstract
+ * @class
+ * @extends OO.ui.ToolGroup
+ * @mixins OO.ui.mixin.IconElement
+ * @mixins OO.ui.mixin.IndicatorElement
+ * @mixins OO.ui.mixin.LabelElement
+ * @mixins OO.ui.mixin.TitledElement
+ * @mixins OO.ui.mixin.ClippableElement
+ * @mixins OO.ui.mixin.TabIndexedElement
+ *
+ * @constructor
+ * @param {OO.ui.Toolbar} toolbar
+ * @param {Object} [config] Configuration options
+ * @cfg {string} [header] Text to display at the top of the popup
+ */
+OO.ui.PopupToolGroup = function OoUiPopupToolGroup( toolbar, config ) {
+       // Allow passing positional parameters inside the config object
+       if ( OO.isPlainObject( toolbar ) && config === undefined ) {
+               config = toolbar;
+               toolbar = config.toolbar;
+       }
+
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.PopupToolGroup.parent.call( this, toolbar, config );
+
+       // Properties
+       this.active = false;
+       this.dragging = false;
+       this.onBlurHandler = this.onBlur.bind( this );
+       this.$handle = $( '<span>' );
+
+       // Mixin constructors
+       OO.ui.mixin.IconElement.call( this, config );
+       OO.ui.mixin.IndicatorElement.call( this, config );
+       OO.ui.mixin.LabelElement.call( this, config );
+       OO.ui.mixin.TitledElement.call( this, config );
+       OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) );
+       OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) );
+
+       // Events
+       this.$handle.on( {
+               keydown: this.onHandleMouseKeyDown.bind( this ),
+               keyup: this.onHandleMouseKeyUp.bind( this ),
+               mousedown: this.onHandleMouseKeyDown.bind( this ),
+               mouseup: this.onHandleMouseKeyUp.bind( this )
+       } );
+
+       // Initialization
+       this.$handle
+               .addClass( 'oo-ui-popupToolGroup-handle' )
+               .append( this.$icon, this.$label, this.$indicator );
+       // If the pop-up should have a header, add it to the top of the toolGroup.
+       // Note: If this feature is useful for other widgets, we could abstract it into an
+       // OO.ui.HeaderedElement mixin constructor.
+       if ( config.header !== undefined ) {
+               this.$group
+                       .prepend( $( '<span>' )
+                               .addClass( 'oo-ui-popupToolGroup-header' )
+                               .text( config.header )
+                       );
+       }
+       this.$element
+               .addClass( 'oo-ui-popupToolGroup' )
+               .prepend( this.$handle );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.PopupToolGroup, OO.ui.ToolGroup );
+OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.IconElement );
+OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.IndicatorElement );
+OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.LabelElement );
+OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.TitledElement );
+OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.ClippableElement );
+OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.TabIndexedElement );
+
+/* Methods */
+
+/**
+ * @inheritdoc
+ */
+OO.ui.PopupToolGroup.prototype.setDisabled = function () {
+       // Parent method
+       OO.ui.PopupToolGroup.parent.prototype.setDisabled.apply( this, arguments );
+
+       if ( this.isDisabled() && this.isElementAttached() ) {
+               this.setActive( false );
+       }
+};
+
+/**
+ * Handle focus being lost.
+ *
+ * The event is actually generated from a mouseup/keyup, so it is not a normal blur event object.
+ *
+ * @protected
+ * @param {jQuery.Event} e Mouse up or key up event
+ */
+OO.ui.PopupToolGroup.prototype.onBlur = function ( e ) {
+       // Only deactivate when clicking outside the dropdown element
+       if ( $( e.target ).closest( '.oo-ui-popupToolGroup' )[ 0 ] !== this.$element[ 0 ] ) {
+               this.setActive( false );
+       }
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.PopupToolGroup.prototype.onMouseKeyUp = function ( e ) {
+       // Only close toolgroup when a tool was actually selected
+       if (
+               !this.isDisabled() && this.pressed && this.pressed === this.getTargetTool( e ) &&
+               ( e.which === OO.ui.MouseButtons.LEFT || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
+       ) {
+               this.setActive( false );
+       }
+       return OO.ui.PopupToolGroup.parent.prototype.onMouseKeyUp.call( this, e );
+};
+
+/**
+ * Handle mouse up and key up events.
+ *
+ * @protected
+ * @param {jQuery.Event} e Mouse up or key up event
+ */
+OO.ui.PopupToolGroup.prototype.onHandleMouseKeyUp = function ( e ) {
+       if (
+               !this.isDisabled() &&
+               ( e.which === OO.ui.MouseButtons.LEFT || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
+       ) {
+               return false;
+       }
+};
+
+/**
+ * Handle mouse down and key down events.
+ *
+ * @protected
+ * @param {jQuery.Event} e Mouse down or key down event
+ */
+OO.ui.PopupToolGroup.prototype.onHandleMouseKeyDown = function ( e ) {
+       if (
+               !this.isDisabled() &&
+               ( e.which === OO.ui.MouseButtons.LEFT || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
+       ) {
+               this.setActive( !this.active );
+               return false;
+       }
+};
+
+/**
+ * Switch into 'active' mode.
+ *
+ * When active, the popup is visible. A mouseup event anywhere in the document will trigger
+ * deactivation.
+ */
+OO.ui.PopupToolGroup.prototype.setActive = function ( value ) {
+       var containerWidth, containerLeft;
+       value = !!value;
+       if ( this.active !== value ) {
+               this.active = value;
+               if ( value ) {
+                       this.getElementDocument().addEventListener( 'mouseup', this.onBlurHandler, true );
+                       this.getElementDocument().addEventListener( 'keyup', this.onBlurHandler, true );
+
+                       this.$clippable.css( 'left', '' );
+                       // Try anchoring the popup to the left first
+                       this.$element.addClass( 'oo-ui-popupToolGroup-active oo-ui-popupToolGroup-left' );
+                       this.toggleClipping( true );
+                       if ( this.isClippedHorizontally() ) {
+                               // Anchoring to the left caused the popup to clip, so anchor it to the right instead
+                               this.toggleClipping( false );
+                               this.$element
+                                       .removeClass( 'oo-ui-popupToolGroup-left' )
+                                       .addClass( 'oo-ui-popupToolGroup-right' );
+                               this.toggleClipping( true );
+                       }
+                       if ( this.isClippedHorizontally() ) {
+                               // Anchoring to the right also caused the popup to clip, so just make it fill the container
+                               containerWidth = this.$clippableScrollableContainer.width();
+                               containerLeft = this.$clippableScrollableContainer.offset().left;
+
+                               this.toggleClipping( false );
+                               this.$element.removeClass( 'oo-ui-popupToolGroup-right' );
+
+                               this.$clippable.css( {
+                                       left: -( this.$element.offset().left - containerLeft ),
+                                       width: containerWidth
+                               } );
+                       }
+               } else {
+                       this.getElementDocument().removeEventListener( 'mouseup', this.onBlurHandler, true );
+                       this.getElementDocument().removeEventListener( 'keyup', this.onBlurHandler, true );
+                       this.$element.removeClass(
+                               'oo-ui-popupToolGroup-active oo-ui-popupToolGroup-left  oo-ui-popupToolGroup-right'
+                       );
+                       this.toggleClipping( false );
+               }
+       }
+};
+
+/**
+ * ListToolGroups are one of three types of {@link OO.ui.ToolGroup toolgroups} that are used to
+ * create {@link OO.ui.Toolbar toolbars} (the other types of groups are {@link OO.ui.MenuToolGroup MenuToolGroup}
+ * and {@link OO.ui.BarToolGroup BarToolGroup}). The {@link OO.ui.Tool tools} in a ListToolGroup are displayed
+ * by label in a dropdown menu. The title of the tool is used as the label text. The menu itself can be configured
+ * with a label, icon, indicator, header, and title.
+ *
+ * ListToolGroups can be configured to be expanded and collapsed. Collapsed lists will have a ‘More’ option that
+ * users can select to see the full list of tools. If a collapsed toolgroup is expanded, a ‘Fewer’ option permits
+ * users to collapse the list again.
+ *
+ * ListToolGroups are created by a {@link OO.ui.ToolGroupFactory toolgroup factory} when the toolbar is set up. The factory
+ * requires the ListToolGroup's symbolic name, 'list', which is specified along with the other configurations. For more
+ * information about how to add tools to a ListToolGroup, please see {@link OO.ui.ToolGroup toolgroup}.
+ *
+ *     @example
+ *     // Example of a ListToolGroup
+ *     var toolFactory = new OO.ui.ToolFactory();
+ *     var toolGroupFactory = new OO.ui.ToolGroupFactory();
+ *     var toolbar = new OO.ui.Toolbar( toolFactory, toolGroupFactory );
+ *
+ *     // Configure and register two tools
+ *     function SettingsTool() {
+ *         SettingsTool.parent.apply( this, arguments );
+ *     }
+ *     OO.inheritClass( SettingsTool, OO.ui.Tool );
+ *     SettingsTool.static.name = 'settings';
+ *     SettingsTool.static.icon = 'settings';
+ *     SettingsTool.static.title = 'Change settings';
+ *     SettingsTool.prototype.onSelect = function () {
+ *         this.setActive( false );
+ *     };
+ *     SettingsTool.prototype.onUpdateState = function () {};
+ *     toolFactory.register( SettingsTool );
+ *     // Register two more tools, nothing interesting here
+ *     function StuffTool() {
+ *         StuffTool.parent.apply( this, arguments );
+ *     }
+ *     OO.inheritClass( StuffTool, OO.ui.Tool );
+ *     StuffTool.static.name = 'stuff';
+ *     StuffTool.static.icon = 'search';
+ *     StuffTool.static.title = 'Change the world';
+ *     StuffTool.prototype.onSelect = function () {
+ *         this.setActive( false );
+ *     };
+ *     StuffTool.prototype.onUpdateState = function () {};
+ *     toolFactory.register( StuffTool );
+ *     toolbar.setup( [
+ *         {
+ *             // Configurations for list toolgroup.
+ *             type: 'list',
+ *             label: 'ListToolGroup',
+ *             indicator: 'down',
+ *             icon: 'ellipsis',
+ *             title: 'This is the title, displayed when user moves the mouse over the list toolgroup',
+ *             header: 'This is the header',
+ *             include: [ 'settings', 'stuff' ],
+ *             allowCollapse: ['stuff']
+ *         }
+ *     ] );
+ *
+ *     // Create some UI around the toolbar and place it in the document
+ *     var frame = new OO.ui.PanelLayout( {
+ *         expanded: false,
+ *         framed: true
+ *     } );
+ *     frame.$element.append(
+ *         toolbar.$element
+ *     );
+ *     $( 'body' ).append( frame.$element );
+ *     // Build the toolbar. This must be done after the toolbar has been appended to the document.
+ *     toolbar.initialize();
+ *
+ * For more information about toolbars in general, please see the [OOjs UI documentation on MediaWiki][1].
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
+ *
+ * @class
+ * @extends OO.ui.PopupToolGroup
+ *
+ * @constructor
+ * @param {OO.ui.Toolbar} toolbar
+ * @param {Object} [config] Configuration options
+ * @cfg {Array} [allowCollapse] Allow the specified tools to be collapsed. By default, collapsible tools
+ *  will only be displayed if users click the ‘More’ option displayed at the bottom of the list. If
+ *  the list is expanded, a ‘Fewer’ option permits users to collapse the list again. Any tools that
+ *  are included in the toolgroup, but are not designated as collapsible, will always be displayed.
+ *  To open a collapsible list in its expanded state, set #expanded to 'true'.
+ * @cfg {Array} [forceExpand] Expand the specified tools. All other tools will be designated as collapsible.
+ *  Unless #expanded is set to true, the collapsible tools will be collapsed when the list is first opened.
+ * @cfg {boolean} [expanded=false] Expand collapsible tools. This config is only relevant if tools have
+ *  been designated as collapsible. When expanded is set to true, all tools in the group will be displayed
+ *  when the list is first opened. Users can collapse the list with a ‘Fewer’ option at the bottom.
+ */
+OO.ui.ListToolGroup = function OoUiListToolGroup( toolbar, config ) {
+       // Allow passing positional parameters inside the config object
+       if ( OO.isPlainObject( toolbar ) && config === undefined ) {
+               config = toolbar;
+               toolbar = config.toolbar;
+       }
+
+       // Configuration initialization
+       config = config || {};
+
+       // Properties (must be set before parent constructor, which calls #populate)
+       this.allowCollapse = config.allowCollapse;
+       this.forceExpand = config.forceExpand;
+       this.expanded = config.expanded !== undefined ? config.expanded : false;
+       this.collapsibleTools = [];
+
+       // Parent constructor
+       OO.ui.ListToolGroup.parent.call( this, toolbar, config );
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-listToolGroup' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.ListToolGroup, OO.ui.PopupToolGroup );
+
+/* Static Properties */
+
+OO.ui.ListToolGroup.static.name = 'list';
+
+/* Methods */
+
+/**
+ * @inheritdoc
+ */
+OO.ui.ListToolGroup.prototype.populate = function () {
+       var i, len, allowCollapse = [];
+
+       OO.ui.ListToolGroup.parent.prototype.populate.call( this );
+
+       // Update the list of collapsible tools
+       if ( this.allowCollapse !== undefined ) {
+               allowCollapse = this.allowCollapse;
+       } else if ( this.forceExpand !== undefined ) {
+               allowCollapse = OO.simpleArrayDifference( Object.keys( this.tools ), this.forceExpand );
+       }
+
+       this.collapsibleTools = [];
+       for ( i = 0, len = allowCollapse.length; i < len; i++ ) {
+               if ( this.tools[ allowCollapse[ i ] ] !== undefined ) {
+                       this.collapsibleTools.push( this.tools[ allowCollapse[ i ] ] );
+               }
+       }
+
+       // Keep at the end, even when tools are added
+       this.$group.append( this.getExpandCollapseTool().$element );
+
+       this.getExpandCollapseTool().toggle( this.collapsibleTools.length !== 0 );
+       this.updateCollapsibleState();
+};
+
+OO.ui.ListToolGroup.prototype.getExpandCollapseTool = function () {
+       var ExpandCollapseTool;
+       if ( this.expandCollapseTool === undefined ) {
+               ExpandCollapseTool = function () {
+                       ExpandCollapseTool.parent.apply( this, arguments );
+               };
+
+               OO.inheritClass( ExpandCollapseTool, OO.ui.Tool );
+
+               ExpandCollapseTool.prototype.onSelect = function () {
+                       this.toolGroup.expanded = !this.toolGroup.expanded;
+                       this.toolGroup.updateCollapsibleState();
+                       this.setActive( false );
+               };
+               ExpandCollapseTool.prototype.onUpdateState = function () {
+                       // Do nothing. Tool interface requires an implementation of this function.
+               };
+
+               ExpandCollapseTool.static.name = 'more-fewer';
+
+               this.expandCollapseTool = new ExpandCollapseTool( this );
+       }
+       return this.expandCollapseTool;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.ListToolGroup.prototype.onMouseKeyUp = function ( e ) {
+       // Do not close the popup when the user wants to show more/fewer tools
+       if (
+               $( e.target ).closest( '.oo-ui-tool-name-more-fewer' ).length &&
+               ( e.which === OO.ui.MouseButtons.LEFT || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
+       ) {
+               // HACK: Prevent the popup list from being hidden. Skip the PopupToolGroup implementation (which
+               // hides the popup list when a tool is selected) and call ToolGroup's implementation directly.
+               return OO.ui.ListToolGroup.parent.parent.prototype.onMouseKeyUp.call( this, e );
+       } else {
+               return OO.ui.ListToolGroup.parent.prototype.onMouseKeyUp.call( this, e );
+       }
+};
+
+OO.ui.ListToolGroup.prototype.updateCollapsibleState = function () {
+       var i, len;
+
+       this.getExpandCollapseTool()
+               .setIcon( this.expanded ? 'collapse' : 'expand' )
+               .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 );
+       }
+};
+
+/**
+ * MenuToolGroups are one of three types of {@link OO.ui.ToolGroup toolgroups} that are used to
+ * create {@link OO.ui.Toolbar toolbars} (the other types of groups are {@link OO.ui.BarToolGroup BarToolGroup}
+ * and {@link OO.ui.ListToolGroup ListToolGroup}). MenuToolGroups contain selectable {@link OO.ui.Tool tools},
+ * which are displayed by label in a dropdown menu. The tool's title is used as the label text, and the
+ * menu label is updated to reflect which tool or tools are currently selected. If no tools are selected,
+ * the menu label is empty. The menu can be configured with an indicator, icon, title, and/or header.
+ *
+ * MenuToolGroups are created by a {@link OO.ui.ToolGroupFactory tool group factory} when the toolbar
+ * is set up.
+ *
+ *     @example
+ *     // Example of a MenuToolGroup
+ *     var toolFactory = new OO.ui.ToolFactory();
+ *     var toolGroupFactory = new OO.ui.ToolGroupFactory();
+ *     var toolbar = new OO.ui.Toolbar( toolFactory, toolGroupFactory );
+ *
+ *     // We will be placing status text in this element when tools are used
+ *     var $area = $( '<p>' ).text( 'An example of a MenuToolGroup. Select a tool from the dropdown menu.' );
+ *
+ *     // Define the tools that we're going to place in our toolbar
+ *
+ *     function SettingsTool() {
+ *         SettingsTool.parent.apply( this, arguments );
+ *         this.reallyActive = false;
+ *     }
+ *     OO.inheritClass( SettingsTool, OO.ui.Tool );
+ *     SettingsTool.static.name = 'settings';
+ *     SettingsTool.static.icon = 'settings';
+ *     SettingsTool.static.title = 'Change settings';
+ *     SettingsTool.prototype.onSelect = function () {
+ *         $area.text( 'Settings tool clicked!' );
+ *         // Toggle the active state on each click
+ *         this.reallyActive = !this.reallyActive;
+ *         this.setActive( this.reallyActive );
+ *         // To update the menu label
+ *         this.toolbar.emit( 'updateState' );
+ *     };
+ *     SettingsTool.prototype.onUpdateState = function () {};
+ *     toolFactory.register( SettingsTool );
+ *
+ *     function StuffTool() {
+ *         StuffTool.parent.apply( this, arguments );
+ *         this.reallyActive = false;
+ *     }
+ *     OO.inheritClass( StuffTool, OO.ui.Tool );
+ *     StuffTool.static.name = 'stuff';
+ *     StuffTool.static.icon = 'ellipsis';
+ *     StuffTool.static.title = 'More stuff';
+ *     StuffTool.prototype.onSelect = function () {
+ *         $area.text( 'More stuff tool clicked!' );
+ *         // Toggle the active state on each click
+ *         this.reallyActive = !this.reallyActive;
+ *         this.setActive( this.reallyActive );
+ *         // To update the menu label
+ *         this.toolbar.emit( 'updateState' );
+ *     };
+ *     StuffTool.prototype.onUpdateState = function () {};
+ *     toolFactory.register( StuffTool );
+ *
+ *     // Finally define which tools and in what order appear in the toolbar. Each tool may only be
+ *     // used once (but not all defined tools must be used).
+ *     toolbar.setup( [
+ *         {
+ *             type: 'menu',
+ *             header: 'This is the (optional) header',
+ *             title: 'This is the (optional) title',
+ *             indicator: 'down',
+ *             include: [ 'settings', 'stuff' ]
+ *         }
+ *     ] );
+ *
+ *     // Create some UI around the toolbar and place it in the document
+ *     var frame = new OO.ui.PanelLayout( {
+ *         expanded: false,
+ *         framed: true
+ *     } );
+ *     var contentFrame = new OO.ui.PanelLayout( {
+ *         expanded: false,
+ *         padded: true
+ *     } );
+ *     frame.$element.append(
+ *         toolbar.$element,
+ *         contentFrame.$element.append( $area )
+ *     );
+ *     $( 'body' ).append( frame.$element );
+ *
+ *     // Here is where the toolbar is actually built. This must be done after inserting it into the
+ *     // document.
+ *     toolbar.initialize();
+ *     toolbar.emit( 'updateState' );
+ *
+ * For more information about how to add tools to a MenuToolGroup, please see {@link OO.ui.ToolGroup toolgroup}.
+ * For more information about toolbars in general, please see the [OOjs UI documentation on MediaWiki] [1].
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
+ *
+ * @class
+ * @extends OO.ui.PopupToolGroup
+ *
+ * @constructor
+ * @param {OO.ui.Toolbar} toolbar
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.MenuToolGroup = function OoUiMenuToolGroup( toolbar, config ) {
+       // Allow passing positional parameters inside the config object
+       if ( OO.isPlainObject( toolbar ) && config === undefined ) {
+               config = toolbar;
+               toolbar = config.toolbar;
+       }
+
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.MenuToolGroup.parent.call( this, toolbar, config );
+
+       // Events
+       this.toolbar.connect( this, { updateState: 'onUpdateState' } );
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-menuToolGroup' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.MenuToolGroup, OO.ui.PopupToolGroup );
+
+/* Static Properties */
+
+OO.ui.MenuToolGroup.static.name = 'menu';
+
+/* Methods */
+
+/**
+ * Handle the toolbar state being updated.
+ *
+ * When the state changes, the title of each active item in the menu will be joined together and
+ * used as a label for the group. The label will be empty if none of the items are active.
+ *
+ * @private
+ */
+OO.ui.MenuToolGroup.prototype.onUpdateState = function () {
+       var name,
+               labelTexts = [];
+
+       for ( name in this.tools ) {
+               if ( this.tools[ name ].isActive() ) {
+                       labelTexts.push( this.tools[ name ].getTitle() );
+               }
+       }
+
+       this.setLabel( labelTexts.join( ', ' ) || ' ' );
+};
+
+}( OO ) );
diff --git a/resources/lib/oojs-ui/oojs-ui-widgets-apex.css b/resources/lib/oojs-ui/oojs-ui-widgets-apex.css
new file mode 100644 (file)
index 0000000..252e402
--- /dev/null
@@ -0,0 +1,936 @@
+/*!
+ * OOjs UI v0.15.2
+ * https://www.mediawiki.org/wiki/OOjs_UI
+ *
+ * Copyright 2011–2016 OOjs UI Team and other contributors.
+ * Released under the MIT license
+ * http://oojs.mit-license.org
+ *
+ * Date: 2016-02-02T22:07:06Z
+ */
+@-webkit-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@-moz-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@-ms-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@-o-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+.oo-ui-draggableElement {
+       cursor: -webkit-grab -moz-grab, url(images/grab.cur), move;
+}
+.oo-ui-draggableElement-dragging {
+       cursor: -webkit-grabbing -moz-grabbing, url(images/grabbing.cur), move;
+       background: rgba(0, 0, 0, 0.2);
+       opacity: 0.4;
+}
+.oo-ui-draggableGroupElement-horizontal .oo-ui-draggableElement.oo-ui-optionWidget {
+       display: inline-block;
+}
+.oo-ui-draggableGroupElement-placeholder {
+       position: absolute;
+       display: block;
+       background: rgba(0, 0, 0, 0.4);
+}
+.oo-ui-lookupElement > .oo-ui-menuSelectWidget {
+       z-index: 1;
+       width: 100%;
+}
+.oo-ui-bookletLayout-stackLayout.oo-ui-stackLayout-continuous > .oo-ui-panelLayout-scrollable {
+       overflow-y: hidden;
+}
+.oo-ui-bookletLayout-stackLayout > .oo-ui-panelLayout {
+       width: 100%;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-bookletLayout-stackLayout > .oo-ui-panelLayout-scrollable {
+       overflow-y: auto;
+}
+.oo-ui-bookletLayout-stackLayout > .oo-ui-panelLayout-padded {
+       padding: 2em;
+}
+.oo-ui-bookletLayout-outlinePanel-editable > .oo-ui-outlineSelectWidget {
+       position: absolute;
+       top: 0;
+       left: 0;
+       right: 0;
+       bottom: 3em;
+       overflow-y: auto;
+}
+.oo-ui-bookletLayout-outlinePanel > .oo-ui-outlineControlsWidget {
+       position: absolute;
+       bottom: 0;
+       left: 0;
+       right: 0;
+}
+.oo-ui-bookletLayout-stackLayout > .oo-ui-panelLayout {
+       padding: 1.5em;
+}
+.oo-ui-bookletLayout-outlinePanel {
+       border-right: 1px solid #dddddd;
+}
+.oo-ui-bookletLayout-outlinePanel > .oo-ui-outlineControlsWidget {
+       box-shadow: 0 0 0.25em rgba(0, 0, 0, 0.25);
+}
+.oo-ui-indexLayout > .oo-ui-menuLayout-menu {
+       height: 3em;
+}
+.oo-ui-indexLayout > .oo-ui-menuLayout-content {
+       top: 3em;
+}
+.oo-ui-indexLayout-stackLayout > .oo-ui-panelLayout {
+       padding: 1.5em;
+}
+.oo-ui-menuLayout {
+       position: absolute;
+       top: 0;
+       left: 0;
+       right: 0;
+       bottom: 0;
+}
+.oo-ui-menuLayout-menu,
+.oo-ui-menuLayout-content {
+       position: absolute;
+       -webkit-transition: all 200ms ease;
+          -moz-transition: all 200ms ease;
+               transition: all 200ms ease;
+}
+.oo-ui-menuLayout-menu {
+       height: 18em;
+       width: 18em;
+}
+.oo-ui-menuLayout-content {
+       top: 18em;
+       left: 18em;
+       right: 18em;
+       bottom: 18em;
+}
+.oo-ui-menuLayout.oo-ui-menuLayout-hideMenu > .oo-ui-menuLayout-menu {
+       width: 0 !important;
+       height: 0 !important;
+       overflow: hidden;
+}
+.oo-ui-menuLayout.oo-ui-menuLayout-hideMenu > .oo-ui-menuLayout-content {
+       top: 0 !important;
+       left: 0 !important;
+       right: 0 !important;
+       bottom: 0 !important;
+}
+.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-top > .oo-ui-menuLayout-menu {
+       width: auto !important;
+       left: 0;
+       top: 0;
+       right: 0;
+}
+.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-top > .oo-ui-menuLayout-content {
+       right: 0 !important;
+       bottom: 0 !important;
+       left: 0 !important;
+}
+.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-after > .oo-ui-menuLayout-menu {
+       height: auto !important;
+       top: 0;
+       right: 0;
+       bottom: 0;
+}
+.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-after > .oo-ui-menuLayout-content {
+       bottom: 0 !important;
+       left: 0 !important;
+       top: 0 !important;
+}
+.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-bottom > .oo-ui-menuLayout-menu {
+       width: auto !important;
+       right: 0;
+       bottom: 0;
+       left: 0;
+}
+.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-bottom > .oo-ui-menuLayout-content {
+       left: 0 !important;
+       top: 0 !important;
+       right: 0 !important;
+}
+.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-before > .oo-ui-menuLayout-menu {
+       height: auto !important;
+       bottom: 0;
+       left: 0;
+       top: 0;
+}
+.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-before > .oo-ui-menuLayout-content {
+       top: 0 !important;
+       right: 0 !important;
+       bottom: 0 !important;
+}
+.oo-ui-stackLayout-continuous > .oo-ui-panelLayout {
+       display: block;
+       position: relative;
+}
+.oo-ui-buttonSelectWidget {
+       display: inline-block;
+       white-space: nowrap;
+       border-radius: 0.3em;
+       margin-right: 0.5em;
+}
+.oo-ui-buttonSelectWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-buttonSelectWidget .oo-ui-buttonOptionWidget .oo-ui-buttonElement-button {
+       border-radius: 0;
+       margin-left: -1px;
+}
+.oo-ui-buttonSelectWidget .oo-ui-buttonOptionWidget:first-child .oo-ui-buttonElement-button {
+       border-bottom-left-radius: 0.3em;
+       border-top-left-radius: 0.3em;
+       margin-left: 0;
+}
+.oo-ui-buttonSelectWidget .oo-ui-buttonOptionWidget:last-child .oo-ui-buttonElement-button {
+       border-bottom-right-radius: 0.3em;
+       border-top-right-radius: 0.3em;
+}
+.oo-ui-buttonOptionWidget {
+       display: inline-block;
+       padding: 0;
+       background-color: transparent;
+}
+.oo-ui-buttonOptionWidget .oo-ui-buttonElement-button {
+       position: relative;
+}
+.oo-ui-buttonOptionWidget.oo-ui-iconElement .oo-ui-iconElement-icon,
+.oo-ui-buttonOptionWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
+       position: static;
+       display: inline-block;
+       vertical-align: middle;
+}
+.oo-ui-buttonOptionWidget .oo-ui-buttonElement-button {
+       height: 1.875em;
+}
+.oo-ui-buttonOptionWidget.oo-ui-iconElement .oo-ui-iconElement-icon {
+       margin-top: 0;
+}
+.oo-ui-buttonOptionWidget.oo-ui-optionWidget-selected,
+.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed,
+.oo-ui-buttonOptionWidget.oo-ui-optionWidget-highlighted {
+       background-color: transparent;
+}
+.oo-ui-toggleButtonWidget {
+       display: inline-block;
+       vertical-align: middle;
+       margin-right: 0.5em;
+}
+.oo-ui-toggleButtonWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-toggleSwitchWidget {
+       position: relative;
+       display: inline-block;
+       vertical-align: middle;
+       overflow: hidden;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+       -webkit-transform: translateZ(0);
+          -moz-transform: translateZ(0);
+           -ms-transform: translateZ(0);
+               transform: translateZ(0);
+       height: 2em;
+       width: 4em;
+       border-radius: 1em;
+       box-shadow: 0 0 0 white, inset 0 0.1em 0.2em #dddddd;
+       border: 1px solid #cccccc;
+       margin-right: 0.5em;
+       background-color: #eeeeee;
+       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #dddddd), color-stop(100%, #ffffff));
+       background-image: -webkit-linear-gradient(top, #dddddd 0, #ffffff 100%);
+       background-image:    -moz-linear-gradient(top, #dddddd 0, #ffffff 100%);
+       background-image:         linear-gradient(to bottom, #dddddd 0, #ffffff 100%);
+       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffdddddd', endColorstr='#ffffffff' )";
+}
+.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled {
+       cursor: pointer;
+}
+.oo-ui-toggleSwitchWidget-grip {
+       position: absolute;
+       display: block;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-toggleSwitchWidget .oo-ui-toggleSwitchWidget-glow {
+       position: absolute;
+       top: 0;
+       bottom: 0;
+       right: 0;
+       left: 0;
+       -webkit-touch-callout: none;
+       -webkit-user-select: none;
+          -moz-user-select: none;
+           -ms-user-select: none;
+               user-select: none;
+}
+.oo-ui-toggleWidget-off .oo-ui-toggleSwitchWidget-glow {
+       display: none;
+}
+.oo-ui-toggleSwitchWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-toggleSwitchWidget.oo-ui-widget-disabled {
+       opacity: 0.5;
+}
+.oo-ui-toggleSwitchWidget-grip {
+       top: 0.25em;
+       left: 0.25em;
+       width: 1.5em;
+       height: 1.5em;
+       margin-top: -1px;
+       border-radius: 1em;
+       box-shadow: 0 0.1em 0.25em rgba(0, 0, 0, 0.1);
+       border: 1px #c9c9c9 solid;
+       -webkit-transition: left 250ms ease, margin-left 250ms ease;
+          -moz-transition: left 250ms ease, margin-left 250ms ease;
+               transition: left 250ms ease, margin-left 250ms ease;
+       background-color: #eeeeee;
+       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #ffffff), color-stop(100%, #dddddd));
+       background-image: -webkit-linear-gradient(top, #ffffff 0, #dddddd 100%);
+       background-image:    -moz-linear-gradient(top, #ffffff 0, #dddddd 100%);
+       background-image:         linear-gradient(to bottom, #ffffff 0, #dddddd 100%);
+       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffffffff', endColorstr='#ffdddddd' )";
+}
+.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:hover,
+.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:hover .oo-ui-toggleSwitchWidget-grip {
+       border-color: #aaaaaa;
+}
+.oo-ui-toggleSwitchWidget .oo-ui-toggleSwitchWidget-glow {
+       border-radius: 1em;
+       box-shadow: inset 0 1px 4px 0 rgba(0, 0, 0, 0.07);
+       -webkit-transition: opacity 250ms ease;
+          -moz-transition: opacity 250ms ease;
+               transition: opacity 250ms ease;
+       background-color: #cde7f4;
+       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #b0d9ee), color-stop(100%, #eaf4fa));
+       background-image: -webkit-linear-gradient(top, #b0d9ee 0, #eaf4fa 100%);
+       background-image:    -moz-linear-gradient(top, #b0d9ee 0, #eaf4fa 100%);
+       background-image:         linear-gradient(to bottom, #b0d9ee 0, #eaf4fa 100%);
+       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffb0d9ee', endColorstr='#ffeaf4fa' )";
+}
+.oo-ui-toggleWidget-on .oo-ui-toggleSwitchWidget-glow {
+       opacity: 1;
+}
+.oo-ui-toggleWidget-on .oo-ui-toggleSwitchWidget-grip {
+       left: 2.25em;
+       margin-left: -2px;
+}
+.oo-ui-toggleWidget-off .oo-ui-toggleSwitchWidget-glow {
+       display: block;
+       opacity: 0;
+}
+.oo-ui-toggleWidget-off .oo-ui-toggleSwitchWidget-grip {
+       left: 0.25em;
+       margin-left: 0;
+}
+.oo-ui-progressBarWidget {
+       max-width: 50em;
+       background-color: #ffffff;
+       border: 1px solid #cccccc;
+       border-radius: 0.25em;
+       overflow: hidden;
+}
+.oo-ui-progressBarWidget-bar {
+       height: 1em;
+       border-right: 1px solid #cccccc;
+       -webkit-transition: width 250ms ease, margin-left 250ms ease;
+          -moz-transition: width 250ms ease, margin-left 250ms ease;
+               transition: width 250ms ease, margin-left 250ms ease;
+       background-color: #cde7f4;
+       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #eaf4fa), color-stop(100%, #b0d9ee));
+       background-image: -webkit-linear-gradient(top, #eaf4fa 0, #b0d9ee 100%);
+       background-image:    -moz-linear-gradient(top, #eaf4fa 0, #b0d9ee 100%);
+       background-image:         linear-gradient(to bottom, #eaf4fa 0, #b0d9ee 100%);
+       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffeaf4fa', endColorstr='#ffb0d9ee' )";
+}
+.oo-ui-progressBarWidget-indeterminate .oo-ui-progressBarWidget-bar {
+       -webkit-animation: oo-ui-progressBarWidget-slide 2s infinite linear;
+          -moz-animation: oo-ui-progressBarWidget-slide 2s infinite linear;
+               animation: oo-ui-progressBarWidget-slide 2s infinite linear;
+       width: 40%;
+       margin-left: -10%;
+       border-left: 1px solid #a6cee1;
+}
+.oo-ui-progressBarWidget.oo-ui-widget-disabled {
+       opacity: 0.6;
+}
+.oo-ui-selectFileWidget {
+       display: inline-block;
+       vertical-align: middle;
+       width: 100%;
+       max-width: 50em;
+       margin-right: 0.5em;
+}
+.oo-ui-selectFileWidget-selectButton {
+       display: table-cell;
+       vertical-align: middle;
+}
+.oo-ui-selectFileWidget-selectButton > .oo-ui-buttonElement-button {
+       position: relative;
+       overflow: hidden;
+}
+.oo-ui-selectFileWidget-selectButton > .oo-ui-buttonElement-button > input[type="file"] {
+       position: absolute;
+       margin: 0;
+       top: 0;
+       bottom: 0;
+       left: 0;
+       right: 0;
+       width: 100%;
+       height: 100%;
+       opacity: 0;
+       z-index: 1;
+       cursor: pointer;
+       padding-top: 100px;
+}
+.oo-ui-selectFileWidget-selectButton.oo-ui-widget-disabled > .oo-ui-buttonElement-button > input[type="file"] {
+       display: none;
+}
+.oo-ui-selectFileWidget-info {
+       width: 100%;
+       display: table-cell;
+       vertical-align: middle;
+       position: relative;
+       overflow: hidden;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-label {
+       position: absolute;
+       top: 0;
+       bottom: 0;
+       left: 0;
+       right: 0;
+       text-overflow: ellipsis;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-label > .oo-ui-selectFileWidget-fileName {
+       float: left;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-label > .oo-ui-selectFileWidget-fileType {
+       float: right;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-indicatorElement-indicator,
+.oo-ui-selectFileWidget-info > .oo-ui-iconElement-icon,
+.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-clearButton {
+       position: absolute;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-clearButton {
+       z-index: 2;
+}
+.oo-ui-selectFileWidget-dropTarget {
+       cursor: default;
+}
+.oo-ui-selectFileWidget-supported.oo-ui-widget-enabled .oo-ui-selectFileWidget-dropTarget {
+       cursor: pointer;
+}
+.oo-ui-selectFileWidget-empty .oo-ui-selectFileWidget-clearButton,
+.oo-ui-selectFileWidget-notsupported .oo-ui-selectFileWidget-clearButton {
+       display: none;
+}
+.oo-ui-selectFileWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-selectFileWidget-selectButton > .oo-ui-buttonElement-button {
+       margin-left: 0.5em;
+}
+.oo-ui-selectFileWidget-info {
+       height: 2.4em;
+       background-color: #ffffff;
+       border: 1px solid rgba(0, 0, 0, 0.1);
+       border-radius: 0.25em;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-indicatorElement-indicator {
+       right: 0;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-iconElement-icon {
+       left: 0;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-label {
+       line-height: 2.3em;
+       margin: 0;
+       overflow: hidden;
+       white-space: nowrap;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+       text-overflow: ellipsis;
+       left: 0.5em;
+       right: 0.5em;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-label > .oo-ui-selectFileWidget-fileType {
+       color: #888888;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-clearButton {
+       top: 0;
+       width: 1.875em;
+       margin-right: 0;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-clearButton .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
+       height: 2.3em;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-indicatorElement-indicator {
+       top: 0;
+       width: 0.9375em;
+       height: 2.3em;
+       margin-right: 0.775em;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-iconElement-icon {
+       top: 0;
+       width: 1.875em;
+       height: 2.3em;
+       margin-left: 0.3em;
+}
+.oo-ui-selectFileWidget.oo-ui-widget-disabled .oo-ui-selectFileWidget-info {
+       color: #cccccc;
+       text-shadow: 0 1px 1px #ffffff;
+       border-color: #dddddd;
+       background-color: #f3f3f3;
+}
+.oo-ui-selectFileWidget.oo-ui-widget-disabled .oo-ui-selectFileWidget-info > .oo-ui-iconElement-icon,
+.oo-ui-selectFileWidget.oo-ui-widget-disabled .oo-ui-selectFileWidget-info > .oo-ui-indicatorElement-indicator {
+       opacity: 0.2;
+}
+.oo-ui-selectFileWidget-empty .oo-ui-selectFileWidget-label {
+       color: #cccccc;
+}
+.oo-ui-selectFileWidget.oo-ui-iconElement .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label {
+       left: 2.475em;
+}
+.oo-ui-selectFileWidget .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label {
+       right: 2.175em;
+}
+.oo-ui-selectFileWidget .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-clearButton {
+       right: 0;
+}
+.oo-ui-selectFileWidget.oo-ui-indicatorElement .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label {
+       right: 4.2625em;
+}
+.oo-ui-selectFileWidget.oo-ui-indicatorElement .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-clearButton {
+       right: 2.0875em;
+}
+.oo-ui-selectFileWidget-empty .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label,
+.oo-ui-selectFileWidget-notsupported .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label {
+       right: 0.5em;
+}
+.oo-ui-selectFileWidget-empty.oo-ui-indicatorElement .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label,
+.oo-ui-selectFileWidget-notsupported.oo-ui-indicatorElement .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label {
+       right: 2em;
+}
+.oo-ui-selectFileWidget-dropTarget {
+       line-height: 3.5em;
+       background-color: #ffffff;
+       border: 1px dashed #aaaaaa;
+       padding: 0.5em 1em;
+       margin-bottom: 0.5em;
+       text-align: center;
+       vertical-align: middle;
+}
+.oo-ui-selectFileWidget-supported.oo-ui-widget-enabled .oo-ui-selectFileWidget-dropTarget:hover,
+.oo-ui-selectFileWidget-supported.oo-ui-widget-enabled.oo-ui-selectFileWidget-canDrop oo-ui-selectfilewidget-droptarget {
+       background-color: #e1f3ff;
+}
+.oo-ui-selectFileWidget.oo-ui-widget-disabled .oo-ui-selectFileWidget-dropTarget,
+.oo-ui-selectFileWidget-notsupported .oo-ui-selectFileWidget-dropTarget {
+       color: #cccccc;
+       text-shadow: 0 1px 1px #ffffff;
+       border-color: #dddddd;
+       background-color: #f3f3f3;
+}
+.oo-ui-outlineOptionWidget {
+       position: relative;
+       cursor: pointer;
+       -webkit-touch-callout: none;
+       -webkit-user-select: none;
+          -moz-user-select: none;
+           -ms-user-select: none;
+               user-select: none;
+       font-size: 1.1em;
+       padding: 0.75em;
+}
+.oo-ui-outlineOptionWidget.oo-ui-indicatorElement .oo-ui-labelElement-label {
+       padding-right: 1.5em;
+}
+.oo-ui-outlineOptionWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
+       opacity: 0.5;
+}
+.oo-ui-outlineOptionWidget-level-0 {
+       padding-left: 3.5em;
+}
+.oo-ui-outlineOptionWidget-level-0 .oo-ui-iconElement-icon {
+       left: 1em;
+}
+.oo-ui-outlineOptionWidget-level-1 {
+       padding-left: 5em;
+}
+.oo-ui-outlineOptionWidget-level-1 .oo-ui-iconElement-icon {
+       left: 2.5em;
+}
+.oo-ui-outlineOptionWidget-level-2 {
+       padding-left: 6.5em;
+}
+.oo-ui-outlineOptionWidget-level-2 .oo-ui-iconElement-icon {
+       left: 4em;
+}
+.oo-ui-selectWidget-depressed .oo-ui-outlineOptionWidget.oo-ui-optionWidget-selected {
+       background-color: #a7dcff;
+       text-shadow: 0 1px 1px rgba(255, 255, 255, 0.5);
+}
+.oo-ui-outlineOptionWidget.oo-ui-flaggedElement-important {
+       font-weight: bold;
+}
+.oo-ui-outlineOptionWidget.oo-ui-flaggedElement-placeholder {
+       font-style: italic;
+}
+.oo-ui-outlineOptionWidget.oo-ui-flaggedElement-empty .oo-ui-iconElement-icon {
+       opacity: 0.5;
+}
+.oo-ui-outlineOptionWidget.oo-ui-flaggedElement-empty .oo-ui-labelElement-label {
+       color: #777777;
+}
+.oo-ui-outlineControlsWidget {
+       height: 3em;
+       background-color: #ffffff;
+}
+.oo-ui-outlineControlsWidget-items,
+.oo-ui-outlineControlsWidget-movers {
+       float: left;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-outlineControlsWidget > .oo-ui-iconElement-icon {
+       float: left;
+       background-position: right center;
+}
+.oo-ui-outlineControlsWidget-items {
+       float: left;
+}
+.oo-ui-outlineControlsWidget-items .oo-ui-buttonWidget {
+       float: left;
+}
+.oo-ui-outlineControlsWidget-movers {
+       float: right;
+}
+.oo-ui-outlineControlsWidget-movers .oo-ui-buttonWidget {
+       float: right;
+}
+.oo-ui-outlineControlsWidget-items,
+.oo-ui-outlineControlsWidget-movers {
+       height: 2em;
+       margin: 0.5em 0.5em 0.5em 0;
+       padding: 0;
+}
+.oo-ui-outlineControlsWidget > .oo-ui-iconElement-icon {
+       width: 1.5em;
+       height: 2em;
+       margin: 0.5em 0 0.5em 0.5em;
+       opacity: 0.2;
+}
+.oo-ui-tabSelectWidget {
+       text-align: left;
+       white-space: nowrap;
+       overflow: hidden;
+       background-color: #eeeeee;
+       box-shadow: inset 0 -0.015em 0.1em rgba(0, 0, 0, 0.1);
+}
+.oo-ui-tabOptionWidget {
+       display: inline-block;
+       vertical-align: bottom;
+       padding: 0.5em 1em;
+       margin: 0.5em 0 0 0.75em;
+       border: 1px solid transparent;
+       border-bottom: none;
+       border-top-left-radius: 0.5em;
+       border-top-right-radius: 0.5em;
+}
+.oo-ui-tabOptionWidget.oo-ui-indicatorElement .oo-ui-labelElement-label {
+       padding-right: 1.5em;
+}
+.oo-ui-tabOptionWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
+       opacity: 0.5;
+}
+.oo-ui-selectWidget-pressed .oo-ui-tabOptionWidget.oo-ui-optionWidget-pressed {
+       background-color: transparent;
+}
+.oo-ui-tabOptionWidget.oo-ui-widget-enabled:hover {
+       background-color: rgba(255, 255, 255, 0.2);
+       border-color: #dddddd;
+}
+.oo-ui-tabOptionWidget.oo-ui-widget-enabled:active {
+       background-color: #ffffff;
+       border-color: #dddddd;
+}
+.oo-ui-selectWidget-pressed .oo-ui-tabOptionWidget.oo-ui-optionWidget-selected,
+.oo-ui-selectWidget-depressed .oo-ui-tabOptionWidget.oo-ui-optionWidget-selected,
+.oo-ui-tabOptionWidget.oo-ui-optionWidget-selected:hover {
+       background-color: #ffffff;
+       border-color: #dddddd;
+}
+.oo-ui-capsuleMultiSelectWidget {
+       display: inline-block;
+       position: relative;
+       width: 100%;
+       max-width: 50em;
+}
+.oo-ui-capsuleMultiSelectWidget-handle {
+       width: 100%;
+       display: inline-block;
+       position: relative;
+}
+.oo-ui-capsuleMultiSelectWidget-content {
+       position: relative;
+}
+.oo-ui-capsuleMultiSelectWidget.oo-ui-widget-disabled .oo-ui-capsuleMultiSelectWidget-content > input {
+       display: none;
+}
+.oo-ui-capsuleMultiSelectWidget-group {
+       display: inline;
+}
+.oo-ui-capsuleMultiSelectWidget > .oo-ui-menuSelectWidget {
+       z-index: 1;
+       width: 100%;
+}
+.oo-ui-capsuleMultiSelectWidget-handle {
+       background-color: #ffffff;
+       cursor: text;
+       min-height: 2.4em;
+       margin-right: 0.5em;
+       padding: 0.15em 0.25em;
+       border: 1px solid rgba(0, 0, 0, 0.1);
+       border-radius: 0.25em;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-capsuleMultiSelectWidget-handle:last-child {
+       margin-right: 0;
+}
+.oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-indicatorElement-indicator,
+.oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-iconElement-icon {
+       position: absolute;
+       background-position: center center;
+       background-repeat: no-repeat;
+}
+.oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-capsuleMultiSelectWidget-content > input {
+       border: none;
+       line-height: 1.675em;
+       margin: 0;
+       margin-left: 0.2em;
+       padding: 0;
+       font-size: inherit;
+       font-family: inherit;
+       background-color: transparent;
+       color: black;
+       vertical-align: middle;
+}
+.oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-capsuleMultiSelectWidget-content > input:focus {
+       outline: none;
+}
+.oo-ui-capsuleMultiSelectWidget.oo-ui-indicatorElement .oo-ui-capsuleMultiSelectWidget-handle {
+       padding-right: 2.4875em;
+}
+.oo-ui-capsuleMultiSelectWidget.oo-ui-indicatorElement .oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-indicatorElement-indicator {
+       right: 0;
+       top: 0;
+       width: 0.9375em;
+       height: 0.9375em;
+       margin: 0.775em;
+}
+.oo-ui-capsuleMultiSelectWidget.oo-ui-iconElement .oo-ui-capsuleMultiSelectWidget-handle {
+       padding-left: 2.475em;
+}
+.oo-ui-capsuleMultiSelectWidget.oo-ui-iconElement .oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-iconElement-icon {
+       left: 0;
+       top: 0;
+       width: 1.875em;
+       height: 1.875em;
+       margin: 0.3em;
+}
+.oo-ui-capsuleMultiSelectWidget:hover .oo-ui-capsuleMultiSelectWidget-handle {
+       border-color: rgba(0, 0, 0, 0.2);
+}
+.oo-ui-capsuleMultiSelectWidget.oo-ui-widget-disabled .oo-ui-capsuleMultiSelectWidget-handle {
+       color: #cccccc;
+       text-shadow: 0 1px 1px #ffffff;
+       border-color: #dddddd;
+       background-color: #f3f3f3;
+       cursor: default;
+}
+.oo-ui-capsuleMultiSelectWidget.oo-ui-widget-disabled .oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-iconElement-icon,
+.oo-ui-capsuleMultiSelectWidget.oo-ui-widget-disabled .oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-indicatorElement-indicator {
+       opacity: 0.2;
+}
+.oo-ui-capsuleMultiSelectWidget .oo-ui-selectWidget {
+       border-top-color: #ffffff;
+}
+.oo-ui-capsuleItemWidget {
+       position: relative;
+       display: inline-block;
+       cursor: default;
+       white-space: nowrap;
+       width: auto;
+       max-width: 100%;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+       vertical-align: middle;
+       padding: 0 0.4em;
+       margin: 0.1em;
+       height: 1.7em;
+       line-height: 1.7em;
+       background-color: #eeeeee;
+       background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #ffffff), color-stop(100%, #dddddd));
+       background-image: -webkit-linear-gradient(top, #ffffff 0, #dddddd 100%);
+       background-image:    -moz-linear-gradient(top, #ffffff 0, #dddddd 100%);
+       background-image:         linear-gradient(to bottom, #ffffff 0, #dddddd 100%);
+       -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffffffff', endColorstr='#ffdddddd' )";
+       border: 1px solid #cccccc;
+       color: #555555;
+       border-radius: 0.25em;
+}
+.oo-ui-capsuleItemWidget > .oo-ui-iconElement-icon {
+       cursor: pointer;
+}
+.oo-ui-capsuleItemWidget.oo-ui-widget-disabled > .oo-ui-iconElement-icon {
+       cursor: default;
+}
+.oo-ui-capsuleItemWidget.oo-ui-labelElement .oo-ui-labelElement-label {
+       display: block;
+       text-overflow: ellipsis;
+       overflow: hidden;
+}
+.oo-ui-capsuleItemWidget.oo-ui-indicatorElement > .oo-ui-labelElement-label {
+       padding-right: 1.3375em;
+}
+.oo-ui-capsuleItemWidget.oo-ui-indicatorElement > .oo-ui-indicatorElement-indicator {
+       position: absolute;
+       right: 0.4em;
+       top: 0;
+       width: 0.9375em;
+       height: 100%;
+       background-repeat: no-repeat;
+}
+.oo-ui-capsuleItemWidget.oo-ui-indicatorElement > .oo-ui-indicator-clear {
+       cursor: pointer;
+}
+.oo-ui-capsuleItemWidget.oo-ui-widget-disabled {
+       opacity: 0.5;
+       -webkit-transform: translate3d(0, 0, 0);
+       box-shadow: none;
+       color: #333333;
+       background: #eeeeee;
+       border-color: #cccccc;
+}
+.oo-ui-capsuleItemWidget.oo-ui-widget-disabled > .oo-ui-indicatorElement-indicator {
+       opacity: 0.2;
+}
+.oo-ui-searchWidget-query {
+       position: absolute;
+       top: 0;
+       left: 0;
+       right: 0;
+}
+.oo-ui-searchWidget-query .oo-ui-textInputWidget {
+       width: 100%;
+}
+.oo-ui-searchWidget-results {
+       position: absolute;
+       bottom: 0;
+       left: 0;
+       right: 0;
+       overflow-x: hidden;
+       overflow-y: auto;
+}
+.oo-ui-searchWidget-query {
+       height: 4em;
+       padding: 0 1em;
+       box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.2);
+}
+.oo-ui-searchWidget-query .oo-ui-textInputWidget {
+       margin: 0.75em 0;
+}
+.oo-ui-searchWidget-results {
+       top: 4em;
+       padding: 1em;
+       line-height: 0;
+}
+.oo-ui-numberInputWidget {
+       display: inline-block;
+       position: relative;
+       max-width: 50em;
+}
+.oo-ui-numberInputWidget-field {
+       display: table;
+       table-layout: fixed;
+       width: 100%;
+}
+.oo-ui-numberInputWidget-field > .oo-ui-buttonWidget,
+.oo-ui-numberInputWidget-field > .oo-ui-textInputWidget {
+       display: table-cell;
+       vertical-align: middle;
+}
+.oo-ui-numberInputWidget-field > .oo-ui-textInputWidget {
+       width: 100%;
+}
+.oo-ui-numberInputWidget-field > .oo-ui-buttonWidget {
+       white-space: nowrap;
+}
+.oo-ui-numberInputWidget-field > .oo-ui-buttonWidget > .oo-ui-buttonElement-button {
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-numberInputWidget-field > .oo-ui-buttonWidget {
+       width: 2.25em;
+}
+.oo-ui-numberInputWidget-minusButton.oo-ui-buttonElement-framed.oo-ui-widget-enabled > .oo-ui-buttonElement-button {
+       border-top-right-radius: 0;
+       border-bottom-right-radius: 0;
+       border-right-width: 0;
+}
+.oo-ui-numberInputWidget-plusButton.oo-ui-buttonElement-framed.oo-ui-widget-enabled > .oo-ui-buttonElement-button {
+       border-top-left-radius: 0;
+       border-bottom-left-radius: 0;
+       border-left-width: 0;
+}
+.oo-ui-numberInputWidget .oo-ui-textInputWidget input {
+       border-radius: 0;
+}
diff --git a/resources/lib/oojs-ui/oojs-ui-widgets-mediawiki.css b/resources/lib/oojs-ui/oojs-ui-widgets-mediawiki.css
new file mode 100644 (file)
index 0000000..d5f8298
--- /dev/null
@@ -0,0 +1,930 @@
+/*!
+ * OOjs UI v0.15.2
+ * https://www.mediawiki.org/wiki/OOjs_UI
+ *
+ * Copyright 2011–2016 OOjs UI Team and other contributors.
+ * Released under the MIT license
+ * http://oojs.mit-license.org
+ *
+ * Date: 2016-02-02T22:07:06Z
+ */
+@-webkit-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@-moz-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+.oo-ui-draggableElement {
+       cursor: -webkit-grab -moz-grab, url(images/grab.cur), move;
+}
+.oo-ui-draggableElement-dragging {
+       cursor: -webkit-grabbing -moz-grabbing, url(images/grabbing.cur), move;
+       background: rgba(0, 0, 0, 0.2);
+       opacity: 0.4;
+}
+.oo-ui-draggableGroupElement-horizontal .oo-ui-draggableElement.oo-ui-optionWidget {
+       display: inline-block;
+}
+.oo-ui-draggableGroupElement-placeholder {
+       position: absolute;
+       display: block;
+       background: rgba(0, 0, 0, 0.4);
+}
+.oo-ui-lookupElement > .oo-ui-menuSelectWidget {
+       z-index: 1;
+       width: 100%;
+}
+.oo-ui-bookletLayout-stackLayout.oo-ui-stackLayout-continuous > .oo-ui-panelLayout-scrollable {
+       overflow-y: hidden;
+}
+.oo-ui-bookletLayout-stackLayout > .oo-ui-panelLayout {
+       width: 100%;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-bookletLayout-stackLayout > .oo-ui-panelLayout-scrollable {
+       overflow-y: auto;
+}
+.oo-ui-bookletLayout-stackLayout > .oo-ui-panelLayout-padded {
+       padding: 2em;
+}
+.oo-ui-bookletLayout-outlinePanel-editable > .oo-ui-outlineSelectWidget {
+       position: absolute;
+       top: 0;
+       left: 0;
+       right: 0;
+       bottom: 3em;
+       overflow-y: auto;
+}
+.oo-ui-bookletLayout-outlinePanel > .oo-ui-outlineControlsWidget {
+       position: absolute;
+       bottom: 0;
+       left: 0;
+       right: 0;
+}
+.oo-ui-bookletLayout-stackLayout > .oo-ui-panelLayout {
+       padding: 1.5em;
+}
+.oo-ui-bookletLayout-outlinePanel {
+       border-right: 1px solid #dddddd;
+}
+.oo-ui-bookletLayout-outlinePanel > .oo-ui-outlineControlsWidget {
+       box-shadow: 0 0.15em 0 0 rgba(0, 0, 0, 0.15);
+}
+.oo-ui-indexLayout > .oo-ui-menuLayout-menu {
+       height: 3em;
+}
+.oo-ui-indexLayout > .oo-ui-menuLayout-content {
+       top: 3em;
+}
+.oo-ui-indexLayout-stackLayout > .oo-ui-panelLayout {
+       padding: 1.5em;
+}
+.oo-ui-indexLayout > .oo-ui-menuLayout-menu {
+       height: 2.75em;
+}
+.oo-ui-indexLayout > .oo-ui-menuLayout-content {
+       top: 2.75em;
+}
+.oo-ui-menuLayout {
+       position: absolute;
+       top: 0;
+       left: 0;
+       right: 0;
+       bottom: 0;
+}
+.oo-ui-menuLayout-menu,
+.oo-ui-menuLayout-content {
+       position: absolute;
+       -webkit-transition: all 200ms ease;
+          -moz-transition: all 200ms ease;
+               transition: all 200ms ease;
+}
+.oo-ui-menuLayout-menu {
+       height: 18em;
+       width: 18em;
+}
+.oo-ui-menuLayout-content {
+       top: 18em;
+       left: 18em;
+       right: 18em;
+       bottom: 18em;
+}
+.oo-ui-menuLayout.oo-ui-menuLayout-hideMenu > .oo-ui-menuLayout-menu {
+       width: 0 !important;
+       height: 0 !important;
+       overflow: hidden;
+}
+.oo-ui-menuLayout.oo-ui-menuLayout-hideMenu > .oo-ui-menuLayout-content {
+       top: 0 !important;
+       left: 0 !important;
+       right: 0 !important;
+       bottom: 0 !important;
+}
+.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-top > .oo-ui-menuLayout-menu {
+       width: auto !important;
+       left: 0;
+       top: 0;
+       right: 0;
+}
+.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-top > .oo-ui-menuLayout-content {
+       right: 0 !important;
+       bottom: 0 !important;
+       left: 0 !important;
+}
+.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-after > .oo-ui-menuLayout-menu {
+       height: auto !important;
+       top: 0;
+       right: 0;
+       bottom: 0;
+}
+.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-after > .oo-ui-menuLayout-content {
+       bottom: 0 !important;
+       left: 0 !important;
+       top: 0 !important;
+}
+.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-bottom > .oo-ui-menuLayout-menu {
+       width: auto !important;
+       right: 0;
+       bottom: 0;
+       left: 0;
+}
+.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-bottom > .oo-ui-menuLayout-content {
+       left: 0 !important;
+       top: 0 !important;
+       right: 0 !important;
+}
+.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-before > .oo-ui-menuLayout-menu {
+       height: auto !important;
+       bottom: 0;
+       left: 0;
+       top: 0;
+}
+.oo-ui-menuLayout.oo-ui-menuLayout-showMenu.oo-ui-menuLayout-before > .oo-ui-menuLayout-content {
+       top: 0 !important;
+       right: 0 !important;
+       bottom: 0 !important;
+}
+.oo-ui-stackLayout-continuous > .oo-ui-panelLayout {
+       display: block;
+       position: relative;
+}
+.oo-ui-buttonSelectWidget {
+       display: inline-block;
+       white-space: nowrap;
+       border-radius: 2px;
+       margin-right: 0.5em;
+}
+.oo-ui-buttonSelectWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-buttonSelectWidget .oo-ui-buttonOptionWidget .oo-ui-buttonElement-button {
+       border-radius: 0;
+       margin-left: -1px;
+}
+.oo-ui-buttonSelectWidget .oo-ui-buttonOptionWidget:first-child .oo-ui-buttonElement-button {
+       border-bottom-left-radius: 2px;
+       border-top-left-radius: 2px;
+       margin-left: 0;
+}
+.oo-ui-buttonSelectWidget .oo-ui-buttonOptionWidget:last-child .oo-ui-buttonElement-button {
+       border-bottom-right-radius: 2px;
+       border-top-right-radius: 2px;
+}
+.oo-ui-buttonOptionWidget {
+       display: inline-block;
+       padding: 0;
+       background-color: transparent;
+}
+.oo-ui-buttonOptionWidget .oo-ui-buttonElement-button {
+       position: relative;
+}
+.oo-ui-buttonOptionWidget.oo-ui-iconElement .oo-ui-iconElement-icon,
+.oo-ui-buttonOptionWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
+       position: static;
+       display: inline-block;
+       vertical-align: middle;
+}
+.oo-ui-buttonOptionWidget.oo-ui-iconElement .oo-ui-iconElement-icon {
+       margin-top: 0;
+}
+.oo-ui-buttonOptionWidget.oo-ui-optionWidget-selected,
+.oo-ui-buttonOptionWidget.oo-ui-optionWidget-pressed,
+.oo-ui-buttonOptionWidget.oo-ui-optionWidget-highlighted {
+       background-color: transparent;
+}
+.oo-ui-buttonOptionWidget.oo-ui-widget-disabled .oo-ui-iconElement-icon,
+.oo-ui-buttonOptionWidget.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator {
+       opacity: 1;
+}
+.oo-ui-toggleButtonWidget {
+       display: inline-block;
+       vertical-align: middle;
+       margin-right: 0.5em;
+}
+.oo-ui-toggleButtonWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-toggleSwitchWidget {
+       position: relative;
+       display: inline-block;
+       vertical-align: middle;
+       overflow: hidden;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+       -webkit-transform: translateZ(0);
+          -moz-transform: translateZ(0);
+           -ms-transform: translateZ(0);
+               transform: translateZ(0);
+       height: 2em;
+       width: 3.5em;
+       border: 1px solid #777777;
+       border-radius: 1em;
+       background-color: #ffffff;
+       margin-right: 0.5em;
+       -webkit-transition: background-color 100ms ease, border-color 100ms ease;
+          -moz-transition: background-color 100ms ease, border-color 100ms ease;
+               transition: background-color 100ms ease, border-color 100ms ease;
+}
+.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled {
+       cursor: pointer;
+}
+.oo-ui-toggleSwitchWidget-grip {
+       position: absolute;
+       display: block;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-toggleSwitchWidget .oo-ui-toggleSwitchWidget-glow {
+       position: absolute;
+       top: 0;
+       bottom: 0;
+       right: 0;
+       left: 0;
+       -webkit-touch-callout: none;
+       -webkit-user-select: none;
+          -moz-user-select: none;
+           -ms-user-select: none;
+               user-select: none;
+}
+.oo-ui-toggleWidget-off .oo-ui-toggleSwitchWidget-glow {
+       display: none;
+}
+.oo-ui-toggleSwitchWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-toggleSwitchWidget:before {
+       content: "";
+       display: block;
+       position: absolute;
+       top: 0;
+       left: 0;
+       bottom: 0;
+       right: 0;
+       border: 1px solid transparent;
+       border-radius: 1em;
+       z-index: 1;
+}
+.oo-ui-toggleSwitchWidget-grip {
+       top: 0.35em;
+       width: 1.2em;
+       height: 1.2em;
+       border-radius: 1.2em;
+       background-color: #555555;
+       -webkit-transition: left 100ms ease, margin-left 100ms ease;
+          -moz-transition: left 100ms ease, margin-left 100ms ease;
+               transition: left 100ms ease, margin-left 100ms ease;
+}
+.oo-ui-toggleSwitchWidget-glow {
+       display: none;
+}
+.oo-ui-toggleSwitchWidget.oo-ui-toggleWidget-on .oo-ui-toggleSwitchWidget-grip {
+       left: 1.9em;
+       margin-left: -2px;
+}
+.oo-ui-toggleSwitchWidget.oo-ui-toggleWidget-off .oo-ui-toggleSwitchWidget-grip {
+       left: 0.4em;
+       margin-left: 0;
+}
+.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled.oo-ui-toggleWidget-on {
+       background-color: #347bff;
+       border-color: #347bff;
+}
+.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled.oo-ui-toggleWidget-on .oo-ui-toggleSwitchWidget-grip {
+       background-color: #ffffff;
+       box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
+}
+.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:hover {
+       border-color: #2962cc;
+}
+.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:hover.oo-ui-toggleWidget-on {
+       background-color: #2962cc;
+       border-color: #2962cc;
+}
+.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:focus {
+       border-color: #347bff;
+       outline: none;
+}
+.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:focus.oo-ui-toggleWidget-on {
+       border-color: #347bff;
+}
+.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:focus.oo-ui-toggleWidget-on:before {
+       border-color: #ffffff;
+}
+.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:active,
+.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:active:hover {
+       background-color: #347bff;
+       border-color: #347bff;
+}
+.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:active .oo-ui-toggleSwitchWidget-grip,
+.oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:active:hover .oo-ui-toggleSwitchWidget-grip {
+       background-color: #ffffff;
+       box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
+}
+.oo-ui-toggleSwitchWidget.oo-ui-widget-disabled {
+       background: #dddddd;
+       border-color: #dddddd;
+       outline: 0;
+}
+.oo-ui-toggleSwitchWidget.oo-ui-widget-disabled .oo-ui-toggleSwitchWidget-grip {
+       background: #ffffff;
+}
+.oo-ui-progressBarWidget {
+       max-width: 50em;
+       background-color: #ffffff;
+       border: 1px solid #cccccc;
+       border-radius: 2px;
+       overflow: hidden;
+}
+.oo-ui-progressBarWidget-bar {
+       height: 1em;
+       background: #dddddd;
+       -webkit-transition: width 200ms, margin-left 200ms;
+          -moz-transition: width 200ms, margin-left 200ms;
+               transition: width 200ms, margin-left 200ms;
+}
+.oo-ui-progressBarWidget-indeterminate .oo-ui-progressBarWidget-bar {
+       -webkit-animation: oo-ui-progressBarWidget-slide 2s infinite linear;
+          -moz-animation: oo-ui-progressBarWidget-slide 2s infinite linear;
+               animation: oo-ui-progressBarWidget-slide 2s infinite linear;
+       width: 40%;
+       margin-left: -10%;
+       border-left-width: 1px;
+}
+.oo-ui-progressBarWidget.oo-ui-widget-disabled {
+       opacity: 0.6;
+}
+.oo-ui-selectFileWidget {
+       display: inline-block;
+       vertical-align: middle;
+       width: 100%;
+       max-width: 50em;
+       margin-right: 0.5em;
+}
+.oo-ui-selectFileWidget-selectButton {
+       display: table-cell;
+       vertical-align: middle;
+}
+.oo-ui-selectFileWidget-selectButton > .oo-ui-buttonElement-button {
+       position: relative;
+       overflow: hidden;
+}
+.oo-ui-selectFileWidget-selectButton > .oo-ui-buttonElement-button > input[type="file"] {
+       position: absolute;
+       margin: 0;
+       top: 0;
+       bottom: 0;
+       left: 0;
+       right: 0;
+       width: 100%;
+       height: 100%;
+       opacity: 0;
+       z-index: 1;
+       cursor: pointer;
+       padding-top: 100px;
+}
+.oo-ui-selectFileWidget-selectButton.oo-ui-widget-disabled > .oo-ui-buttonElement-button > input[type="file"] {
+       display: none;
+}
+.oo-ui-selectFileWidget-info {
+       width: 100%;
+       display: table-cell;
+       vertical-align: middle;
+       position: relative;
+       overflow: hidden;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-label {
+       position: absolute;
+       top: 0;
+       bottom: 0;
+       left: 0;
+       right: 0;
+       text-overflow: ellipsis;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-label > .oo-ui-selectFileWidget-fileName {
+       float: left;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-label > .oo-ui-selectFileWidget-fileType {
+       float: right;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-indicatorElement-indicator,
+.oo-ui-selectFileWidget-info > .oo-ui-iconElement-icon,
+.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-clearButton {
+       position: absolute;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-clearButton {
+       z-index: 2;
+}
+.oo-ui-selectFileWidget-dropTarget {
+       cursor: default;
+}
+.oo-ui-selectFileWidget-supported.oo-ui-widget-enabled .oo-ui-selectFileWidget-dropTarget {
+       cursor: pointer;
+}
+.oo-ui-selectFileWidget-empty .oo-ui-selectFileWidget-clearButton,
+.oo-ui-selectFileWidget-notsupported .oo-ui-selectFileWidget-clearButton {
+       display: none;
+}
+.oo-ui-selectFileWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-selectFileWidget-selectButton > .oo-ui-buttonElement-button {
+       margin-left: 0.5em;
+}
+.oo-ui-selectFileWidget-info {
+       height: 2.4em;
+       background-color: #ffffff;
+       border: 1px solid #cccccc;
+       border-radius: 2px;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-indicatorElement-indicator {
+       right: 0;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-iconElement-icon {
+       left: 0;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-label {
+       line-height: 2.3em;
+       margin: 0;
+       overflow: hidden;
+       white-space: nowrap;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+       text-overflow: ellipsis;
+       left: 0.5em;
+       right: 0.5em;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-label > .oo-ui-selectFileWidget-fileType {
+       color: #888888;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-clearButton {
+       top: 0;
+       width: 1.875em;
+       margin-right: 0;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-selectFileWidget-clearButton .oo-ui-buttonElement-button > .oo-ui-iconElement-icon {
+       height: 2.3em;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-indicatorElement-indicator {
+       top: 0;
+       width: 0.9375em;
+       height: 2.3em;
+       margin-right: 0.775em;
+}
+.oo-ui-selectFileWidget-info > .oo-ui-iconElement-icon {
+       top: 0;
+       width: 1.875em;
+       height: 2.3em;
+       margin-left: 0.5em;
+}
+.oo-ui-selectFileWidget.oo-ui-widget-disabled .oo-ui-selectFileWidget-info {
+       color: #cccccc;
+       text-shadow: 0 1px 1px #ffffff;
+       border-color: #dddddd;
+       background-color: #f3f3f3;
+}
+.oo-ui-selectFileWidget.oo-ui-widget-disabled .oo-ui-selectFileWidget-info > .oo-ui-iconElement-icon,
+.oo-ui-selectFileWidget.oo-ui-widget-disabled .oo-ui-selectFileWidget-info > .oo-ui-indicatorElement-indicator {
+       opacity: 0.2;
+}
+.oo-ui-selectFileWidget-empty .oo-ui-selectFileWidget-label {
+       color: #cccccc;
+}
+.oo-ui-selectFileWidget.oo-ui-iconElement .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label {
+       left: 2.875em;
+}
+.oo-ui-selectFileWidget .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label {
+       right: 2.375em;
+}
+.oo-ui-selectFileWidget .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-clearButton {
+       right: 0;
+}
+.oo-ui-selectFileWidget.oo-ui-indicatorElement .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label {
+       right: 4.4625em;
+}
+.oo-ui-selectFileWidget.oo-ui-indicatorElement .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-clearButton {
+       right: 2.0875em;
+}
+.oo-ui-selectFileWidget-empty .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label,
+.oo-ui-selectFileWidget-notsupported .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label {
+       right: 0.5em;
+}
+.oo-ui-selectFileWidget-empty.oo-ui-indicatorElement .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label,
+.oo-ui-selectFileWidget-notsupported.oo-ui-indicatorElement .oo-ui-selectFileWidget-info .oo-ui-selectFileWidget-label {
+       right: 2em;
+}
+.oo-ui-selectFileWidget-dropTarget {
+       line-height: 3.5em;
+       background-color: #ffffff;
+       border: 1px dashed #cccccc;
+       padding: 0.5em 1em;
+       margin-bottom: 0.5em;
+       text-align: center;
+       vertical-align: middle;
+}
+.oo-ui-selectFileWidget-supported.oo-ui-widget-enabled .oo-ui-selectFileWidget-dropTarget:hover {
+       background-color: #eeeeee;
+}
+.oo-ui-selectFileWidget-supported.oo-ui-widget-enabled.oo-ui-selectFileWidget-canDrop .oo-ui-selectFileWidget-dropTarget {
+       background: rgba(52, 123, 255, 0.1);
+}
+.oo-ui-selectFileWidget.oo-ui-widget-disabled .oo-ui-selectFileWidget-dropTarget,
+.oo-ui-selectFileWidget-notsupported .oo-ui-selectFileWidget-dropTarget {
+       color: #cccccc;
+       text-shadow: 0 1px 1px #ffffff;
+       border-color: #dddddd;
+       background-color: #f3f3f3;
+}
+.oo-ui-outlineOptionWidget {
+       position: relative;
+       cursor: pointer;
+       -webkit-touch-callout: none;
+       -webkit-user-select: none;
+          -moz-user-select: none;
+           -ms-user-select: none;
+               user-select: none;
+       font-size: 1.1em;
+       padding: 0.75em;
+}
+.oo-ui-outlineOptionWidget.oo-ui-indicatorElement .oo-ui-labelElement-label {
+       padding-right: 1.5em;
+}
+.oo-ui-outlineOptionWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
+       opacity: 0.5;
+}
+.oo-ui-outlineOptionWidget-level-0 {
+       padding-left: 3.5em;
+}
+.oo-ui-outlineOptionWidget-level-0 .oo-ui-iconElement-icon {
+       left: 1em;
+}
+.oo-ui-outlineOptionWidget-level-1 {
+       padding-left: 5em;
+}
+.oo-ui-outlineOptionWidget-level-1 .oo-ui-iconElement-icon {
+       left: 2.5em;
+}
+.oo-ui-outlineOptionWidget-level-2 {
+       padding-left: 6.5em;
+}
+.oo-ui-outlineOptionWidget-level-2 .oo-ui-iconElement-icon {
+       left: 4em;
+}
+.oo-ui-selectWidget-depressed .oo-ui-outlineOptionWidget.oo-ui-optionWidget-selected {
+       background-color: #d0d0d0;
+       text-shadow: 0 1px 1px #ffffff;
+}
+.oo-ui-outlineOptionWidget.oo-ui-flaggedElement-important {
+       font-weight: bold;
+}
+.oo-ui-outlineOptionWidget.oo-ui-flaggedElement-placeholder {
+       font-style: italic;
+}
+.oo-ui-outlineOptionWidget.oo-ui-flaggedElement-empty .oo-ui-iconElement-icon {
+       opacity: 0.5;
+}
+.oo-ui-outlineOptionWidget.oo-ui-flaggedElement-empty .oo-ui-labelElement-label {
+       color: #777777;
+}
+.oo-ui-outlineControlsWidget {
+       height: 3em;
+       background-color: #ffffff;
+}
+.oo-ui-outlineControlsWidget-items,
+.oo-ui-outlineControlsWidget-movers {
+       float: left;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-outlineControlsWidget > .oo-ui-iconElement-icon {
+       float: left;
+       background-position: right center;
+}
+.oo-ui-outlineControlsWidget-items {
+       float: left;
+}
+.oo-ui-outlineControlsWidget-items .oo-ui-buttonWidget {
+       float: left;
+}
+.oo-ui-outlineControlsWidget-movers {
+       float: right;
+}
+.oo-ui-outlineControlsWidget-movers .oo-ui-buttonWidget {
+       float: right;
+}
+.oo-ui-outlineControlsWidget-items,
+.oo-ui-outlineControlsWidget-movers {
+       height: 2em;
+       margin: 0.5em 0.5em 0.5em 0;
+       padding: 0;
+}
+.oo-ui-outlineControlsWidget > .oo-ui-iconElement-icon {
+       width: 1.5em;
+       height: 2em;
+       margin: 0.5em 0 0.5em 0.5em;
+       opacity: 0.2;
+}
+.oo-ui-tabSelectWidget {
+       text-align: left;
+       white-space: nowrap;
+       overflow: hidden;
+       background-color: #dddddd;
+}
+.oo-ui-tabOptionWidget {
+       display: inline-block;
+       vertical-align: bottom;
+       padding: 0.35em 1em;
+       margin: 0.5em 0 0 0.75em;
+       border: 1px solid transparent;
+       border-bottom: none;
+       border-top-left-radius: 2px;
+       border-top-right-radius: 2px;
+       color: #555555;
+       font-weight: bold;
+}
+.oo-ui-tabOptionWidget.oo-ui-widget-enabled:hover {
+       background-color: rgba(255, 255, 255, 0.3);
+}
+.oo-ui-tabOptionWidget.oo-ui-widget-enabled:active {
+       background-color: rgba(255, 255, 255, 0.8);
+}
+.oo-ui-tabOptionWidget.oo-ui-indicatorElement .oo-ui-labelElement-label {
+       padding-right: 1.5em;
+}
+.oo-ui-tabOptionWidget.oo-ui-indicatorElement .oo-ui-indicatorElement-indicator {
+       opacity: 0.5;
+}
+.oo-ui-selectWidget-pressed .oo-ui-tabOptionWidget.oo-ui-optionWidget-selected,
+.oo-ui-selectWidget-depressed .oo-ui-tabOptionWidget.oo-ui-optionWidget-selected,
+.oo-ui-tabOptionWidget.oo-ui-optionWidget-selected:hover {
+       background-color: #ffffff;
+       color: #333333;
+}
+.oo-ui-capsuleMultiSelectWidget {
+       display: inline-block;
+       position: relative;
+       width: 100%;
+       max-width: 50em;
+}
+.oo-ui-capsuleMultiSelectWidget-handle {
+       width: 100%;
+       display: inline-block;
+       position: relative;
+}
+.oo-ui-capsuleMultiSelectWidget-content {
+       position: relative;
+}
+.oo-ui-capsuleMultiSelectWidget.oo-ui-widget-disabled .oo-ui-capsuleMultiSelectWidget-content > input {
+       display: none;
+}
+.oo-ui-capsuleMultiSelectWidget-group {
+       display: inline;
+}
+.oo-ui-capsuleMultiSelectWidget > .oo-ui-menuSelectWidget {
+       z-index: 1;
+       width: 100%;
+}
+.oo-ui-capsuleMultiSelectWidget-handle {
+       background-color: #ffffff;
+       cursor: text;
+       min-height: 2.4em;
+       margin-right: 0.5em;
+       padding: 0.15em 0.25em;
+       border: 1px solid #cccccc;
+       border-radius: 2px;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-capsuleMultiSelectWidget-handle:last-child {
+       margin-right: 0;
+}
+.oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-indicatorElement-indicator,
+.oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-iconElement-icon {
+       position: absolute;
+       background-position: center center;
+       background-repeat: no-repeat;
+}
+.oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-capsuleMultiSelectWidget-content > input {
+       border: 0;
+       line-height: 1.675em;
+       margin: 0 0 0 0.2em;
+       padding: 0;
+       font-size: inherit;
+       font-family: inherit;
+       background-color: transparent;
+       color: #000000;
+       vertical-align: middle;
+}
+.oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-capsuleMultiSelectWidget-content > input:focus {
+       outline: none;
+}
+.oo-ui-capsuleMultiSelectWidget.oo-ui-indicatorElement .oo-ui-capsuleMultiSelectWidget-handle {
+       padding-right: 2.4875em;
+}
+.oo-ui-capsuleMultiSelectWidget.oo-ui-indicatorElement .oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-indicatorElement-indicator {
+       right: 0;
+       top: 0;
+       width: 0.9375em;
+       height: 0.9375em;
+       margin: 0.775em;
+}
+.oo-ui-capsuleMultiSelectWidget.oo-ui-iconElement .oo-ui-capsuleMultiSelectWidget-handle {
+       padding-left: 2.475em;
+}
+.oo-ui-capsuleMultiSelectWidget.oo-ui-iconElement .oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-iconElement-icon {
+       left: 0;
+       top: 0;
+       width: 1.875em;
+       height: 1.875em;
+       margin: 0.3em;
+}
+.oo-ui-capsuleMultiSelectWidget:hover .oo-ui-capsuleMultiSelectWidget-handle {
+       border-color: #aaaaaa;
+}
+.oo-ui-capsuleMultiSelectWidget.oo-ui-widget-disabled .oo-ui-capsuleMultiSelectWidget-handle {
+       color: #cccccc;
+       text-shadow: 0 1px 1px #ffffff;
+       border-color: #dddddd;
+       background-color: #f3f3f3;
+       cursor: default;
+}
+.oo-ui-capsuleMultiSelectWidget.oo-ui-widget-disabled .oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-iconElement-icon,
+.oo-ui-capsuleMultiSelectWidget.oo-ui-widget-disabled .oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-indicatorElement-indicator {
+       opacity: 0.2;
+}
+.oo-ui-capsuleMultiSelectWidget .oo-ui-selectWidget {
+       border-top-color: #ffffff;
+}
+.oo-ui-capsuleItemWidget {
+       position: relative;
+       display: inline-block;
+       cursor: default;
+       white-space: nowrap;
+       width: auto;
+       max-width: 100%;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+       vertical-align: middle;
+       padding: 0 0.4em;
+       margin: 0.1em;
+       height: 1.7em;
+       line-height: 1.7em;
+       background-color: #eeeeee;
+       border: 1px solid #cccccc;
+       color: #555555;
+       border-radius: 2px;
+}
+.oo-ui-capsuleItemWidget > .oo-ui-iconElement-icon {
+       cursor: pointer;
+}
+.oo-ui-capsuleItemWidget.oo-ui-widget-disabled > .oo-ui-iconElement-icon {
+       cursor: default;
+}
+.oo-ui-capsuleItemWidget.oo-ui-labelElement .oo-ui-labelElement-label {
+       display: block;
+       text-overflow: ellipsis;
+       overflow: hidden;
+}
+.oo-ui-capsuleItemWidget.oo-ui-indicatorElement > .oo-ui-labelElement-label {
+       padding-right: 1.3375em;
+}
+.oo-ui-capsuleItemWidget.oo-ui-indicatorElement > .oo-ui-indicatorElement-indicator {
+       position: absolute;
+       right: 0.4em;
+       top: 0;
+       width: 0.9375em;
+       height: 100%;
+       background-repeat: no-repeat;
+}
+.oo-ui-capsuleItemWidget.oo-ui-indicatorElement > .oo-ui-indicator-clear {
+       cursor: pointer;
+}
+.oo-ui-capsuleItemWidget.oo-ui-widget-disabled {
+       color: #cccccc;
+       text-shadow: 0 1px 1px #ffffff;
+       border-color: #dddddd;
+       background-color: #f3f3f3;
+}
+.oo-ui-capsuleItemWidget.oo-ui-widget-disabled > .oo-ui-indicatorElement-indicator {
+       opacity: 0.2;
+}
+.oo-ui-searchWidget-query {
+       position: absolute;
+       top: 0;
+       left: 0;
+       right: 0;
+}
+.oo-ui-searchWidget-query .oo-ui-textInputWidget {
+       width: 100%;
+}
+.oo-ui-searchWidget-results {
+       position: absolute;
+       bottom: 0;
+       left: 0;
+       right: 0;
+       overflow-x: hidden;
+       overflow-y: auto;
+}
+.oo-ui-searchWidget-query {
+       height: 4em;
+       padding: 0 1em;
+       border-bottom: 1px solid #cccccc;
+}
+.oo-ui-searchWidget-query .oo-ui-textInputWidget {
+       margin: 0.75em 0;
+}
+.oo-ui-searchWidget-results {
+       top: 4em;
+       padding: 1em;
+       line-height: 0;
+}
+.oo-ui-numberInputWidget {
+       display: inline-block;
+       position: relative;
+       max-width: 50em;
+}
+.oo-ui-numberInputWidget-field {
+       display: table;
+       table-layout: fixed;
+       width: 100%;
+}
+.oo-ui-numberInputWidget-field > .oo-ui-buttonWidget,
+.oo-ui-numberInputWidget-field > .oo-ui-textInputWidget {
+       display: table-cell;
+       vertical-align: middle;
+}
+.oo-ui-numberInputWidget-field > .oo-ui-textInputWidget {
+       width: 100%;
+}
+.oo-ui-numberInputWidget-field > .oo-ui-buttonWidget {
+       white-space: nowrap;
+}
+.oo-ui-numberInputWidget-field > .oo-ui-buttonWidget > .oo-ui-buttonElement-button {
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-numberInputWidget-field > .oo-ui-buttonWidget {
+       width: 2.5em;
+}
+.oo-ui-numberInputWidget-minusButton.oo-ui-buttonElement-framed.oo-ui-widget-enabled > .oo-ui-buttonElement-button {
+       border-top-right-radius: 0;
+       border-bottom-right-radius: 0;
+       border-right-width: 0;
+}
+.oo-ui-numberInputWidget-plusButton.oo-ui-buttonElement-framed.oo-ui-widget-enabled > .oo-ui-buttonElement-button {
+       border-top-left-radius: 0;
+       border-bottom-left-radius: 0;
+       border-left-width: 0;
+}
+.oo-ui-numberInputWidget .oo-ui-textInputWidget input {
+       border-radius: 0;
+}
diff --git a/resources/lib/oojs-ui/oojs-ui-widgets.js b/resources/lib/oojs-ui/oojs-ui-widgets.js
new file mode 100644 (file)
index 0000000..5dbca20
--- /dev/null
@@ -0,0 +1,5131 @@
+/*!
+ * OOjs UI v0.15.2
+ * https://www.mediawiki.org/wiki/OOjs_UI
+ *
+ * Copyright 2011–2016 OOjs UI Team and other contributors.
+ * Released under the MIT license
+ * http://oojs.mit-license.org
+ *
+ * Date: 2016-02-02T22:07:00Z
+ */
+( function ( OO ) {
+
+'use strict';
+
+/**
+ * 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.mixin.DraggableGroupElement, which provides a container for
+ * the draggable elements.
+ *
+ * @abstract
+ * @class
+ *
+ * @constructor
+ */
+OO.ui.mixin.DraggableElement = function OoUiMixinDraggableElement() {
+       // Properties
+       this.index = null;
+
+       // Initialize and events
+       this.$element
+               .attr( 'draggable', true )
+               .addClass( 'oo-ui-draggableElement' )
+               .on( {
+                       dragstart: this.onDragStart.bind( this ),
+                       dragover: this.onDragOver.bind( this ),
+                       dragend: this.onDragEnd.bind( this ),
+                       drop: this.onDrop.bind( this )
+               } );
+};
+
+OO.initClass( OO.ui.mixin.DraggableElement );
+
+/* Events */
+
+/**
+ * @event dragstart
+ *
+ * A dragstart event is emitted when the user clicks and begins dragging an item.
+ * @param {OO.ui.mixin.DraggableElement} item The item the user has clicked and is dragging with the mouse.
+ */
+
+/**
+ * @event dragend
+ * A dragend event is emitted when the user drags an item and releases the mouse,
+ * thus terminating the drag operation.
+ */
+
+/**
+ * @event drop
+ * A drop event is emitted when the user drags an item and then releases the mouse button
+ * over a valid target.
+ */
+
+/* Static Properties */
+
+/**
+ * @inheritdoc OO.ui.mixin.ButtonElement
+ */
+OO.ui.mixin.DraggableElement.static.cancelButtonMouseDownEvents = false;
+
+/* Methods */
+
+/**
+ * Respond to dragstart event.
+ *
+ * @private
+ * @param {jQuery.Event} event jQuery event
+ * @fires dragstart
+ */
+OO.ui.mixin.DraggableElement.prototype.onDragStart = function ( e ) {
+       var dataTransfer = e.originalEvent.dataTransfer;
+       // Define drop effect
+       dataTransfer.dropEffect = 'none';
+       dataTransfer.effectAllowed = 'move';
+       // Support: Firefox
+       // We must set up a dataTransfer data property or Firefox seems to
+       // ignore the fact the element is draggable.
+       try {
+               dataTransfer.setData( 'application-x/OOjs-UI-draggable', this.getIndex() );
+       } catch ( err ) {
+               // The above is only for Firefox. Move on if it fails.
+       }
+       // Add dragging class
+       this.$element.addClass( 'oo-ui-draggableElement-dragging' );
+       // Emit event
+       this.emit( 'dragstart', this );
+       return true;
+};
+
+/**
+ * Respond to dragend event.
+ *
+ * @private
+ * @fires dragend
+ */
+OO.ui.mixin.DraggableElement.prototype.onDragEnd = function () {
+       this.$element.removeClass( 'oo-ui-draggableElement-dragging' );
+       this.emit( 'dragend' );
+};
+
+/**
+ * Handle drop event.
+ *
+ * @private
+ * @param {jQuery.Event} event jQuery event
+ * @fires drop
+ */
+OO.ui.mixin.DraggableElement.prototype.onDrop = function ( e ) {
+       e.preventDefault();
+       this.emit( 'drop', e );
+};
+
+/**
+ * In order for drag/drop to work, the dragover event must
+ * return false and stop propogation.
+ *
+ * @private
+ */
+OO.ui.mixin.DraggableElement.prototype.onDragOver = function ( e ) {
+       e.preventDefault();
+};
+
+/**
+ * Set item index.
+ * Store it in the DOM so we can access from the widget drag event
+ *
+ * @private
+ * @param {number} Item index
+ */
+OO.ui.mixin.DraggableElement.prototype.setIndex = function ( index ) {
+       if ( this.index !== index ) {
+               this.index = index;
+               this.$element.data( 'index', index );
+       }
+};
+
+/**
+ * Get item index
+ *
+ * @private
+ * @return {number} Item index
+ */
+OO.ui.mixin.DraggableElement.prototype.getIndex = function () {
+       return this.index;
+};
+
+/**
+ * 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.mixin.DraggableElement.
+ *
+ * @abstract
+ * @class
+ * @mixins OO.ui.mixin.GroupElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {string} [orientation] Item orientation: 'horizontal' or 'vertical'. The orientation
+ *  should match the layout of the items. Items displayed in a single row
+ *  or in several rows should use horizontal orientation. The vertical orientation should only be
+ *  used when the items are displayed in a single column. Defaults to 'vertical'
+ */
+OO.ui.mixin.DraggableGroupElement = function OoUiMixinDraggableGroupElement( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.mixin.GroupElement.call( this, config );
+
+       // Properties
+       this.orientation = config.orientation || 'vertical';
+       this.dragItem = null;
+       this.itemDragOver = null;
+       this.itemKeys = {};
+       this.sideInsertion = '';
+
+       // Events
+       this.aggregate( {
+               dragstart: 'itemDragStart',
+               dragend: 'itemDragEnd',
+               drop: 'itemDrop'
+       } );
+       this.connect( this, {
+               itemDragStart: 'onItemDragStart',
+               itemDrop: 'onItemDrop',
+               itemDragEnd: 'onItemDragEnd'
+       } );
+       this.$element.on( {
+               dragover: this.onDragOver.bind( this ),
+               dragleave: this.onDragLeave.bind( this )
+       } );
+
+       // Initialize
+       if ( Array.isArray( config.items ) ) {
+               this.addItems( config.items );
+       }
+       this.$placeholder = $( '<div>' )
+               .addClass( 'oo-ui-draggableGroupElement-placeholder' );
+       this.$element
+               .addClass( 'oo-ui-draggableGroupElement' )
+               .append( this.$status )
+               .toggleClass( 'oo-ui-draggableGroupElement-horizontal', this.orientation === 'horizontal' )
+               .prepend( this.$placeholder );
+};
+
+/* Setup */
+OO.mixinClass( OO.ui.mixin.DraggableGroupElement, OO.ui.mixin.GroupElement );
+
+/* Events */
+
+/**
+ * A 'reorder' event is emitted when the order of items in the group changes.
+ *
+ * @event reorder
+ * @param {OO.ui.mixin.DraggableElement} item Reordered item
+ * @param {number} [newIndex] New index for the item
+ */
+
+/* Methods */
+
+/**
+ * Respond to item drag start event
+ *
+ * @private
+ * @param {OO.ui.mixin.DraggableElement} item Dragged item
+ */
+OO.ui.mixin.DraggableGroupElement.prototype.onItemDragStart = function ( item ) {
+       var i, len;
+
+       // Map the index of each object
+       for ( i = 0, len = this.items.length; i < len; i++ ) {
+               this.items[ i ].setIndex( i );
+       }
+
+       if ( this.orientation === 'horizontal' ) {
+               // Set the height of the indicator
+               this.$placeholder.css( {
+                       height: item.$element.outerHeight(),
+                       width: 2
+               } );
+       } else {
+               // Set the width of the indicator
+               this.$placeholder.css( {
+                       height: 2,
+                       width: item.$element.outerWidth()
+               } );
+       }
+       this.setDragItem( item );
+};
+
+/**
+ * Respond to item drag end event
+ *
+ * @private
+ */
+OO.ui.mixin.DraggableGroupElement.prototype.onItemDragEnd = function () {
+       this.unsetDragItem();
+       return false;
+};
+
+/**
+ * Handle drop event and switch the order of the items accordingly
+ *
+ * @private
+ * @param {OO.ui.mixin.DraggableElement} item Dropped item
+ * @fires reorder
+ */
+OO.ui.mixin.DraggableGroupElement.prototype.onItemDrop = function ( item ) {
+       var toIndex = item.getIndex();
+       // Check if the dropped item is from the current group
+       // TODO: Figure out a way to configure a list of legally droppable
+       // elements even if they are not yet in the list
+       if ( this.getDragItem() ) {
+               // If the insertion point is 'after', the insertion index
+               // is shifted to the right (or to the left in RTL, hence 'after')
+               if ( this.sideInsertion === 'after' ) {
+                       toIndex++;
+               }
+               // Emit change event
+               this.emit( 'reorder', this.getDragItem(), toIndex );
+       }
+       this.unsetDragItem();
+       // Return false to prevent propogation
+       return false;
+};
+
+/**
+ * Handle dragleave event.
+ *
+ * @private
+ */
+OO.ui.mixin.DraggableGroupElement.prototype.onDragLeave = function () {
+       // This means the item was dragged outside the widget
+       this.$placeholder
+               .css( 'left', 0 )
+               .addClass( 'oo-ui-element-hidden' );
+};
+
+/**
+ * Respond to dragover event
+ *
+ * @private
+ * @param {jQuery.Event} event Event details
+ */
+OO.ui.mixin.DraggableGroupElement.prototype.onDragOver = function ( e ) {
+       var dragOverObj, $optionWidget, itemOffset, itemMidpoint, itemBoundingRect,
+               itemSize, cssOutput, dragPosition, itemIndex, itemPosition,
+               clientX = e.originalEvent.clientX,
+               clientY = e.originalEvent.clientY;
+
+       // Get the OptionWidget item we are dragging over
+       dragOverObj = this.getElementDocument().elementFromPoint( clientX, clientY );
+       $optionWidget = $( dragOverObj ).closest( '.oo-ui-draggableElement' );
+       if ( $optionWidget[ 0 ] ) {
+               itemOffset = $optionWidget.offset();
+               itemBoundingRect = $optionWidget[ 0 ].getBoundingClientRect();
+               itemPosition = $optionWidget.position();
+               itemIndex = $optionWidget.data( 'index' );
+       }
+
+       if (
+               itemOffset &&
+               this.isDragging() &&
+               itemIndex !== this.getDragItem().getIndex()
+       ) {
+               if ( this.orientation === 'horizontal' ) {
+                       // Calculate where the mouse is relative to the item width
+                       itemSize = itemBoundingRect.width;
+                       itemMidpoint = itemBoundingRect.left + itemSize / 2;
+                       dragPosition = clientX;
+                       // Which side of the item we hover over will dictate
+                       // where the placeholder will appear, on the left or
+                       // on the right
+                       cssOutput = {
+                               left: dragPosition < itemMidpoint ? itemPosition.left : itemPosition.left + itemSize,
+                               top: itemPosition.top
+                       };
+               } else {
+                       // Calculate where the mouse is relative to the item height
+                       itemSize = itemBoundingRect.height;
+                       itemMidpoint = itemBoundingRect.top + itemSize / 2;
+                       dragPosition = clientY;
+                       // Which side of the item we hover over will dictate
+                       // where the placeholder will appear, on the top or
+                       // on the bottom
+                       cssOutput = {
+                               top: dragPosition < itemMidpoint ? itemPosition.top : itemPosition.top + itemSize,
+                               left: itemPosition.left
+                       };
+               }
+               // Store whether we are before or after an item to rearrange
+               // For horizontal layout, we need to account for RTL, as this is flipped
+               if (  this.orientation === 'horizontal' && this.$element.css( 'direction' ) === 'rtl' ) {
+                       this.sideInsertion = dragPosition < itemMidpoint ? 'after' : 'before';
+               } else {
+                       this.sideInsertion = dragPosition < itemMidpoint ? 'before' : 'after';
+               }
+               // Add drop indicator between objects
+               this.$placeholder
+                       .css( cssOutput )
+                       .removeClass( 'oo-ui-element-hidden' );
+       } else {
+               // This means the item was dragged outside the widget
+               this.$placeholder
+                       .css( 'left', 0 )
+                       .addClass( 'oo-ui-element-hidden' );
+       }
+       // Prevent default
+       e.preventDefault();
+};
+
+/**
+ * Set a dragged item
+ *
+ * @param {OO.ui.mixin.DraggableElement} item Dragged item
+ */
+OO.ui.mixin.DraggableGroupElement.prototype.setDragItem = function ( item ) {
+       this.dragItem = item;
+};
+
+/**
+ * Unset the current dragged item
+ */
+OO.ui.mixin.DraggableGroupElement.prototype.unsetDragItem = function () {
+       this.dragItem = null;
+       this.itemDragOver = null;
+       this.$placeholder.addClass( 'oo-ui-element-hidden' );
+       this.sideInsertion = '';
+};
+
+/**
+ * Get the item that is currently being dragged.
+ *
+ * @return {OO.ui.mixin.DraggableElement|null} The currently dragged item, or `null` if no item is being dragged
+ */
+OO.ui.mixin.DraggableGroupElement.prototype.getDragItem = function () {
+       return this.dragItem;
+};
+
+/**
+ * Check if an item in the group is currently being dragged.
+ *
+ * @return {Boolean} Item is being dragged
+ */
+OO.ui.mixin.DraggableGroupElement.prototype.isDragging = function () {
+       return this.getDragItem() !== null;
+};
+
+/**
+ * RequestManager is a mixin that manages the lifecycle of a promise-backed request for a widget, such as
+ * the {@link OO.ui.mixin.LookupElement}.
+ *
+ * @class
+ * @abstract
+ *
+ * @constructor
+ */
+OO.ui.mixin.RequestManager = function OoUiMixinRequestManager() {
+       this.requestCache = {};
+       this.requestQuery = null;
+       this.requestRequest = null;
+};
+
+/* Setup */
+
+OO.initClass( OO.ui.mixin.RequestManager );
+
+/**
+ * Get request results for the current query.
+ *
+ * @return {jQuery.Promise} Promise object which will be passed response data as the first argument of
+ *   the done event. If the request was aborted to make way for a subsequent request, this promise
+ *   may not be rejected, depending on what jQuery feels like doing.
+ */
+OO.ui.mixin.RequestManager.prototype.getRequestData = function () {
+       var widget = this,
+               value = this.getRequestQuery(),
+               deferred = $.Deferred(),
+               ourRequest;
+
+       this.abortRequest();
+       if ( Object.prototype.hasOwnProperty.call( this.requestCache, value ) ) {
+               deferred.resolve( this.requestCache[ value ] );
+       } else {
+               if ( this.pushPending ) {
+                       this.pushPending();
+               }
+               this.requestQuery = value;
+               ourRequest = this.requestRequest = this.getRequest();
+               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.
+                               if ( widget.popPending ) {
+                                       widget.popPending();
+                               }
+                       } )
+                       .done( function ( response ) {
+                               // If this is an old request (and aborting it somehow caused it to still succeed),
+                               // ignore its success completely
+                               if ( ourRequest === widget.requestRequest ) {
+                                       widget.requestQuery = null;
+                                       widget.requestRequest = null;
+                                       widget.requestCache[ value ] = widget.getRequestCacheDataFromResponse( response );
+                                       deferred.resolve( widget.requestCache[ value ] );
+                               }
+                       } )
+                       .fail( function () {
+                               // If this is an old request (or a request failing because it's being aborted),
+                               // ignore its failure completely
+                               if ( ourRequest === widget.requestRequest ) {
+                                       widget.requestQuery = null;
+                                       widget.requestRequest = null;
+                                       deferred.reject();
+                               }
+                       } );
+       }
+       return deferred.promise();
+};
+
+/**
+ * Abort the currently pending request, if any.
+ *
+ * @private
+ */
+OO.ui.mixin.RequestManager.prototype.abortRequest = function () {
+       var oldRequest = this.requestRequest;
+       if ( oldRequest ) {
+               // First unset this.requestRequest to the fail handler will notice
+               // that the request is no longer current
+               this.requestRequest = null;
+               this.requestQuery = null;
+               oldRequest.abort();
+       }
+};
+
+/**
+ * Get the query to be made.
+ *
+ * @protected
+ * @method
+ * @abstract
+ * @return {string} query to be used
+ */
+OO.ui.mixin.RequestManager.prototype.getRequestQuery = null;
+
+/**
+ * Get a new request object of the current query value.
+ *
+ * @protected
+ * @method
+ * @abstract
+ * @return {jQuery.Promise} jQuery AJAX object, or promise object with an .abort() method
+ */
+OO.ui.mixin.RequestManager.prototype.getRequest = null;
+
+/**
+ * Pre-process data returned by the request from #getRequest.
+ *
+ * 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.
+ *
+ * @protected
+ * @method
+ * @abstract
+ * @param {Mixed} response Response from server
+ * @return {Mixed} Cached result data
+ */
+OO.ui.mixin.RequestManager.prototype.getRequestCacheDataFromResponse = null;
+
+/**
+ * LookupElement is a mixin that creates a {@link OO.ui.FloatingMenuSelectWidget menu} of suggested values for
+ * a {@link OO.ui.TextInputWidget text input widget}. Suggested values are based on the characters the user types
+ * into the text input field and, in general, the menu is only displayed when the user types. If a suggested value is chosen
+ * from the lookup menu, that value becomes the value of the input field.
+ *
+ * Note that a new menu of suggested items is displayed when a value is chosen from the lookup menu. If this is
+ * not the desired behavior, disable lookup menus with the #setLookupsDisabled method, then set the value, then
+ * re-enable lookups.
+ *
+ * See the [OOjs UI demos][1] for an example.
+ *
+ * [1]: https://tools.wmflabs.org/oojs-ui/oojs-ui/demos/index.html#widgets-apex-vector-ltr
+ *
+ * @class
+ * @abstract
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {jQuery} [$overlay] Overlay for the lookup menu; defaults to relative positioning
+ * @cfg {jQuery} [$container=this.$element] The container element. The lookup menu is rendered beneath the specified element.
+ * @cfg {boolean} [allowSuggestionsWhenEmpty=false] Request and display a lookup menu when the text input is empty.
+ *  By default, the lookup menu is not generated and displayed until the user begins to type.
+ * @cfg {boolean} [highlightFirst=true] Whether the first lookup result should be highlighted (so, that the user can
+ *  take it over into the input with simply pressing return) automatically or not.
+ */
+OO.ui.mixin.LookupElement = function OoUiMixinLookupElement( config ) {
+       // Configuration initialization
+       config = $.extend( { highlightFirst: true }, config );
+
+       // Mixin constructors
+       OO.ui.mixin.RequestManager.call( this, config );
+
+       // Properties
+       this.$overlay = config.$overlay || this.$element;
+       this.lookupMenu = new OO.ui.FloatingMenuSelectWidget( {
+               widget: this,
+               input: this,
+               $container: config.$container || this.$element
+       } );
+
+       this.allowSuggestionsWhenEmpty = config.allowSuggestionsWhenEmpty || false;
+
+       this.lookupsDisabled = false;
+       this.lookupInputFocused = false;
+       this.lookupHighlightFirstItem = config.highlightFirst;
+
+       // 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 );
+};
+
+/* Setup */
+
+OO.mixinClass( OO.ui.mixin.LookupElement, OO.ui.mixin.RequestManager );
+
+/* Methods */
+
+/**
+ * Handle input focus event.
+ *
+ * @protected
+ * @param {jQuery.Event} e Input focus event
+ */
+OO.ui.mixin.LookupElement.prototype.onLookupInputFocus = function () {
+       this.lookupInputFocused = true;
+       this.populateLookupMenu();
+};
+
+/**
+ * Handle input blur event.
+ *
+ * @protected
+ * @param {jQuery.Event} e Input blur event
+ */
+OO.ui.mixin.LookupElement.prototype.onLookupInputBlur = function () {
+       this.closeLookupMenu();
+       this.lookupInputFocused = false;
+};
+
+/**
+ * Handle input mouse down event.
+ *
+ * @protected
+ * @param {jQuery.Event} e Input mouse down event
+ */
+OO.ui.mixin.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();
+       }
+};
+
+/**
+ * Handle input change event.
+ *
+ * @protected
+ * @param {string} value New input value
+ */
+OO.ui.mixin.LookupElement.prototype.onLookupInputChange = function () {
+       if ( this.lookupInputFocused ) {
+               this.populateLookupMenu();
+       }
+};
+
+/**
+ * Handle the lookup menu being shown/hidden.
+ *
+ * @protected
+ * @param {boolean} visible Whether the lookup menu is now visible.
+ */
+OO.ui.mixin.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();
+       }
+};
+
+/**
+ * Handle menu item 'choose' event, updating the text input value to the value of the clicked item.
+ *
+ * @protected
+ * @param {OO.ui.MenuOptionWidget} item Selected item
+ */
+OO.ui.mixin.LookupElement.prototype.onLookupMenuItemChoose = function ( item ) {
+       this.setValue( item.getData() );
+};
+
+/**
+ * Get lookup menu.
+ *
+ * @private
+ * @return {OO.ui.FloatingMenuSelectWidget}
+ */
+OO.ui.mixin.LookupElement.prototype.getLookupMenu = function () {
+       return this.lookupMenu;
+};
+
+/**
+ * Disable or re-enable lookups.
+ *
+ * When lookups are disabled, calls to #populateLookupMenu will be ignored.
+ *
+ * @param {boolean} disabled Disable lookups
+ */
+OO.ui.mixin.LookupElement.prototype.setLookupsDisabled = function ( disabled ) {
+       this.lookupsDisabled = !!disabled;
+};
+
+/**
+ * Open the menu. If there are no entries in the menu, this does nothing.
+ *
+ * @private
+ * @chainable
+ */
+OO.ui.mixin.LookupElement.prototype.openLookupMenu = function () {
+       if ( !this.lookupMenu.isEmpty() ) {
+               this.lookupMenu.toggle( true );
+       }
+       return this;
+};
+
+/**
+ * Close the menu, empty it, and abort any pending request.
+ *
+ * @private
+ * @chainable
+ */
+OO.ui.mixin.LookupElement.prototype.closeLookupMenu = function () {
+       this.lookupMenu.toggle( false );
+       this.abortLookupRequest();
+       this.lookupMenu.clearItems();
+       return this;
+};
+
+/**
+ * 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.
+ *
+ * @private
+ * @chainable
+ */
+OO.ui.mixin.LookupElement.prototype.populateLookupMenu = function () {
+       var widget = this,
+               value = this.getValue();
+
+       if ( this.lookupsDisabled || this.isReadOnly() ) {
+               return;
+       }
+
+       // If the input is empty, clear the menu, unless suggestions when empty are allowed.
+       if ( !this.allowSuggestionsWhenEmpty && 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;
+};
+
+/**
+ * Highlight the first selectable item in the menu, if configured.
+ *
+ * @private
+ * @chainable
+ */
+OO.ui.mixin.LookupElement.prototype.initializeLookupMenuSelection = function () {
+       if ( this.lookupHighlightFirstItem && !this.lookupMenu.getSelectedItem() ) {
+               this.lookupMenu.highlightItem( this.lookupMenu.getFirstSelectableItem() );
+       }
+};
+
+/**
+ * Get lookup menu items for the current query.
+ *
+ * @private
+ * @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.mixin.LookupElement.prototype.getLookupMenuItems = function () {
+       return this.getRequestData().then( function ( data ) {
+               return this.getLookupMenuOptionsFromData( data );
+       }.bind( this ) );
+};
+
+/**
+ * Abort the currently pending lookup request, if any.
+ *
+ * @private
+ */
+OO.ui.mixin.LookupElement.prototype.abortLookupRequest = function () {
+       this.abortRequest();
+};
+
+/**
+ * Get a new request object of the current lookup query value.
+ *
+ * @protected
+ * @method
+ * @abstract
+ * @return {jQuery.Promise} jQuery AJAX object, or promise object with an .abort() method
+ */
+OO.ui.mixin.LookupElement.prototype.getLookupRequest = null;
+
+/**
+ * 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.
+ *
+ * @protected
+ * @method
+ * @abstract
+ * @param {Mixed} response Response from server
+ * @return {Mixed} Cached result data
+ */
+OO.ui.mixin.LookupElement.prototype.getLookupCacheDataFromResponse = null;
+
+/**
+ * Get a list of menu option widgets from the (possibly cached) data returned by
+ * #getLookupCacheDataFromResponse.
+ *
+ * @protected
+ * @method
+ * @abstract
+ * @param {Mixed} data Cached result data, usually an array
+ * @return {OO.ui.MenuOptionWidget[]} Menu items
+ */
+OO.ui.mixin.LookupElement.prototype.getLookupMenuOptionsFromData = null;
+
+/**
+ * Set the read-only state of the widget.
+ *
+ * This will also disable/enable the lookups functionality.
+ *
+ * @param {boolean} readOnly Make input read-only
+ * @chainable
+ */
+OO.ui.mixin.LookupElement.prototype.setReadOnly = function ( readOnly ) {
+       // Parent method
+       // Note: Calling #setReadOnly this way assumes this is mixed into an OO.ui.TextInputWidget
+       OO.ui.TextInputWidget.prototype.setReadOnly.call( this, readOnly );
+
+       // During construction, #setReadOnly is called before the OO.ui.mixin.LookupElement constructor
+       if ( this.isReadOnly() && this.lookupMenu ) {
+               this.closeLookupMenu();
+       }
+
+       return this;
+};
+
+/**
+ * @inheritdoc OO.ui.mixin.RequestManager
+ */
+OO.ui.mixin.LookupElement.prototype.getRequestQuery = function () {
+       return this.getValue();
+};
+
+/**
+ * @inheritdoc OO.ui.mixin.RequestManager
+ */
+OO.ui.mixin.LookupElement.prototype.getRequest = function () {
+       return this.getLookupRequest();
+};
+
+/**
+ * @inheritdoc OO.ui.mixin.RequestManager
+ */
+OO.ui.mixin.LookupElement.prototype.getRequestCacheDataFromResponse = function ( response ) {
+       return this.getLookupCacheDataFromResponse( response );
+};
+
+/**
+ * CardLayouts are used within {@link OO.ui.IndexLayout index layouts} to create cards that users can select and display
+ * from the index's optional {@link OO.ui.TabSelectWidget tab} navigation. Cards are usually not instantiated directly,
+ * rather extended to include the required content and functionality.
+ *
+ * Each card must have a unique symbolic name, which is passed to the constructor. In addition, the card's tab
+ * item is customized (with a label) using the #setupTabItem method. See
+ * {@link OO.ui.IndexLayout IndexLayout} for an example.
+ *
+ * @class
+ * @extends OO.ui.PanelLayout
+ *
+ * @constructor
+ * @param {string} name Unique symbolic name of card
+ * @param {Object} [config] Configuration options
+ * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] Label for card's tab
+ */
+OO.ui.CardLayout = function OoUiCardLayout( name, config ) {
+       // Allow passing positional parameters inside the config object
+       if ( OO.isPlainObject( name ) && config === undefined ) {
+               config = name;
+               name = config.name;
+       }
+
+       // Configuration initialization
+       config = $.extend( { scrollable: true }, config );
+
+       // Parent constructor
+       OO.ui.CardLayout.parent.call( this, config );
+
+       // Properties
+       this.name = name;
+       this.label = config.label;
+       this.tabItem = null;
+       this.active = false;
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-cardLayout' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.CardLayout, OO.ui.PanelLayout );
+
+/* Events */
+
+/**
+ * An 'active' event is emitted when the card becomes active. Cards become active when they are
+ * shown in a index layout that is configured to display only one card at a time.
+ *
+ * @event active
+ * @param {boolean} active Card is active
+ */
+
+/* Methods */
+
+/**
+ * Get the symbolic name of the card.
+ *
+ * @return {string} Symbolic name of card
+ */
+OO.ui.CardLayout.prototype.getName = function () {
+       return this.name;
+};
+
+/**
+ * Check if card is active.
+ *
+ * Cards become active when they are shown in a {@link OO.ui.IndexLayout index layout} that is configured to display
+ * only one card at a time. Additional CSS is applied to the card's tab item to reflect the active state.
+ *
+ * @return {boolean} Card is active
+ */
+OO.ui.CardLayout.prototype.isActive = function () {
+       return this.active;
+};
+
+/**
+ * Get tab item.
+ *
+ * The tab item allows users to access the card from the index's tab
+ * navigation. The tab item itself can be customized (with a label, level, etc.) using the #setupTabItem method.
+ *
+ * @return {OO.ui.TabOptionWidget|null} Tab option widget
+ */
+OO.ui.CardLayout.prototype.getTabItem = function () {
+       return this.tabItem;
+};
+
+/**
+ * Set or unset the tab item.
+ *
+ * Specify a {@link OO.ui.TabOptionWidget tab option} to set it,
+ * or `null` to clear the tab item. To customize the tab item itself (e.g., to set a label or tab
+ * level), use #setupTabItem instead of this method.
+ *
+ * @param {OO.ui.TabOptionWidget|null} tabItem Tab option widget, null to clear
+ * @chainable
+ */
+OO.ui.CardLayout.prototype.setTabItem = function ( tabItem ) {
+       this.tabItem = tabItem || null;
+       if ( tabItem ) {
+               this.setupTabItem();
+       }
+       return this;
+};
+
+/**
+ * Set up the tab item.
+ *
+ * Use this method to customize the tab item (e.g., to add a label or tab level). To set or unset
+ * the tab item itself (with a {@link OO.ui.TabOptionWidget tab option} or `null`), use
+ * the #setTabItem method instead.
+ *
+ * @param {OO.ui.TabOptionWidget} tabItem Tab option widget to set up
+ * @chainable
+ */
+OO.ui.CardLayout.prototype.setupTabItem = function () {
+       if ( this.label ) {
+               this.tabItem.setLabel( this.label );
+       }
+       return this;
+};
+
+/**
+ * Set the card to its 'active' state.
+ *
+ * Cards become active when they are shown in a index layout that is configured to display only one card at a time. Additional
+ * CSS is applied to the tab item to reflect the card's active state. Outside of the index
+ * context, setting the active state on a card does nothing.
+ *
+ * @param {boolean} value Card is active
+ * @fires active
+ */
+OO.ui.CardLayout.prototype.setActive = function ( active ) {
+       active = !!active;
+
+       if ( active !== this.active ) {
+               this.active = active;
+               this.$element.toggleClass( 'oo-ui-cardLayout-active', this.active );
+               this.emit( 'active', this.active );
+       }
+};
+
+/**
+ * PageLayouts are used within {@link OO.ui.BookletLayout booklet layouts} to create pages that users can select and display
+ * from the booklet's optional {@link OO.ui.OutlineSelectWidget outline} navigation. Pages are usually not instantiated directly,
+ * rather extended to include the required content and functionality.
+ *
+ * Each page must have a unique symbolic name, which is passed to the constructor. In addition, the page's outline
+ * item is customized (with a label, outline level, etc.) using the #setupOutlineItem method. See
+ * {@link OO.ui.BookletLayout BookletLayout} for an example.
+ *
+ * @class
+ * @extends OO.ui.PanelLayout
+ *
+ * @constructor
+ * @param {string} name Unique symbolic name of page
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.PageLayout = function OoUiPageLayout( name, config ) {
+       // Allow passing positional parameters inside the config object
+       if ( OO.isPlainObject( name ) && config === undefined ) {
+               config = name;
+               name = config.name;
+       }
+
+       // Configuration initialization
+       config = $.extend( { scrollable: true }, config );
+
+       // Parent constructor
+       OO.ui.PageLayout.parent.call( this, config );
+
+       // Properties
+       this.name = name;
+       this.outlineItem = null;
+       this.active = false;
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-pageLayout' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.PageLayout, OO.ui.PanelLayout );
+
+/* Events */
+
+/**
+ * An 'active' event is emitted when the page becomes active. Pages become active when they are
+ * shown in a booklet layout that is configured to display only one page at a time.
+ *
+ * @event active
+ * @param {boolean} active Page is active
+ */
+
+/* Methods */
+
+/**
+ * Get the symbolic name of the page.
+ *
+ * @return {string} Symbolic name of page
+ */
+OO.ui.PageLayout.prototype.getName = function () {
+       return this.name;
+};
+
+/**
+ * Check if page is active.
+ *
+ * Pages become active when they are shown in a {@link OO.ui.BookletLayout booklet layout} that is configured to display
+ * only one page at a time. Additional CSS is applied to the page's outline item to reflect the active state.
+ *
+ * @return {boolean} Page is active
+ */
+OO.ui.PageLayout.prototype.isActive = function () {
+       return this.active;
+};
+
+/**
+ * Get outline item.
+ *
+ * The outline item allows users to access the page from the booklet's outline
+ * navigation. The outline item itself can be customized (with a label, level, etc.) using the #setupOutlineItem method.
+ *
+ * @return {OO.ui.OutlineOptionWidget|null} Outline option widget
+ */
+OO.ui.PageLayout.prototype.getOutlineItem = function () {
+       return this.outlineItem;
+};
+
+/**
+ * Set or unset the outline item.
+ *
+ * Specify an {@link OO.ui.OutlineOptionWidget outline option} to set it,
+ * or `null` to clear the outline item. To customize the outline item itself (e.g., to set a label or outline
+ * level), use #setupOutlineItem instead of this method.
+ *
+ * @param {OO.ui.OutlineOptionWidget|null} outlineItem Outline option widget, null to clear
+ * @chainable
+ */
+OO.ui.PageLayout.prototype.setOutlineItem = function ( outlineItem ) {
+       this.outlineItem = outlineItem || null;
+       if ( outlineItem ) {
+               this.setupOutlineItem();
+       }
+       return this;
+};
+
+/**
+ * Set up the outline item.
+ *
+ * Use this method to customize the outline item (e.g., to add a label or outline level). To set or unset
+ * the outline item itself (with an {@link OO.ui.OutlineOptionWidget outline option} or `null`), use
+ * the #setOutlineItem method instead.
+ *
+ * @param {OO.ui.OutlineOptionWidget} outlineItem Outline option widget to set up
+ * @chainable
+ */
+OO.ui.PageLayout.prototype.setupOutlineItem = function () {
+       return this;
+};
+
+/**
+ * Set the page to its 'active' state.
+ *
+ * Pages become active when they are shown in a booklet layout that is configured to display only one page at a time. Additional
+ * CSS is applied to the outline item to reflect the page's active state. Outside of the booklet
+ * context, setting the active state on a page does nothing.
+ *
+ * @param {boolean} value Page is active
+ * @fires active
+ */
+OO.ui.PageLayout.prototype.setActive = function ( active ) {
+       active = !!active;
+
+       if ( active !== this.active ) {
+               this.active = active;
+               this.$element.toggleClass( 'oo-ui-pageLayout-active', active );
+               this.emit( 'active', this.active );
+       }
+};
+
+/**
+ * StackLayouts contain a series of {@link OO.ui.PanelLayout panel layouts}. By default, only one panel is displayed
+ * at a time, though the stack layout can also be configured to show all contained panels, one after another,
+ * by setting the #continuous option to 'true'.
+ *
+ *     @example
+ *     // A stack layout with two panels, configured to be displayed continously
+ *     var myStack = new OO.ui.StackLayout( {
+ *         items: [
+ *             new OO.ui.PanelLayout( {
+ *                 $content: $( '<p>Panel One</p>' ),
+ *                 padded: true,
+ *                 framed: true
+ *             } ),
+ *             new OO.ui.PanelLayout( {
+ *                 $content: $( '<p>Panel Two</p>' ),
+ *                 padded: true,
+ *                 framed: true
+ *             } )
+ *         ],
+ *         continuous: true
+ *     } );
+ *     $( 'body' ).append( myStack.$element );
+ *
+ * @class
+ * @extends OO.ui.PanelLayout
+ * @mixins OO.ui.mixin.GroupElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {boolean} [continuous=false] Show all panels, one after another. By default, only one panel is displayed at a time.
+ * @cfg {OO.ui.Layout[]} [items] Panel layouts to add to the stack layout.
+ */
+OO.ui.StackLayout = function OoUiStackLayout( config ) {
+       // Configuration initialization
+       config = $.extend( { scrollable: true }, config );
+
+       // Parent constructor
+       OO.ui.StackLayout.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
+
+       // Properties
+       this.currentItem = null;
+       this.continuous = !!config.continuous;
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-stackLayout' );
+       if ( this.continuous ) {
+               this.$element.addClass( 'oo-ui-stackLayout-continuous' );
+               this.$element.on( 'scroll', OO.ui.debounce( this.onScroll.bind( this ), 250 ) );
+       }
+       if ( Array.isArray( config.items ) ) {
+               this.addItems( config.items );
+       }
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.StackLayout, OO.ui.PanelLayout );
+OO.mixinClass( OO.ui.StackLayout, OO.ui.mixin.GroupElement );
+
+/* Events */
+
+/**
+ * A 'set' event is emitted when panels are {@link #addItems added}, {@link #removeItems removed},
+ * {@link #clearItems cleared} or {@link #setItem displayed}.
+ *
+ * @event set
+ * @param {OO.ui.Layout|null} item Current panel or `null` if no panel is shown
+ */
+
+/**
+ * When used in continuous mode, this event is emitted when the user scrolls down
+ * far enough such that currentItem is no longer visible.
+ *
+ * @event visibleItemChange
+ * @param {OO.ui.PanelLayout} panel The next visible item in the layout
+ */
+
+/* Methods */
+
+/**
+ * Handle scroll events from the layout element
+ *
+ * @param {jQuery.Event} e
+ * @fires visibleItemChange
+ */
+OO.ui.StackLayout.prototype.onScroll = function () {
+       var currentRect,
+               len = this.items.length,
+               currentIndex = this.items.indexOf( this.currentItem ),
+               newIndex = currentIndex,
+               containerRect = this.$element[ 0 ].getBoundingClientRect();
+
+       if ( !containerRect || ( !containerRect.top && !containerRect.bottom ) ) {
+               // Can't get bounding rect, possibly not attached.
+               return;
+       }
+
+       function getRect( item ) {
+               return item.$element[ 0 ].getBoundingClientRect();
+       }
+
+       function isVisible( item ) {
+               var rect = getRect( item );
+               return rect.bottom > containerRect.top && rect.top < containerRect.bottom;
+       }
+
+       currentRect = getRect( this.currentItem );
+
+       if ( currentRect.bottom < containerRect.top ) {
+               // Scrolled down past current item
+               while ( ++newIndex < len ) {
+                       if ( isVisible( this.items[ newIndex ] ) ) {
+                               break;
+                       }
+               }
+       } else if ( currentRect.top > containerRect.bottom ) {
+               // Scrolled up past current item
+               while ( --newIndex >= 0 ) {
+                       if ( isVisible( this.items[ newIndex ] ) ) {
+                               break;
+                       }
+               }
+       }
+
+       if ( newIndex !== currentIndex ) {
+               this.emit( 'visibleItemChange', this.items[ newIndex ] );
+       }
+};
+
+/**
+ * Get the current panel.
+ *
+ * @return {OO.ui.Layout|null}
+ */
+OO.ui.StackLayout.prototype.getCurrentItem = function () {
+       return this.currentItem;
+};
+
+/**
+ * Unset the current item.
+ *
+ * @private
+ * @param {OO.ui.StackLayout} layout
+ * @fires set
+ */
+OO.ui.StackLayout.prototype.unsetCurrentItem = function () {
+       var prevItem = this.currentItem;
+       if ( prevItem === null ) {
+               return;
+       }
+
+       this.currentItem = null;
+       this.emit( 'set', null );
+};
+
+/**
+ * Add panel layouts to the stack layout.
+ *
+ * Panels will be added to the end of the stack layout array unless the optional index parameter specifies a different
+ * insertion point. Adding a panel that is already in the stack will move it to the end of the array or the point specified
+ * by the index.
+ *
+ * @param {OO.ui.Layout[]} items Panels to add
+ * @param {number} [index] Index of the insertion point
+ * @chainable
+ */
+OO.ui.StackLayout.prototype.addItems = function ( items, index ) {
+       // Update the visibility
+       this.updateHiddenState( items, this.currentItem );
+
+       // Mixin method
+       OO.ui.mixin.GroupElement.prototype.addItems.call( this, items, index );
+
+       if ( !this.currentItem && items.length ) {
+               this.setItem( items[ 0 ] );
+       }
+
+       return this;
+};
+
+/**
+ * Remove the specified panels from the stack layout.
+ *
+ * Removed panels are detached from the DOM, not removed, so that they may be reused. To remove all panels,
+ * you may wish to use the #clearItems method instead.
+ *
+ * @param {OO.ui.Layout[]} items Panels to remove
+ * @chainable
+ * @fires set
+ */
+OO.ui.StackLayout.prototype.removeItems = function ( items ) {
+       // Mixin method
+       OO.ui.mixin.GroupElement.prototype.removeItems.call( this, items );
+
+       if ( items.indexOf( this.currentItem ) !== -1 ) {
+               if ( this.items.length ) {
+                       this.setItem( this.items[ 0 ] );
+               } else {
+                       this.unsetCurrentItem();
+               }
+       }
+
+       return this;
+};
+
+/**
+ * Clear all panels from the stack layout.
+ *
+ * Cleared panels are detached from the DOM, not removed, so that they may be reused. To remove only
+ * a subset of panels, use the #removeItems method.
+ *
+ * @chainable
+ * @fires set
+ */
+OO.ui.StackLayout.prototype.clearItems = function () {
+       this.unsetCurrentItem();
+       OO.ui.mixin.GroupElement.prototype.clearItems.call( this );
+
+       return this;
+};
+
+/**
+ * Show the specified panel.
+ *
+ * If another panel is currently displayed, it will be hidden.
+ *
+ * @param {OO.ui.Layout} item Panel to show
+ * @chainable
+ * @fires set
+ */
+OO.ui.StackLayout.prototype.setItem = function ( item ) {
+       if ( item !== this.currentItem ) {
+               this.updateHiddenState( this.items, item );
+
+               if ( this.items.indexOf( item ) !== -1 ) {
+                       this.currentItem = item;
+                       this.emit( 'set', item );
+               } else {
+                       this.unsetCurrentItem();
+               }
+       }
+
+       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.
+ *
+ * @private
+ * @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' );
+               }
+       }
+};
+
+/**
+ * MenuLayouts combine a menu and a content {@link OO.ui.PanelLayout panel}. The menu is positioned relative to the content (after, before, top, or bottom)
+ * and its size is customized with the #menuSize config. The content area will fill all remaining space.
+ *
+ *     @example
+ *     var menuLayout = new OO.ui.MenuLayout( {
+ *         position: 'top'
+ *     } ),
+ *         menuPanel = new OO.ui.PanelLayout( { padded: true, expanded: true, scrollable: true } ),
+ *         contentPanel = new OO.ui.PanelLayout( { padded: true, expanded: true, scrollable: true } ),
+ *         select = new OO.ui.SelectWidget( {
+ *             items: [
+ *                 new OO.ui.OptionWidget( {
+ *                     data: 'before',
+ *                     label: 'Before',
+ *                 } ),
+ *                 new OO.ui.OptionWidget( {
+ *                     data: 'after',
+ *                     label: 'After',
+ *                 } ),
+ *                 new OO.ui.OptionWidget( {
+ *                     data: 'top',
+ *                     label: 'Top',
+ *                 } ),
+ *                 new OO.ui.OptionWidget( {
+ *                     data: 'bottom',
+ *                     label: 'Bottom',
+ *                 } )
+ *              ]
+ *         } ).on( 'select', function ( item ) {
+ *            menuLayout.setMenuPosition( item.getData() );
+ *         } );
+ *
+ *     menuLayout.$menu.append(
+ *         menuPanel.$element.append( '<b>Menu panel</b>', select.$element )
+ *     );
+ *     menuLayout.$content.append(
+ *         contentPanel.$element.append( '<b>Content panel</b>', '<p>Note that the menu is positioned relative to the content panel: top, bottom, after, before.</p>')
+ *     );
+ *     $( 'body' ).append( menuLayout.$element );
+ *
+ * If menu size needs to be overridden, it can be accomplished using CSS similar to the snippet
+ * below. MenuLayout's CSS will override the appropriate values with 'auto' or '0' to display the
+ * menu correctly. If `menuPosition` is known beforehand, CSS rules corresponding to other positions
+ * may be omitted.
+ *
+ *     .oo-ui-menuLayout-menu {
+ *         height: 200px;
+ *         width: 200px;
+ *     }
+ *     .oo-ui-menuLayout-content {
+ *         top: 200px;
+ *         left: 200px;
+ *         right: 200px;
+ *         bottom: 200px;
+ *     }
+ *
+ * @class
+ * @extends OO.ui.Layout
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {boolean} [showMenu=true] Show menu
+ * @cfg {string} [menuPosition='before'] Position of menu: `top`, `after`, `bottom` or `before`
+ */
+OO.ui.MenuLayout = function OoUiMenuLayout( config ) {
+       // Configuration initialization
+       config = $.extend( {
+               showMenu: true,
+               menuPosition: 'before'
+       }, config );
+
+       // Parent constructor
+       OO.ui.MenuLayout.parent.call( this, config );
+
+       /**
+        * Menu DOM node
+        *
+        * @property {jQuery}
+        */
+       this.$menu = $( '<div>' );
+       /**
+        * Content DOM node
+        *
+        * @property {jQuery}
+        */
+       this.$content = $( '<div>' );
+
+       // Initialization
+       this.$menu
+               .addClass( 'oo-ui-menuLayout-menu' );
+       this.$content.addClass( 'oo-ui-menuLayout-content' );
+       this.$element
+               .addClass( 'oo-ui-menuLayout' )
+               .append( this.$content, this.$menu );
+       this.setMenuPosition( config.menuPosition );
+       this.toggleMenu( config.showMenu );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.MenuLayout, OO.ui.Layout );
+
+/* Methods */
+
+/**
+ * Toggle menu.
+ *
+ * @param {boolean} showMenu Show menu, omit to toggle
+ * @chainable
+ */
+OO.ui.MenuLayout.prototype.toggleMenu = function ( showMenu ) {
+       showMenu = showMenu === undefined ? !this.showMenu : !!showMenu;
+
+       if ( this.showMenu !== showMenu ) {
+               this.showMenu = showMenu;
+               this.$element
+                       .toggleClass( 'oo-ui-menuLayout-showMenu', this.showMenu )
+                       .toggleClass( 'oo-ui-menuLayout-hideMenu', !this.showMenu );
+       }
+
+       return this;
+};
+
+/**
+ * Check if menu is visible
+ *
+ * @return {boolean} Menu is visible
+ */
+OO.ui.MenuLayout.prototype.isMenuVisible = function () {
+       return this.showMenu;
+};
+
+/**
+ * Set menu position.
+ *
+ * @param {string} position Position of menu, either `top`, `after`, `bottom` or `before`
+ * @throws {Error} If position value is not supported
+ * @chainable
+ */
+OO.ui.MenuLayout.prototype.setMenuPosition = function ( position ) {
+       this.$element.removeClass( 'oo-ui-menuLayout-' + this.menuPosition );
+       this.menuPosition = position;
+       this.$element.addClass( 'oo-ui-menuLayout-' + position );
+
+       return this;
+};
+
+/**
+ * Get menu position.
+ *
+ * @return {string} Menu position
+ */
+OO.ui.MenuLayout.prototype.getMenuPosition = function () {
+       return this.menuPosition;
+};
+
+/**
+ * BookletLayouts contain {@link OO.ui.PageLayout page layouts} as well as
+ * an {@link OO.ui.OutlineSelectWidget outline} that allows users to easily navigate
+ * through the pages and select which one to display. By default, only one page is
+ * displayed at a time and the outline is hidden. When a user navigates to a new page,
+ * the booklet layout automatically focuses on the first focusable element, unless the
+ * default setting is changed. Optionally, booklets can be configured to show
+ * {@link OO.ui.OutlineControlsWidget controls} for adding, moving, and removing items.
+ *
+ *     @example
+ *     // Example of a BookletLayout that contains two PageLayouts.
+ *
+ *     function PageOneLayout( name, config ) {
+ *         PageOneLayout.parent.call( this, name, config );
+ *         this.$element.append( '<p>First page</p><p>(This booklet has an outline, displayed on the left)</p>' );
+ *     }
+ *     OO.inheritClass( PageOneLayout, OO.ui.PageLayout );
+ *     PageOneLayout.prototype.setupOutlineItem = function () {
+ *         this.outlineItem.setLabel( 'Page One' );
+ *     };
+ *
+ *     function PageTwoLayout( name, config ) {
+ *         PageTwoLayout.parent.call( this, name, config );
+ *         this.$element.append( '<p>Second page</p>' );
+ *     }
+ *     OO.inheritClass( PageTwoLayout, OO.ui.PageLayout );
+ *     PageTwoLayout.prototype.setupOutlineItem = function () {
+ *         this.outlineItem.setLabel( 'Page Two' );
+ *     };
+ *
+ *     var page1 = new PageOneLayout( 'one' ),
+ *         page2 = new PageTwoLayout( 'two' );
+ *
+ *     var booklet = new OO.ui.BookletLayout( {
+ *         outlined: true
+ *     } );
+ *
+ *     booklet.addPages ( [ page1, page2 ] );
+ *     $( 'body' ).append( booklet.$element );
+ *
+ * @class
+ * @extends OO.ui.MenuLayout
+ *
+ * @constructor
+ * @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 a new page is displayed.
+ * @cfg {boolean} [outlined=false] Show the outline. The outline is used to navigate through the pages of the booklet.
+ * @cfg {boolean} [editable=false] Show controls for adding, removing and reordering pages
+ */
+OO.ui.BookletLayout = function OoUiBookletLayout( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.BookletLayout.parent.call( this, config );
+
+       // Properties
+       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
+       this.stackLayout.connect( this, { set: 'onStackLayoutSet' } );
+       if ( this.outlined ) {
+               this.outlineSelectWidget.connect( this, { select: 'onOutlineSelectWidgetSelect' } );
+               this.scrolling = false;
+               this.stackLayout.connect( this, { visibleItemChange: 'onStackLayoutVisibleItemChange' } );
+       }
+       if ( this.autoFocus ) {
+               // Event 'focus' does not bubble, but 'focusin' does
+               this.stackLayout.$element.on( 'focusin', this.onStackLayoutFocus.bind( this ) );
+       }
+
+       // 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 );
+               }
+       }
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.BookletLayout, OO.ui.MenuLayout );
+
+/* Events */
+
+/**
+ * A 'set' event is emitted when a page is {@link #setPage set} to be displayed by the booklet layout.
+ * @event set
+ * @param {OO.ui.PageLayout} page Current page
+ */
+
+/**
+ * An 'add' event is emitted when pages are {@link #addPages added} to the booklet layout.
+ *
+ * @event add
+ * @param {OO.ui.PageLayout[]} page Added pages
+ * @param {number} index Index pages were added at
+ */
+
+/**
+ * A 'remove' event is emitted when pages are {@link #clearPages cleared} or
+ * {@link #removePages removed} from the booklet.
+ *
+ * @event remove
+ * @param {OO.ui.PageLayout[]} pages Removed pages
+ */
+
+/* Methods */
+
+/**
+ * Handle stack layout focus.
+ *
+ * @private
+ * @param {jQuery.Event} e Focusin event
+ */
+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 visibleItemChange events from the stackLayout
+ *
+ * The next visible page is set as the current page by selecting it
+ * in the outline
+ *
+ * @param {OO.ui.PageLayout} page The next visible page in the layout
+ */
+OO.ui.BookletLayout.prototype.onStackLayoutVisibleItemChange = function ( page ) {
+       // Set a flag to so that the resulting call to #onStackLayoutSet doesn't
+       // try and scroll the item into view again.
+       this.scrolling = true;
+       this.outlineSelectWidget.selectItemByData( page.getName() );
+       this.scrolling = false;
+};
+
+/**
+ * Handle stack layout set events.
+ *
+ * @private
+ * @param {OO.ui.PanelLayout|null} page The page panel that is now the current panel
+ */
+OO.ui.BookletLayout.prototype.onStackLayoutSet = function ( page ) {
+       var layout = this;
+       if ( !this.scrolling && page ) {
+               page.scrollElementIntoView( { complete: function () {
+                       if ( layout.autoFocus ) {
+                               layout.focus();
+                       }
+               } } );
+       }
+};
+
+/**
+ * Focus the first input in the current page.
+ *
+ * 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.
+ * @param {number} [itemIndex] A specific item to focus on
+ */
+OO.ui.BookletLayout.prototype.focus = function ( itemIndex ) {
+       var page,
+               items = this.stackLayout.getItems();
+
+       if ( itemIndex !== undefined && items[ itemIndex ] ) {
+               page = items[ itemIndex ];
+       } else {
+               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 ( !OO.ui.contains( page.$element[ 0 ], this.getElementDocument().activeElement, true ) ) {
+               page.focus();
+       }
+};
+
+/**
+ * Find the first focusable input in the booklet layout and focus
+ * on it.
+ */
+OO.ui.BookletLayout.prototype.focusFirstFocusable = function () {
+       OO.ui.findFocusable( this.stackLayout.$element ).focus();
+};
+
+/**
+ * Handle outline widget select events.
+ *
+ * @private
+ * @param {OO.ui.OptionWidget|null} item Selected item
+ */
+OO.ui.BookletLayout.prototype.onOutlineSelectWidgetSelect = function ( item ) {
+       if ( item ) {
+               this.setPage( item.getData() );
+       }
+};
+
+/**
+ * Check if booklet has an outline.
+ *
+ * @return {boolean} Booklet has an outline
+ */
+OO.ui.BookletLayout.prototype.isOutlined = function () {
+       return this.outlined;
+};
+
+/**
+ * Check if booklet has editing controls.
+ *
+ * @return {boolean} Booklet is editable
+ */
+OO.ui.BookletLayout.prototype.isEditable = function () {
+       return this.editable;
+};
+
+/**
+ * Check if booklet has a visible outline.
+ *
+ * @return {boolean} Outline is visible
+ */
+OO.ui.BookletLayout.prototype.isOutlineVisible = function () {
+       return this.outlined && this.outlineVisible;
+};
+
+/**
+ * Hide or show the outline.
+ *
+ * @param {boolean} [show] Show outline, omit to invert current state
+ * @chainable
+ */
+OO.ui.BookletLayout.prototype.toggleOutline = function ( show ) {
+       if ( this.outlined ) {
+               show = show === undefined ? !this.outlineVisible : !!show;
+               this.outlineVisible = show;
+               this.toggleMenu( show );
+       }
+
+       return this;
+};
+
+/**
+ * Get the page closest to the specified page.
+ *
+ * @param {OO.ui.PageLayout} page Page to use as a reference point
+ * @return {OO.ui.PageLayout|null} Page closest to the specified page
+ */
+OO.ui.BookletLayout.prototype.getClosestPage = function ( page ) {
+       var next, prev, level,
+               pages = this.stackLayout.getItems(),
+               index = pages.indexOf( page );
+
+       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;
+};
+
+/**
+ * Get the outline widget.
+ *
+ * If the booklet is not outlined, the method will return `null`.
+ *
+ * @return {OO.ui.OutlineSelectWidget|null} Outline widget, or null if the booklet is not outlined
+ */
+OO.ui.BookletLayout.prototype.getOutline = function () {
+       return this.outlineSelectWidget;
+};
+
+/**
+ * Get the outline controls widget.
+ *
+ * If the outline is not editable, the method will return `null`.
+ *
+ * @return {OO.ui.OutlineControlsWidget|null} The outline controls widget.
+ */
+OO.ui.BookletLayout.prototype.getOutlineControls = function () {
+       return this.outlineControlsWidget;
+};
+
+/**
+ * Get a page by its symbolic 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 ];
+};
+
+/**
+ * Get the current page.
+ *
+ * @return {OO.ui.PageLayout|undefined} Current page, if found
+ */
+OO.ui.BookletLayout.prototype.getCurrentPage = function () {
+       var name = this.getCurrentPageName();
+       return name ? this.getPage( name ) : undefined;
+};
+
+/**
+ * Get the symbolic name of the current page.
+ *
+ * @return {string|null} Symbolic name of the current page
+ */
+OO.ui.BookletLayout.prototype.getCurrentPageName = function () {
+       return this.currentPageName;
+};
+
+/**
+ * Add pages to the booklet 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.
+ *
+ * @param {OO.ui.PageLayout[]} pages Pages to add
+ * @param {number} index Index of the insertion point
+ * @fires add
+ * @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 = stackLayoutPages.indexOf( this.pages[ name ] );
+                       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( { 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 );
+
+       return this;
+};
+
+/**
+ * Remove the specified pages from the booklet layout.
+ *
+ * To remove all pages from the booklet, you may wish to use the #clearPages method instead.
+ *
+ * @param {OO.ui.PageLayout[]} pages An array of pages to remove
+ * @fires remove
+ * @chainable
+ */
+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();
+       }
+       this.stackLayout.removeItems( pages );
+       this.emit( 'remove', pages );
+
+       return this;
+};
+
+/**
+ * Clear all pages from the booklet layout.
+ *
+ * To remove only a subset of pages from the booklet, use the #removePages method.
+ *
+ * @fires remove
+ * @chainable
+ */
+OO.ui.BookletLayout.prototype.clearPages = function () {
+       var i, len,
+               pages = this.stackLayout.getItems();
+
+       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();
+
+       this.emit( 'remove', pages );
+
+       return this;
+};
+
+/**
+ * Set the current page by symbolic name.
+ *
+ * @fires set
+ * @param {string} name Symbolic name of page
+ */
+OO.ui.BookletLayout.prototype.setPage = function ( name ) {
+       var selectedItem,
+               $focused,
+               page = this.pages[ name ],
+               previousPage = this.currentPageName && this.pages[ this.currentPageName ];
+
+       if ( name !== this.currentPageName ) {
+               if ( this.outlined ) {
+                       selectedItem = this.outlineSelectWidget.getSelectedItem();
+                       if ( selectedItem && selectedItem.getData() !== name ) {
+                               this.outlineSelectWidget.selectItemByData( name );
+                       }
+               }
+               if ( page ) {
+                       if ( previousPage ) {
+                               previousPage.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 the layout is non-continuous, this check is
+                               // meaningless because the next page is not visible yet and thus can't hold focus.
+                               if (
+                                       this.autoFocus &&
+                                       this.stackLayout.continuous &&
+                                       OO.ui.findFocusable( page.$element ).length !== 0
+                               ) {
+                                       $focused = previousPage.$element.find( ':focus' );
+                                       if ( $focused.length ) {
+                                               $focused[ 0 ].blur();
+                                       }
+                               }
+                       }
+                       this.currentPageName = name;
+                       page.setActive( true );
+                       this.stackLayout.setItem( page );
+                       if ( !this.stackLayout.continuous && previousPage ) {
+                               // This should not be necessary, since any inputs on the previous page should have been
+                               // blurred when it was hidden, but browsers are not very consistent about this.
+                               $focused = previousPage.$element.find( ':focus' );
+                               if ( $focused.length ) {
+                                       $focused[ 0 ].blur();
+                               }
+                       }
+                       this.emit( 'set', page );
+               }
+       }
+};
+
+/**
+ * Select the first selectable page.
+ *
+ * @chainable
+ */
+OO.ui.BookletLayout.prototype.selectFirstSelectablePage = function () {
+       if ( !this.outlineSelectWidget.getSelectedItem() ) {
+               this.outlineSelectWidget.selectItem( this.outlineSelectWidget.getFirstSelectableItem() );
+       }
+
+       return this;
+};
+
+/**
+ * IndexLayouts contain {@link OO.ui.CardLayout card layouts} as well as
+ * {@link OO.ui.TabSelectWidget tabs} that allow users to easily navigate through the cards and
+ * select which one to display. By default, only one card is displayed at a time. When a user
+ * navigates to a new card, the index layout automatically focuses on the first focusable element,
+ * unless the default setting is changed.
+ *
+ * TODO: This class is similar to BookletLayout, we may want to refactor to reduce duplication
+ *
+ *     @example
+ *     // Example of a IndexLayout that contains two CardLayouts.
+ *
+ *     function CardOneLayout( name, config ) {
+ *         CardOneLayout.parent.call( this, name, config );
+ *         this.$element.append( '<p>First card</p>' );
+ *     }
+ *     OO.inheritClass( CardOneLayout, OO.ui.CardLayout );
+ *     CardOneLayout.prototype.setupTabItem = function () {
+ *         this.tabItem.setLabel( 'Card one' );
+ *     };
+ *
+ *     var card1 = new CardOneLayout( 'one' ),
+ *         card2 = new CardLayout( 'two', { label: 'Card two' } );
+ *
+ *     card2.$element.append( '<p>Second card</p>' );
+ *
+ *     var index = new OO.ui.IndexLayout();
+ *
+ *     index.addCards ( [ card1, card2 ] );
+ *     $( 'body' ).append( index.$element );
+ *
+ * @class
+ * @extends OO.ui.MenuLayout
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {boolean} [continuous=false] Show all cards, one after another
+ * @cfg {boolean} [expanded=true] Expand the content panel to fill the entire parent element.
+ * @cfg {boolean} [autoFocus=true] Focus on the first focusable element when a new card is displayed.
+ */
+OO.ui.IndexLayout = function OoUiIndexLayout( config ) {
+       // Configuration initialization
+       config = $.extend( {}, config, { menuPosition: 'top' } );
+
+       // Parent constructor
+       OO.ui.IndexLayout.parent.call( this, config );
+
+       // Properties
+       this.currentCardName = null;
+       this.cards = {};
+       this.ignoreFocus = false;
+       this.stackLayout = new OO.ui.StackLayout( {
+               continuous: !!config.continuous,
+               expanded: config.expanded
+       } );
+       this.$content.append( this.stackLayout.$element );
+       this.autoFocus = config.autoFocus === undefined || !!config.autoFocus;
+
+       this.tabSelectWidget = new OO.ui.TabSelectWidget();
+       this.tabPanel = new OO.ui.PanelLayout();
+       this.$menu.append( this.tabPanel.$element );
+
+       this.toggleMenu( true );
+
+       // Events
+       this.stackLayout.connect( this, { set: 'onStackLayoutSet' } );
+       this.tabSelectWidget.connect( this, { select: 'onTabSelectWidgetSelect' } );
+       if ( this.autoFocus ) {
+               // Event 'focus' does not bubble, but 'focusin' does
+               this.stackLayout.$element.on( 'focusin', this.onStackLayoutFocus.bind( this ) );
+       }
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-indexLayout' );
+       this.stackLayout.$element.addClass( 'oo-ui-indexLayout-stackLayout' );
+       this.tabPanel.$element
+               .addClass( 'oo-ui-indexLayout-tabPanel' )
+               .append( this.tabSelectWidget.$element );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.IndexLayout, OO.ui.MenuLayout );
+
+/* Events */
+
+/**
+ * A 'set' event is emitted when a card is {@link #setCard set} to be displayed by the index layout.
+ * @event set
+ * @param {OO.ui.CardLayout} card Current card
+ */
+
+/**
+ * An 'add' event is emitted when cards are {@link #addCards added} to the index layout.
+ *
+ * @event add
+ * @param {OO.ui.CardLayout[]} card Added cards
+ * @param {number} index Index cards were added at
+ */
+
+/**
+ * A 'remove' event is emitted when cards are {@link #clearCards cleared} or
+ * {@link #removeCards removed} from the index.
+ *
+ * @event remove
+ * @param {OO.ui.CardLayout[]} cards Removed cards
+ */
+
+/* Methods */
+
+/**
+ * Handle stack layout focus.
+ *
+ * @private
+ * @param {jQuery.Event} e Focusin event
+ */
+OO.ui.IndexLayout.prototype.onStackLayoutFocus = function ( e ) {
+       var name, $target;
+
+       // Find the card that an element was focused within
+       $target = $( e.target ).closest( '.oo-ui-cardLayout' );
+       for ( name in this.cards ) {
+               // Check for card match, exclude current card to find only card changes
+               if ( this.cards[ name ].$element[ 0 ] === $target[ 0 ] && name !== this.currentCardName ) {
+                       this.setCard( name );
+                       break;
+               }
+       }
+};
+
+/**
+ * Handle stack layout set events.
+ *
+ * @private
+ * @param {OO.ui.PanelLayout|null} card The card panel that is now the current panel
+ */
+OO.ui.IndexLayout.prototype.onStackLayoutSet = function ( card ) {
+       var layout = this;
+       if ( card ) {
+               card.scrollElementIntoView( { complete: function () {
+                       if ( layout.autoFocus ) {
+                               layout.focus();
+                       }
+               } } );
+       }
+};
+
+/**
+ * Focus the first input in the current card.
+ *
+ * If no card is selected, the first selectable card will be selected.
+ * If the focus is already in an element on the current card, nothing will happen.
+ * @param {number} [itemIndex] A specific item to focus on
+ */
+OO.ui.IndexLayout.prototype.focus = function ( itemIndex ) {
+       var card,
+               items = this.stackLayout.getItems();
+
+       if ( itemIndex !== undefined && items[ itemIndex ] ) {
+               card = items[ itemIndex ];
+       } else {
+               card = this.stackLayout.getCurrentItem();
+       }
+
+       if ( !card ) {
+               this.selectFirstSelectableCard();
+               card = this.stackLayout.getCurrentItem();
+       }
+       if ( !card ) {
+               return;
+       }
+       // Only change the focus if is not already in the current page
+       if ( !OO.ui.contains( card.$element[ 0 ], this.getElementDocument().activeElement, true ) ) {
+               card.focus();
+       }
+};
+
+/**
+ * Find the first focusable input in the index layout and focus
+ * on it.
+ */
+OO.ui.IndexLayout.prototype.focusFirstFocusable = function () {
+       OO.ui.findFocusable( this.stackLayout.$element ).focus();
+};
+
+/**
+ * Handle tab widget select events.
+ *
+ * @private
+ * @param {OO.ui.OptionWidget|null} item Selected item
+ */
+OO.ui.IndexLayout.prototype.onTabSelectWidgetSelect = function ( item ) {
+       if ( item ) {
+               this.setCard( item.getData() );
+       }
+};
+
+/**
+ * Get the card closest to the specified card.
+ *
+ * @param {OO.ui.CardLayout} card Card to use as a reference point
+ * @return {OO.ui.CardLayout|null} Card closest to the specified card
+ */
+OO.ui.IndexLayout.prototype.getClosestCard = function ( card ) {
+       var next, prev, level,
+               cards = this.stackLayout.getItems(),
+               index = cards.indexOf( card );
+
+       if ( index !== -1 ) {
+               next = cards[ index + 1 ];
+               prev = cards[ index - 1 ];
+               // Prefer adjacent cards at the same level
+               level = this.tabSelectWidget.getItemFromData( card.getName() ).getLevel();
+               if (
+                       prev &&
+                       level === this.tabSelectWidget.getItemFromData( prev.getName() ).getLevel()
+               ) {
+                       return prev;
+               }
+               if (
+                       next &&
+                       level === this.tabSelectWidget.getItemFromData( next.getName() ).getLevel()
+               ) {
+                       return next;
+               }
+       }
+       return prev || next || null;
+};
+
+/**
+ * Get the tabs widget.
+ *
+ * @return {OO.ui.TabSelectWidget} Tabs widget
+ */
+OO.ui.IndexLayout.prototype.getTabs = function () {
+       return this.tabSelectWidget;
+};
+
+/**
+ * Get a card by its symbolic name.
+ *
+ * @param {string} name Symbolic name of card
+ * @return {OO.ui.CardLayout|undefined} Card, if found
+ */
+OO.ui.IndexLayout.prototype.getCard = function ( name ) {
+       return this.cards[ name ];
+};
+
+/**
+ * Get the current card.
+ *
+ * @return {OO.ui.CardLayout|undefined} Current card, if found
+ */
+OO.ui.IndexLayout.prototype.getCurrentCard = function () {
+       var name = this.getCurrentCardName();
+       return name ? this.getCard( name ) : undefined;
+};
+
+/**
+ * Get the symbolic name of the current card.
+ *
+ * @return {string|null} Symbolic name of the current card
+ */
+OO.ui.IndexLayout.prototype.getCurrentCardName = function () {
+       return this.currentCardName;
+};
+
+/**
+ * Add cards to the index layout
+ *
+ * When cards are added with the same names as existing cards, the existing cards will be
+ * automatically removed before the new cards are added.
+ *
+ * @param {OO.ui.CardLayout[]} cards Cards to add
+ * @param {number} index Index of the insertion point
+ * @fires add
+ * @chainable
+ */
+OO.ui.IndexLayout.prototype.addCards = function ( cards, index ) {
+       var i, len, name, card, item, currentIndex,
+               stackLayoutCards = this.stackLayout.getItems(),
+               remove = [],
+               items = [];
+
+       // Remove cards with same names
+       for ( i = 0, len = cards.length; i < len; i++ ) {
+               card = cards[ i ];
+               name = card.getName();
+
+               if ( Object.prototype.hasOwnProperty.call( this.cards, name ) ) {
+                       // Correct the insertion index
+                       currentIndex = stackLayoutCards.indexOf( this.cards[ name ] );
+                       if ( currentIndex !== -1 && currentIndex + 1 < index ) {
+                               index--;
+                       }
+                       remove.push( this.cards[ name ] );
+               }
+       }
+       if ( remove.length ) {
+               this.removeCards( remove );
+       }
+
+       // Add new cards
+       for ( i = 0, len = cards.length; i < len; i++ ) {
+               card = cards[ i ];
+               name = card.getName();
+               this.cards[ card.getName() ] = card;
+               item = new OO.ui.TabOptionWidget( { data: name } );
+               card.setTabItem( item );
+               items.push( item );
+       }
+
+       if ( items.length ) {
+               this.tabSelectWidget.addItems( items, index );
+               this.selectFirstSelectableCard();
+       }
+       this.stackLayout.addItems( cards, index );
+       this.emit( 'add', cards, index );
+
+       return this;
+};
+
+/**
+ * Remove the specified cards from the index layout.
+ *
+ * To remove all cards from the index, you may wish to use the #clearCards method instead.
+ *
+ * @param {OO.ui.CardLayout[]} cards An array of cards to remove
+ * @fires remove
+ * @chainable
+ */
+OO.ui.IndexLayout.prototype.removeCards = function ( cards ) {
+       var i, len, name, card,
+               items = [];
+
+       for ( i = 0, len = cards.length; i < len; i++ ) {
+               card = cards[ i ];
+               name = card.getName();
+               delete this.cards[ name ];
+               items.push( this.tabSelectWidget.getItemFromData( name ) );
+               card.setTabItem( null );
+       }
+       if ( items.length ) {
+               this.tabSelectWidget.removeItems( items );
+               this.selectFirstSelectableCard();
+       }
+       this.stackLayout.removeItems( cards );
+       this.emit( 'remove', cards );
+
+       return this;
+};
+
+/**
+ * Clear all cards from the index layout.
+ *
+ * To remove only a subset of cards from the index, use the #removeCards method.
+ *
+ * @fires remove
+ * @chainable
+ */
+OO.ui.IndexLayout.prototype.clearCards = function () {
+       var i, len,
+               cards = this.stackLayout.getItems();
+
+       this.cards = {};
+       this.currentCardName = null;
+       this.tabSelectWidget.clearItems();
+       for ( i = 0, len = cards.length; i < len; i++ ) {
+               cards[ i ].setTabItem( null );
+       }
+       this.stackLayout.clearItems();
+
+       this.emit( 'remove', cards );
+
+       return this;
+};
+
+/**
+ * Set the current card by symbolic name.
+ *
+ * @fires set
+ * @param {string} name Symbolic name of card
+ */
+OO.ui.IndexLayout.prototype.setCard = function ( name ) {
+       var selectedItem,
+               $focused,
+               card = this.cards[ name ],
+               previousCard = this.currentCardName && this.cards[ this.currentCardName ];
+
+       if ( name !== this.currentCardName ) {
+               selectedItem = this.tabSelectWidget.getSelectedItem();
+               if ( selectedItem && selectedItem.getData() !== name ) {
+                       this.tabSelectWidget.selectItemByData( name );
+               }
+               if ( card ) {
+                       if ( previousCard ) {
+                               previousCard.setActive( false );
+                               // Blur anything focused if the next card doesn't have anything focusable.
+                               // This is not needed if the next card has something focusable (because once it is focused
+                               // this blur happens automatically). If the layout is non-continuous, this check is
+                               // meaningless because the next card is not visible yet and thus can't hold focus.
+                               if (
+                                       this.autoFocus &&
+                                       this.stackLayout.continuous &&
+                                       OO.ui.findFocusable( card.$element ).length !== 0
+                               ) {
+                                       $focused = previousCard.$element.find( ':focus' );
+                                       if ( $focused.length ) {
+                                               $focused[ 0 ].blur();
+                                       }
+                               }
+                       }
+                       this.currentCardName = name;
+                       card.setActive( true );
+                       this.stackLayout.setItem( card );
+                       if ( !this.stackLayout.continuous && previousCard ) {
+                               // This should not be necessary, since any inputs on the previous card should have been
+                               // blurred when it was hidden, but browsers are not very consistent about this.
+                               $focused = previousCard.$element.find( ':focus' );
+                               if ( $focused.length ) {
+                                       $focused[ 0 ].blur();
+                               }
+                       }
+                       this.emit( 'set', card );
+               }
+       }
+};
+
+/**
+ * Select the first selectable card.
+ *
+ * @chainable
+ */
+OO.ui.IndexLayout.prototype.selectFirstSelectableCard = function () {
+       if ( !this.tabSelectWidget.getSelectedItem() ) {
+               this.tabSelectWidget.selectItem( this.tabSelectWidget.getFirstSelectableItem() );
+       }
+
+       return this;
+};
+
+/**
+ * ToggleWidget implements basic behavior of widgets with an on/off state.
+ * Please see OO.ui.ToggleButtonWidget and OO.ui.ToggleSwitchWidget for examples.
+ *
+ * @abstract
+ * @class
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {boolean} [value=false] The toggle’s initial on/off state.
+ *  By default, the toggle is in the 'off' state.
+ */
+OO.ui.ToggleWidget = function OoUiToggleWidget( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.ToggleWidget.parent.call( this, config );
+
+       // Properties
+       this.value = null;
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-toggleWidget' );
+       this.setValue( !!config.value );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.ToggleWidget, OO.ui.Widget );
+
+/* Events */
+
+/**
+ * @event change
+ *
+ * A change event is emitted when the on/off state of the toggle changes.
+ *
+ * @param {boolean} value Value representing the new state of the toggle
+ */
+
+/* Methods */
+
+/**
+ * Get the value representing the toggle’s state.
+ *
+ * @return {boolean} The on/off state of the toggle
+ */
+OO.ui.ToggleWidget.prototype.getValue = function () {
+       return this.value;
+};
+
+/**
+ * Set the state of the toggle: `true` for 'on', `false' for 'off'.
+ *
+ * @param {boolean} value The state of the toggle
+ * @fires change
+ * @chainable
+ */
+OO.ui.ToggleWidget.prototype.setValue = function ( value ) {
+       value = !!value;
+       if ( this.value !== value ) {
+               this.value = 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;
+};
+
+/**
+ * ToggleButtons are buttons that have a state (‘on’ or ‘off’) that is represented by a
+ * Boolean value. Like other {@link OO.ui.ButtonWidget buttons}, toggle buttons can be
+ * configured with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators},
+ * {@link OO.ui.mixin.TitledElement titles}, {@link OO.ui.mixin.FlaggedElement styling flags},
+ * and {@link OO.ui.mixin.LabelElement labels}. Please see
+ * the [OOjs UI documentation][1] on MediaWiki for more information.
+ *
+ *     @example
+ *     // Toggle buttons in the 'off' and 'on' state.
+ *     var toggleButton1 = new OO.ui.ToggleButtonWidget( {
+ *         label: 'Toggle Button off'
+ *     } );
+ *     var toggleButton2 = new OO.ui.ToggleButtonWidget( {
+ *         label: 'Toggle Button on',
+ *         value: true
+ *     } );
+ *     // Append the buttons to the DOM.
+ *     $( 'body' ).append( toggleButton1.$element, toggleButton2.$element );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches#Toggle_buttons
+ *
+ * @class
+ * @extends OO.ui.ToggleWidget
+ * @mixins OO.ui.mixin.ButtonElement
+ * @mixins OO.ui.mixin.IconElement
+ * @mixins OO.ui.mixin.IndicatorElement
+ * @mixins OO.ui.mixin.LabelElement
+ * @mixins OO.ui.mixin.TitledElement
+ * @mixins OO.ui.mixin.FlaggedElement
+ * @mixins OO.ui.mixin.TabIndexedElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {boolean} [value=false] The toggle button’s initial on/off
+ *  state. By default, the button is in the 'off' state.
+ */
+OO.ui.ToggleButtonWidget = function OoUiToggleButtonWidget( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.ToggleButtonWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.ButtonElement.call( this, config );
+       OO.ui.mixin.IconElement.call( this, config );
+       OO.ui.mixin.IndicatorElement.call( this, config );
+       OO.ui.mixin.LabelElement.call( this, config );
+       OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
+       OO.ui.mixin.FlaggedElement.call( this, config );
+       OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) );
+
+       // Events
+       this.connect( this, { click: 'onAction' } );
+
+       // Initialization
+       this.$button.append( this.$icon, this.$label, this.$indicator );
+       this.$element
+               .addClass( 'oo-ui-toggleButtonWidget' )
+               .append( this.$button );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.ToggleButtonWidget, OO.ui.ToggleWidget );
+OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.ButtonElement );
+OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.IconElement );
+OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.IndicatorElement );
+OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.LabelElement );
+OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.TitledElement );
+OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.FlaggedElement );
+OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.TabIndexedElement );
+
+/* Methods */
+
+/**
+ * Handle the button action being triggered.
+ *
+ * @private
+ */
+OO.ui.ToggleButtonWidget.prototype.onAction = function () {
+       this.setValue( !this.value );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.ToggleButtonWidget.prototype.setValue = function ( value ) {
+       value = !!value;
+       if ( value !== this.value ) {
+               // Might be called from parent constructor before ButtonElement constructor
+               if ( this.$button ) {
+                       this.$button.attr( 'aria-pressed', value.toString() );
+               }
+               this.setActive( value );
+       }
+
+       // Parent method
+       OO.ui.ToggleButtonWidget.parent.prototype.setValue.call( this, value );
+
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.ToggleButtonWidget.prototype.setButtonElement = function ( $button ) {
+       if ( this.$button ) {
+               this.$button.removeAttr( 'aria-pressed' );
+       }
+       OO.ui.mixin.ButtonElement.prototype.setButtonElement.call( this, $button );
+       this.$button.attr( 'aria-pressed', this.value.toString() );
+};
+
+/**
+ * ToggleSwitches are switches that slide on and off. Their state is represented by a Boolean
+ * value (`true` for ‘on’, and `false` otherwise, the default). The ‘off’ state is represented
+ * visually by a slider in the leftmost position.
+ *
+ *     @example
+ *     // Toggle switches in the 'off' and 'on' position.
+ *     var toggleSwitch1 = new OO.ui.ToggleSwitchWidget();
+ *     var toggleSwitch2 = new OO.ui.ToggleSwitchWidget( {
+ *         value: true
+ *     } );
+ *
+ *     // Create a FieldsetLayout to layout and label switches
+ *     var fieldset = new OO.ui.FieldsetLayout( {
+ *        label: 'Toggle switches'
+ *     } );
+ *     fieldset.addItems( [
+ *         new OO.ui.FieldLayout( toggleSwitch1, { label: 'Off', align: 'top' } ),
+ *         new OO.ui.FieldLayout( toggleSwitch2, { label: 'On', align: 'top' } )
+ *     ] );
+ *     $( 'body' ).append( fieldset.$element );
+ *
+ * @class
+ * @extends OO.ui.ToggleWidget
+ * @mixins OO.ui.mixin.TabIndexedElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {boolean} [value=false] The toggle switch’s initial on/off state.
+ *  By default, the toggle switch is in the 'off' position.
+ */
+OO.ui.ToggleSwitchWidget = function OoUiToggleSwitchWidget( config ) {
+       // Parent constructor
+       OO.ui.ToggleSwitchWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.TabIndexedElement.call( this, config );
+
+       // Properties
+       this.dragging = false;
+       this.dragStart = null;
+       this.sliding = false;
+       this.$glow = $( '<span>' );
+       this.$grip = $( '<span>' );
+
+       // Events
+       this.$element.on( {
+               click: this.onClick.bind( this ),
+               keypress: this.onKeyPress.bind( this )
+       } );
+
+       // Initialization
+       this.$glow.addClass( 'oo-ui-toggleSwitchWidget-glow' );
+       this.$grip.addClass( 'oo-ui-toggleSwitchWidget-grip' );
+       this.$element
+               .addClass( 'oo-ui-toggleSwitchWidget' )
+               .attr( 'role', 'checkbox' )
+               .append( this.$glow, this.$grip );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.ToggleSwitchWidget, OO.ui.ToggleWidget );
+OO.mixinClass( OO.ui.ToggleSwitchWidget, OO.ui.mixin.TabIndexedElement );
+
+/* Methods */
+
+/**
+ * Handle mouse click events.
+ *
+ * @private
+ * @param {jQuery.Event} e Mouse click event
+ */
+OO.ui.ToggleSwitchWidget.prototype.onClick = function ( e ) {
+       if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
+               this.setValue( !this.value );
+       }
+       return false;
+};
+
+/**
+ * Handle key press events.
+ *
+ * @private
+ * @param {jQuery.Event} e Key press event
+ */
+OO.ui.ToggleSwitchWidget.prototype.onKeyPress = function ( e ) {
+       if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
+               this.setValue( !this.value );
+               return false;
+       }
+};
+
+/**
+ * OutlineControlsWidget is a set of controls for an {@link OO.ui.OutlineSelectWidget outline select widget}.
+ * Controls include moving items up and down, removing items, and adding different kinds of items.
+ *
+ * **Currently, this class is only used by {@link OO.ui.BookletLayout booklet layouts}.**
+ *
+ * @class
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.GroupElement
+ * @mixins OO.ui.mixin.IconElement
+ *
+ * @constructor
+ * @param {OO.ui.OutlineSelectWidget} outline Outline to control
+ * @param {Object} [config] Configuration options
+ * @cfg {Object} [abilities] List of abilties
+ * @cfg {boolean} [abilities.move=true] Allow moving movable items
+ * @cfg {boolean} [abilities.remove=true] Allow removing removable items
+ */
+OO.ui.OutlineControlsWidget = function OoUiOutlineControlsWidget( outline, config ) {
+       // Allow passing positional parameters inside the config object
+       if ( OO.isPlainObject( outline ) && config === undefined ) {
+               config = outline;
+               outline = config.outline;
+       }
+
+       // Configuration initialization
+       config = $.extend( { icon: 'add' }, config );
+
+       // Parent constructor
+       OO.ui.OutlineControlsWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.GroupElement.call( this, config );
+       OO.ui.mixin.IconElement.call( this, config );
+
+       // Properties
+       this.outline = outline;
+       this.$movers = $( '<div>' );
+       this.upButton = new OO.ui.ButtonWidget( {
+               framed: false,
+               icon: 'collapse',
+               title: OO.ui.msg( 'ooui-outline-control-move-up' )
+       } );
+       this.downButton = new OO.ui.ButtonWidget( {
+               framed: false,
+               icon: 'expand',
+               title: OO.ui.msg( 'ooui-outline-control-move-down' )
+       } );
+       this.removeButton = new OO.ui.ButtonWidget( {
+               framed: false,
+               icon: 'remove',
+               title: OO.ui.msg( 'ooui-outline-control-remove' )
+       } );
+       this.abilities = { move: true, remove: true };
+
+       // Events
+       outline.connect( this, {
+               select: 'onOutlineChange',
+               add: 'onOutlineChange',
+               remove: 'onOutlineChange'
+       } );
+       this.upButton.connect( this, { click: [ 'emit', 'move', -1 ] } );
+       this.downButton.connect( this, { click: [ 'emit', 'move', 1 ] } );
+       this.removeButton.connect( this, { click: [ 'emit', 'remove' ] } );
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-outlineControlsWidget' );
+       this.$group.addClass( 'oo-ui-outlineControlsWidget-items' );
+       this.$movers
+               .addClass( 'oo-ui-outlineControlsWidget-movers' )
+               .append( this.removeButton.$element, this.upButton.$element, this.downButton.$element );
+       this.$element.append( this.$icon, this.$group, this.$movers );
+       this.setAbilities( config.abilities || {} );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.OutlineControlsWidget, OO.ui.Widget );
+OO.mixinClass( OO.ui.OutlineControlsWidget, OO.ui.mixin.GroupElement );
+OO.mixinClass( OO.ui.OutlineControlsWidget, OO.ui.mixin.IconElement );
+
+/* Events */
+
+/**
+ * @event move
+ * @param {number} places Number of places to move
+ */
+
+/**
+ * @event remove
+ */
+
+/* Methods */
+
+/**
+ * Set abilities.
+ *
+ * @param {Object} abilities List of abilties
+ * @param {boolean} [abilities.move] Allow moving movable items
+ * @param {boolean} [abilities.remove] Allow removing removable items
+ */
+OO.ui.OutlineControlsWidget.prototype.setAbilities = function ( abilities ) {
+       var ability;
+
+       for ( ability in this.abilities ) {
+               if ( abilities[ ability ] !== undefined ) {
+                       this.abilities[ ability ] = !!abilities[ ability ];
+               }
+       }
+
+       this.onOutlineChange();
+};
+
+/**
+ * @private
+ * Handle outline change events.
+ */
+OO.ui.OutlineControlsWidget.prototype.onOutlineChange = function () {
+       var i, len, firstMovable, lastMovable,
+               items = this.outline.getItems(),
+               selectedItem = this.outline.getSelectedItem(),
+               movable = this.abilities.move && selectedItem && selectedItem.isMovable(),
+               removable = this.abilities.remove && selectedItem && selectedItem.isRemovable();
+
+       if ( movable ) {
+               i = -1;
+               len = items.length;
+               while ( ++i < len ) {
+                       if ( items[ i ].isMovable() ) {
+                               firstMovable = items[ i ];
+                               break;
+                       }
+               }
+               i = len;
+               while ( i-- ) {
+                       if ( items[ i ].isMovable() ) {
+                               lastMovable = items[ i ];
+                               break;
+                       }
+               }
+       }
+       this.upButton.setDisabled( !movable || selectedItem === firstMovable );
+       this.downButton.setDisabled( !movable || selectedItem === lastMovable );
+       this.removeButton.setDisabled( !removable );
+};
+
+/**
+ * OutlineOptionWidget is an item in an {@link OO.ui.OutlineSelectWidget OutlineSelectWidget}.
+ *
+ * Currently, this class is only used by {@link OO.ui.BookletLayout booklet layouts}, which contain
+ * {@link OO.ui.PageLayout page layouts}. See {@link OO.ui.BookletLayout BookletLayout}
+ * for an example.
+ *
+ * @class
+ * @extends OO.ui.DecoratedOptionWidget
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {number} [level] Indentation level
+ * @cfg {boolean} [movable] Allow modification from {@link OO.ui.OutlineControlsWidget outline controls}.
+ */
+OO.ui.OutlineOptionWidget = function OoUiOutlineOptionWidget( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.OutlineOptionWidget.parent.call( this, config );
+
+       // Properties
+       this.level = 0;
+       this.movable = !!config.movable;
+       this.removable = !!config.removable;
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-outlineOptionWidget' );
+       this.setLevel( config.level );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.OutlineOptionWidget, OO.ui.DecoratedOptionWidget );
+
+/* Static Properties */
+
+OO.ui.OutlineOptionWidget.static.highlightable = false;
+
+OO.ui.OutlineOptionWidget.static.scrollIntoViewOnSelect = true;
+
+OO.ui.OutlineOptionWidget.static.levelClass = 'oo-ui-outlineOptionWidget-level-';
+
+OO.ui.OutlineOptionWidget.static.levels = 3;
+
+/* Methods */
+
+/**
+ * Check if item is movable.
+ *
+ * Movability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
+ *
+ * @return {boolean} Item is movable
+ */
+OO.ui.OutlineOptionWidget.prototype.isMovable = function () {
+       return this.movable;
+};
+
+/**
+ * Check if item is removable.
+ *
+ * Removability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
+ *
+ * @return {boolean} Item is removable
+ */
+OO.ui.OutlineOptionWidget.prototype.isRemovable = function () {
+       return this.removable;
+};
+
+/**
+ * Get indentation level.
+ *
+ * @return {number} Indentation level
+ */
+OO.ui.OutlineOptionWidget.prototype.getLevel = function () {
+       return this.level;
+};
+
+/**
+ * Set movability.
+ *
+ * Movability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
+ *
+ * @param {boolean} movable Item is movable
+ * @chainable
+ */
+OO.ui.OutlineOptionWidget.prototype.setMovable = function ( movable ) {
+       this.movable = !!movable;
+       this.updateThemeClasses();
+       return this;
+};
+
+/**
+ * Set removability.
+ *
+ * Removability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
+ *
+ * @param {boolean} removable Item is removable
+ * @chainable
+ */
+OO.ui.OutlineOptionWidget.prototype.setRemovable = function ( removable ) {
+       this.removable = !!removable;
+       this.updateThemeClasses();
+       return this;
+};
+
+/**
+ * Set indentation level.
+ *
+ * @param {number} [level=0] Indentation level, in the range of [0,#maxLevel]
+ * @chainable
+ */
+OO.ui.OutlineOptionWidget.prototype.setLevel = function ( level ) {
+       var levels = this.constructor.static.levels,
+               levelClass = this.constructor.static.levelClass,
+               i = levels;
+
+       this.level = level ? Math.max( 0, Math.min( levels - 1, level ) ) : 0;
+       while ( i-- ) {
+               if ( this.level === i ) {
+                       this.$element.addClass( levelClass + i );
+               } else {
+                       this.$element.removeClass( levelClass + i );
+               }
+       }
+       this.updateThemeClasses();
+
+       return this;
+};
+
+/**
+ * OutlineSelectWidget is a structured list that contains {@link OO.ui.OutlineOptionWidget outline options}
+ * A set of controls can be provided with an {@link OO.ui.OutlineControlsWidget outline controls} widget.
+ *
+ * **Currently, this class is only used by {@link OO.ui.BookletLayout booklet layouts}.**
+ *
+ * @class
+ * @extends OO.ui.SelectWidget
+ * @mixins OO.ui.mixin.TabIndexedElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.OutlineSelectWidget = function OoUiOutlineSelectWidget( config ) {
+       // Parent constructor
+       OO.ui.OutlineSelectWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.TabIndexedElement.call( this, config );
+
+       // Events
+       this.$element.on( {
+               focus: this.bindKeyDownListener.bind( this ),
+               blur: this.unbindKeyDownListener.bind( this )
+       } );
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-outlineSelectWidget' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.OutlineSelectWidget, OO.ui.SelectWidget );
+OO.mixinClass( OO.ui.OutlineSelectWidget, OO.ui.mixin.TabIndexedElement );
+
+/**
+ * ButtonOptionWidget is a special type of {@link OO.ui.mixin.ButtonElement button element} that
+ * can be selected and configured with data. The class is
+ * used with OO.ui.ButtonSelectWidget to create a selection of button options. Please see the
+ * [OOjs UI documentation on MediaWiki] [1] for more information.
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Button_selects_and_options
+ *
+ * @class
+ * @extends OO.ui.DecoratedOptionWidget
+ * @mixins OO.ui.mixin.ButtonElement
+ * @mixins OO.ui.mixin.TabIndexedElement
+ * @mixins OO.ui.mixin.TitledElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.ButtonOptionWidget = function OoUiButtonOptionWidget( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.ButtonOptionWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.ButtonElement.call( this, config );
+       OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
+       OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, {
+               $tabIndexed: this.$button,
+               tabIndex: -1
+       } ) );
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-buttonOptionWidget' );
+       this.$button.append( this.$element.contents() );
+       this.$element.append( this.$button );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.ButtonOptionWidget, OO.ui.DecoratedOptionWidget );
+OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.ButtonElement );
+OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.TitledElement );
+OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.TabIndexedElement );
+
+/* Static Properties */
+
+// Allow button mouse down events to pass through so they can be handled by the parent select widget
+OO.ui.ButtonOptionWidget.static.cancelButtonMouseDownEvents = false;
+
+OO.ui.ButtonOptionWidget.static.highlightable = false;
+
+/* Methods */
+
+/**
+ * @inheritdoc
+ */
+OO.ui.ButtonOptionWidget.prototype.setSelected = function ( state ) {
+       OO.ui.ButtonOptionWidget.parent.prototype.setSelected.call( this, state );
+
+       if ( this.constructor.static.selectable ) {
+               this.setActive( state );
+       }
+
+       return this;
+};
+
+/**
+ * ButtonSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains
+ * button options and is used together with
+ * OO.ui.ButtonOptionWidget. The ButtonSelectWidget provides an interface for
+ * highlighting, choosing, and selecting mutually exclusive options. Please see
+ * the [OOjs UI documentation on MediaWiki] [1] for more information.
+ *
+ *     @example
+ *     // Example: A ButtonSelectWidget that contains three ButtonOptionWidgets
+ *     var option1 = new OO.ui.ButtonOptionWidget( {
+ *         data: 1,
+ *         label: 'Option 1',
+ *         title: 'Button option 1'
+ *     } );
+ *
+ *     var option2 = new OO.ui.ButtonOptionWidget( {
+ *         data: 2,
+ *         label: 'Option 2',
+ *         title: 'Button option 2'
+ *     } );
+ *
+ *     var option3 = new OO.ui.ButtonOptionWidget( {
+ *         data: 3,
+ *         label: 'Option 3',
+ *         title: 'Button option 3'
+ *     } );
+ *
+ *     var buttonSelect=new OO.ui.ButtonSelectWidget( {
+ *         items: [ option1, option2, option3 ]
+ *     } );
+ *     $( 'body' ).append( buttonSelect.$element );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
+ *
+ * @class
+ * @extends OO.ui.SelectWidget
+ * @mixins OO.ui.mixin.TabIndexedElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.ButtonSelectWidget = function OoUiButtonSelectWidget( config ) {
+       // Parent constructor
+       OO.ui.ButtonSelectWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.TabIndexedElement.call( this, config );
+
+       // Events
+       this.$element.on( {
+               focus: this.bindKeyDownListener.bind( this ),
+               blur: this.unbindKeyDownListener.bind( this )
+       } );
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-buttonSelectWidget' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.ButtonSelectWidget, OO.ui.SelectWidget );
+OO.mixinClass( OO.ui.ButtonSelectWidget, OO.ui.mixin.TabIndexedElement );
+
+/**
+ * TabOptionWidget is an item in a {@link OO.ui.TabSelectWidget TabSelectWidget}.
+ *
+ * Currently, this class is only used by {@link OO.ui.IndexLayout index layouts}, which contain
+ * {@link OO.ui.CardLayout card layouts}. See {@link OO.ui.IndexLayout IndexLayout}
+ * for an example.
+ *
+ * @class
+ * @extends OO.ui.OptionWidget
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.TabOptionWidget = function OoUiTabOptionWidget( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.TabOptionWidget.parent.call( this, config );
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-tabOptionWidget' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.TabOptionWidget, OO.ui.OptionWidget );
+
+/* Static Properties */
+
+OO.ui.TabOptionWidget.static.highlightable = false;
+
+/**
+ * TabSelectWidget is a list that contains {@link OO.ui.TabOptionWidget tab options}
+ *
+ * **Currently, this class is only used by {@link OO.ui.IndexLayout index layouts}.**
+ *
+ * @class
+ * @extends OO.ui.SelectWidget
+ * @mixins OO.ui.mixin.TabIndexedElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.TabSelectWidget = function OoUiTabSelectWidget( config ) {
+       // Parent constructor
+       OO.ui.TabSelectWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.TabIndexedElement.call( this, config );
+
+       // Events
+       this.$element.on( {
+               focus: this.bindKeyDownListener.bind( this ),
+               blur: this.unbindKeyDownListener.bind( this )
+       } );
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-tabSelectWidget' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.TabSelectWidget, OO.ui.SelectWidget );
+OO.mixinClass( OO.ui.TabSelectWidget, OO.ui.mixin.TabIndexedElement );
+
+/**
+ * CapsuleItemWidgets are used within a {@link OO.ui.CapsuleMultiSelectWidget
+ * CapsuleMultiSelectWidget} to display the selected items.
+ *
+ * @class
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.ItemWidget
+ * @mixins OO.ui.mixin.IndicatorElement
+ * @mixins OO.ui.mixin.LabelElement
+ * @mixins OO.ui.mixin.FlaggedElement
+ * @mixins OO.ui.mixin.TabIndexedElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.CapsuleItemWidget = function OoUiCapsuleItemWidget( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.CapsuleItemWidget.parent.call( this, config );
+
+       // Properties (must be set before mixin constructor calls)
+       this.$indicator = $( '<span>' );
+
+       // Mixin constructors
+       OO.ui.mixin.ItemWidget.call( this );
+       OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$indicator, indicator: 'clear' } ) );
+       OO.ui.mixin.LabelElement.call( this, config );
+       OO.ui.mixin.FlaggedElement.call( this, config );
+       OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$indicator } ) );
+
+       // Events
+       this.$indicator.on( {
+               keydown: this.onCloseKeyDown.bind( this ),
+               click: this.onCloseClick.bind( this )
+       } );
+
+       // Initialization
+       this.$element
+               .addClass( 'oo-ui-capsuleItemWidget' )
+               .append( this.$indicator, this.$label );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.CapsuleItemWidget, OO.ui.Widget );
+OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.ItemWidget );
+OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.IndicatorElement );
+OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.LabelElement );
+OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.FlaggedElement );
+OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.TabIndexedElement );
+
+/* Methods */
+
+/**
+ * Handle close icon clicks
+ * @param {jQuery.Event} event
+ */
+OO.ui.CapsuleItemWidget.prototype.onCloseClick = function () {
+       var element = this.getElementGroup();
+
+       if ( !this.isDisabled() && element && $.isFunction( element.removeItems ) ) {
+               element.removeItems( [ this ] );
+               element.focus();
+       }
+};
+
+/**
+ * Handle close keyboard events
+ * @param {jQuery.Event} event Key down event
+ */
+OO.ui.CapsuleItemWidget.prototype.onCloseKeyDown = function ( e ) {
+       if ( !this.isDisabled() && $.isFunction( this.getElementGroup().removeItems ) ) {
+               switch ( e.which ) {
+                       case OO.ui.Keys.ENTER:
+                       case OO.ui.Keys.BACKSPACE:
+                       case OO.ui.Keys.SPACE:
+                               this.getElementGroup().removeItems( [ this ] );
+                               return false;
+               }
+       }
+};
+
+/**
+ * CapsuleMultiSelectWidgets are something like a {@link OO.ui.ComboBoxInputWidget combo box widget}
+ * that allows for selecting multiple values.
+ *
+ * For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
+ *
+ *     @example
+ *     // Example: A CapsuleMultiSelectWidget.
+ *     var capsule = new OO.ui.CapsuleMultiSelectWidget( {
+ *         label: 'CapsuleMultiSelectWidget',
+ *         selected: [ 'Option 1', 'Option 3' ],
+ *         menu: {
+ *             items: [
+ *                 new OO.ui.MenuOptionWidget( {
+ *                     data: 'Option 1',
+ *                     label: 'Option One'
+ *                 } ),
+ *                 new OO.ui.MenuOptionWidget( {
+ *                     data: 'Option 2',
+ *                     label: 'Option Two'
+ *                 } ),
+ *                 new OO.ui.MenuOptionWidget( {
+ *                     data: 'Option 3',
+ *                     label: 'Option Three'
+ *                 } ),
+ *                 new OO.ui.MenuOptionWidget( {
+ *                     data: 'Option 4',
+ *                     label: 'Option Four'
+ *                 } ),
+ *                 new OO.ui.MenuOptionWidget( {
+ *                     data: 'Option 5',
+ *                     label: 'Option Five'
+ *                 } )
+ *             ]
+ *         }
+ *     } );
+ *     $( 'body' ).append( capsule.$element );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
+ *
+ * @class
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.TabIndexedElement
+ * @mixins OO.ui.mixin.GroupElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {boolean} [allowArbitrary=false] Allow data items to be added even if not present in the menu.
+ * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu select widget}.
+ * @cfg {Object} [popup] Configuration options to pass to the {@link OO.ui.PopupWidget popup widget}.
+ *  If specified, this popup will be shown instead of the menu (but the menu
+ *  will still be used for item labels and allowArbitrary=false). The widgets
+ *  in the popup should use this.addItemsFromData() or this.addItems() as necessary.
+ * @cfg {jQuery} [$overlay] Render the menu or popup into a separate layer.
+ *  This configuration is useful in cases where the expanded menu is larger than
+ *  its containing `<div>`. The specified overlay layer is usually on top of
+ *  the containing `<div>` and has a larger area. By default, the menu uses
+ *  relative positioning.
+ */
+OO.ui.CapsuleMultiSelectWidget = function OoUiCapsuleMultiSelectWidget( config ) {
+       var $tabFocus;
+
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.CapsuleMultiSelectWidget.parent.call( this, config );
+
+       // Properties (must be set before mixin constructor calls)
+       this.$input = config.popup ? null : $( '<input>' );
+       this.$handle = $( '<div>' );
+
+       // Mixin constructors
+       OO.ui.mixin.GroupElement.call( this, config );
+       if ( config.popup ) {
+               config.popup = $.extend( {}, config.popup, {
+                       align: 'forwards',
+                       anchor: false
+               } );
+               OO.ui.mixin.PopupElement.call( this, config );
+               $tabFocus = $( '<span>' );
+               OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: $tabFocus } ) );
+       } else {
+               this.popup = null;
+               $tabFocus = null;
+               OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
+       }
+       OO.ui.mixin.IndicatorElement.call( this, config );
+       OO.ui.mixin.IconElement.call( this, config );
+
+       // Properties
+       this.$content = $( '<div>' );
+       this.allowArbitrary = !!config.allowArbitrary;
+       this.$overlay = config.$overlay || this.$element;
+       this.menu = new OO.ui.FloatingMenuSelectWidget( $.extend(
+               {
+                       widget: this,
+                       $input: this.$input,
+                       $container: this.$element,
+                       filterFromInput: true,
+                       disabled: this.isDisabled()
+               },
+               config.menu
+       ) );
+
+       // Events
+       if ( this.popup ) {
+               $tabFocus.on( {
+                       focus: this.onFocusForPopup.bind( this )
+               } );
+               this.popup.$element.on( 'focusout', this.onPopupFocusOut.bind( this ) );
+               if ( this.popup.$autoCloseIgnore ) {
+                       this.popup.$autoCloseIgnore.on( 'focusout', this.onPopupFocusOut.bind( this ) );
+               }
+               this.popup.connect( this, {
+                       toggle: function ( visible ) {
+                               $tabFocus.toggle( !visible );
+                       }
+               } );
+       } else {
+               this.$input.on( {
+                       focus: this.onInputFocus.bind( this ),
+                       blur: this.onInputBlur.bind( this ),
+                       'propertychange change click mouseup keydown keyup input cut paste select focus':
+                               OO.ui.debounce( this.updateInputSize.bind( this ) ),
+                       keydown: this.onKeyDown.bind( this ),
+                       keypress: this.onKeyPress.bind( this )
+               } );
+       }
+       this.menu.connect( this, {
+               choose: 'onMenuChoose',
+               add: 'onMenuItemsChange',
+               remove: 'onMenuItemsChange'
+       } );
+       this.$handle.on( {
+               mousedown: this.onMouseDown.bind( this )
+       } );
+
+       // Initialization
+       if ( this.$input ) {
+               this.$input.prop( 'disabled', this.isDisabled() );
+               this.$input.attr( {
+                       role: 'combobox',
+                       'aria-autocomplete': 'list'
+               } );
+               this.updateInputSize();
+       }
+       if ( config.data ) {
+               this.setItemsFromData( config.data );
+       }
+       this.$content.addClass( 'oo-ui-capsuleMultiSelectWidget-content' )
+               .append( this.$group );
+       this.$group.addClass( 'oo-ui-capsuleMultiSelectWidget-group' );
+       this.$handle.addClass( 'oo-ui-capsuleMultiSelectWidget-handle' )
+               .append( this.$indicator, this.$icon, this.$content );
+       this.$element.addClass( 'oo-ui-capsuleMultiSelectWidget' )
+               .append( this.$handle );
+       if ( this.popup ) {
+               this.$content.append( $tabFocus );
+               this.$overlay.append( this.popup.$element );
+       } else {
+               this.$content.append( this.$input );
+               this.$overlay.append( this.menu.$element );
+       }
+       this.onMenuItemsChange();
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.Widget );
+OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.GroupElement );
+OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.PopupElement );
+OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.TabIndexedElement );
+OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.IndicatorElement );
+OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.IconElement );
+
+/* Events */
+
+/**
+ * @event change
+ *
+ * A change event is emitted when the set of selected items changes.
+ *
+ * @param {Mixed[]} datas Data of the now-selected items
+ */
+
+/* Methods */
+
+/**
+ * Construct a OO.ui.CapsuleItemWidget (or a subclass thereof) from given label and data.
+ *
+ * @protected
+ * @param {Mixed} data Custom data of any type.
+ * @param {string} label The label text.
+ * @return {OO.ui.CapsuleItemWidget}
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.createItemWidget = function ( data, label ) {
+       return new OO.ui.CapsuleItemWidget( { data: data, label: label } );
+};
+
+/**
+ * Get the data of the items in the capsule
+ * @return {Mixed[]}
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.getItemsData = function () {
+       return $.map( this.getItems(), function ( e ) { return e.data; } );
+};
+
+/**
+ * Set the items in the capsule by providing data
+ * @chainable
+ * @param {Mixed[]} datas
+ * @return {OO.ui.CapsuleMultiSelectWidget}
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.setItemsFromData = function ( datas ) {
+       var widget = this,
+               menu = this.menu,
+               items = this.getItems();
+
+       $.each( datas, function ( i, data ) {
+               var j, label,
+                       item = menu.getItemFromData( data );
+
+               if ( item ) {
+                       label = item.label;
+               } else if ( widget.allowArbitrary ) {
+                       label = String( data );
+               } else {
+                       return;
+               }
+
+               item = null;
+               for ( j = 0; j < items.length; j++ ) {
+                       if ( items[ j ].data === data && items[ j ].label === label ) {
+                               item = items[ j ];
+                               items.splice( j, 1 );
+                               break;
+                       }
+               }
+               if ( !item ) {
+                       item = widget.createItemWidget( data, label );
+               }
+               widget.addItems( [ item ], i );
+       } );
+
+       if ( items.length ) {
+               widget.removeItems( items );
+       }
+
+       return this;
+};
+
+/**
+ * Add items to the capsule by providing their data
+ * @chainable
+ * @param {Mixed[]} datas
+ * @return {OO.ui.CapsuleMultiSelectWidget}
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.addItemsFromData = function ( datas ) {
+       var widget = this,
+               menu = this.menu,
+               items = [];
+
+       $.each( datas, function ( i, data ) {
+               var item;
+
+               if ( !widget.getItemFromData( data ) ) {
+                       item = menu.getItemFromData( data );
+                       if ( item ) {
+                               items.push( widget.createItemWidget( data, item.label ) );
+                       } else if ( widget.allowArbitrary ) {
+                               items.push( widget.createItemWidget( data, String( data ) ) );
+                       }
+               }
+       } );
+
+       if ( items.length ) {
+               this.addItems( items );
+       }
+
+       return this;
+};
+
+/**
+ * Remove items by data
+ * @chainable
+ * @param {Mixed[]} datas
+ * @return {OO.ui.CapsuleMultiSelectWidget}
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.removeItemsFromData = function ( datas ) {
+       var widget = this,
+               items = [];
+
+       $.each( datas, function ( i, data ) {
+               var item = widget.getItemFromData( data );
+               if ( item ) {
+                       items.push( item );
+               }
+       } );
+
+       if ( items.length ) {
+               this.removeItems( items );
+       }
+
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.addItems = function ( items ) {
+       var same, i, l,
+               oldItems = this.items.slice();
+
+       OO.ui.mixin.GroupElement.prototype.addItems.call( this, items );
+
+       if ( this.items.length !== oldItems.length ) {
+               same = false;
+       } else {
+               same = true;
+               for ( i = 0, l = oldItems.length; same && i < l; i++ ) {
+                       same = same && this.items[ i ] === oldItems[ i ];
+               }
+       }
+       if ( !same ) {
+               this.emit( 'change', this.getItemsData() );
+               this.menu.position();
+       }
+
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.removeItems = function ( items ) {
+       var same, i, l,
+               oldItems = this.items.slice();
+
+       OO.ui.mixin.GroupElement.prototype.removeItems.call( this, items );
+
+       if ( this.items.length !== oldItems.length ) {
+               same = false;
+       } else {
+               same = true;
+               for ( i = 0, l = oldItems.length; same && i < l; i++ ) {
+                       same = same && this.items[ i ] === oldItems[ i ];
+               }
+       }
+       if ( !same ) {
+               this.emit( 'change', this.getItemsData() );
+               this.menu.position();
+       }
+
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.clearItems = function () {
+       if ( this.items.length ) {
+               OO.ui.mixin.GroupElement.prototype.clearItems.call( this );
+               this.emit( 'change', this.getItemsData() );
+               this.menu.position();
+       }
+       return this;
+};
+
+/**
+ * Get the capsule widget's menu.
+ * @return {OO.ui.MenuSelectWidget} Menu widget
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.getMenu = function () {
+       return this.menu;
+};
+
+/**
+ * Handle focus events
+ *
+ * @private
+ * @param {jQuery.Event} event
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onInputFocus = function () {
+       if ( !this.isDisabled() ) {
+               this.menu.toggle( true );
+       }
+};
+
+/**
+ * Handle blur events
+ *
+ * @private
+ * @param {jQuery.Event} event
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onInputBlur = function () {
+       if ( this.allowArbitrary && this.$input.val().trim() !== '' ) {
+               this.addItemsFromData( [ this.$input.val() ] );
+       }
+       this.clearInput();
+};
+
+/**
+ * Handle focus events
+ *
+ * @private
+ * @param {jQuery.Event} event
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onFocusForPopup = function () {
+       if ( !this.isDisabled() ) {
+               this.popup.setSize( this.$handle.width() );
+               this.popup.toggle( true );
+               this.popup.$element.find( '*' )
+                       .filter( function () { return OO.ui.isFocusableElement( $( this ), true ); } )
+                       .first()
+                       .focus();
+       }
+};
+
+/**
+ * Handles popup focus out events.
+ *
+ * @private
+ * @param {Event} e Focus out event
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onPopupFocusOut = function () {
+       var widget = this.popup;
+
+       setTimeout( function () {
+               if (
+                       widget.isVisible() &&
+                       !OO.ui.contains( widget.$element[ 0 ], document.activeElement, true ) &&
+                       ( !widget.$autoCloseIgnore || !widget.$autoCloseIgnore.has( document.activeElement ).length )
+               ) {
+                       widget.toggle( false );
+               }
+       } );
+};
+
+/**
+ * Handle mouse down events.
+ *
+ * @private
+ * @param {jQuery.Event} e Mouse down event
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onMouseDown = function ( e ) {
+       if ( e.which === OO.ui.MouseButtons.LEFT ) {
+               this.focus();
+               return false;
+       } else {
+               this.updateInputSize();
+       }
+};
+
+/**
+ * Handle key press events.
+ *
+ * @private
+ * @param {jQuery.Event} e Key press event
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onKeyPress = function ( e ) {
+       var item;
+
+       if ( !this.isDisabled() ) {
+               if ( e.which === OO.ui.Keys.ESCAPE ) {
+                       this.clearInput();
+                       return false;
+               }
+
+               if ( !this.popup ) {
+                       this.menu.toggle( true );
+                       if ( e.which === OO.ui.Keys.ENTER ) {
+                               item = this.menu.getItemFromLabel( this.$input.val(), true );
+                               if ( item ) {
+                                       this.addItemsFromData( [ item.data ] );
+                                       this.clearInput();
+                               } else if ( this.allowArbitrary && this.$input.val().trim() !== '' ) {
+                                       this.addItemsFromData( [ this.$input.val() ] );
+                                       this.clearInput();
+                               }
+                               return false;
+                       }
+
+                       // Make sure the input gets resized.
+                       setTimeout( this.updateInputSize.bind( this ), 0 );
+               }
+       }
+};
+
+/**
+ * Handle key down events.
+ *
+ * @private
+ * @param {jQuery.Event} e Key down event
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onKeyDown = function ( e ) {
+       if ( !this.isDisabled() ) {
+               // 'keypress' event is not triggered for Backspace
+               if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.$input.val() === '' ) {
+                       if ( this.items.length ) {
+                               this.removeItems( this.items.slice( -1 ) );
+                       }
+                       return false;
+               }
+       }
+};
+
+/**
+ * Update the dimensions of the text input field to encompass all available area.
+ *
+ * @private
+ * @param {jQuery.Event} e Event of some sort
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.updateInputSize = function () {
+       var $lastItem, direction, contentWidth, currentWidth, bestWidth;
+       if ( !this.isDisabled() ) {
+               this.$input.css( 'width', '1em' );
+               $lastItem = this.$group.children().last();
+               direction = OO.ui.Element.static.getDir( this.$handle );
+               contentWidth = this.$input[ 0 ].scrollWidth;
+               currentWidth = this.$input.width();
+
+               if ( contentWidth < currentWidth ) {
+                       // All is fine, don't perform expensive calculations
+                       return;
+               }
+
+               if ( !$lastItem.length ) {
+                       bestWidth = this.$content.innerWidth();
+               } else {
+                       bestWidth = direction === 'ltr' ?
+                               this.$content.innerWidth() - $lastItem.position().left - $lastItem.outerWidth() :
+                               $lastItem.position().left;
+               }
+               // Some safety margin for sanity, because I *really* don't feel like finding out where the few
+               // pixels this is off by are coming from.
+               bestWidth -= 10;
+               if ( contentWidth > bestWidth ) {
+                       // This will result in the input getting shifted to the next line
+                       bestWidth = this.$content.innerWidth() - 10;
+               }
+               this.$input.width( Math.floor( bestWidth ) );
+
+               this.menu.position();
+       }
+};
+
+/**
+ * Handle menu choose events.
+ *
+ * @private
+ * @param {OO.ui.OptionWidget} item Chosen item
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onMenuChoose = function ( item ) {
+       if ( item && item.isVisible() ) {
+               this.addItemsFromData( [ item.getData() ] );
+               this.clearInput();
+       }
+};
+
+/**
+ * Handle menu item change events.
+ *
+ * @private
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.onMenuItemsChange = function () {
+       this.setItemsFromData( this.getItemsData() );
+       this.$element.toggleClass( 'oo-ui-capsuleMultiSelectWidget-empty', this.menu.isEmpty() );
+};
+
+/**
+ * Clear the input field
+ * @private
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.clearInput = function () {
+       if ( this.$input ) {
+               this.$input.val( '' );
+               this.updateInputSize();
+       }
+       if ( this.popup ) {
+               this.popup.toggle( false );
+       }
+       this.menu.toggle( false );
+       this.menu.selectItem();
+       this.menu.highlightItem();
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.setDisabled = function ( disabled ) {
+       var i, len;
+
+       // Parent method
+       OO.ui.CapsuleMultiSelectWidget.parent.prototype.setDisabled.call( this, disabled );
+
+       if ( this.$input ) {
+               this.$input.prop( 'disabled', this.isDisabled() );
+       }
+       if ( this.menu ) {
+               this.menu.setDisabled( this.isDisabled() );
+       }
+       if ( this.popup ) {
+               this.popup.setDisabled( this.isDisabled() );
+       }
+
+       if ( this.items ) {
+               for ( i = 0, len = this.items.length; i < len; i++ ) {
+                       this.items[ i ].updateDisabled();
+               }
+       }
+
+       return this;
+};
+
+/**
+ * Focus the widget
+ * @chainable
+ * @return {OO.ui.CapsuleMultiSelectWidget}
+ */
+OO.ui.CapsuleMultiSelectWidget.prototype.focus = function () {
+       if ( !this.isDisabled() ) {
+               if ( this.popup ) {
+                       this.popup.setSize( this.$handle.width() );
+                       this.popup.toggle( true );
+                       this.popup.$element.find( '*' )
+                               .filter( function () { return OO.ui.isFocusableElement( $( this ), true ); } )
+                               .first()
+                               .focus();
+               } else {
+                       this.updateInputSize();
+                       this.menu.toggle( true );
+                       this.$input.focus();
+               }
+       }
+       return this;
+};
+
+/**
+ * SelectFileWidgets allow for selecting files, using the HTML5 File API. These
+ * widgets can be configured with {@link OO.ui.mixin.IconElement icons} and {@link
+ * OO.ui.mixin.IndicatorElement indicators}.
+ * Please see the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
+ *
+ *     @example
+ *     // Example of a file select widget
+ *     var selectFile = new OO.ui.SelectFileWidget();
+ *     $( 'body' ).append( selectFile.$element );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets
+ *
+ * @class
+ * @extends OO.ui.Widget
+ * @mixins OO.ui.mixin.IconElement
+ * @mixins OO.ui.mixin.IndicatorElement
+ * @mixins OO.ui.mixin.PendingElement
+ * @mixins OO.ui.mixin.LabelElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {string[]|null} [accept=null] MIME types to accept. null accepts all types.
+ * @cfg {string} [placeholder] Text to display when no file is selected.
+ * @cfg {string} [notsupported] Text to display when file support is missing in the browser.
+ * @cfg {boolean} [droppable=true] Whether to accept files by drag and drop.
+ * @cfg {boolean} [showDropTarget=false] Whether to show a drop target. Requires droppable to be true.
+ * @cfg {boolean} [dragDropUI=false] Deprecated alias for showDropTarget
+ */
+OO.ui.SelectFileWidget = function OoUiSelectFileWidget( config ) {
+       var dragHandler;
+
+       // TODO: Remove in next release
+       if ( config && config.dragDropUI ) {
+               config.showDropTarget = true;
+       }
+
+       // Configuration initialization
+       config = $.extend( {
+               accept: null,
+               placeholder: OO.ui.msg( 'ooui-selectfile-placeholder' ),
+               notsupported: OO.ui.msg( 'ooui-selectfile-not-supported' ),
+               droppable: true,
+               showDropTarget: false
+       }, config );
+
+       // Parent constructor
+       OO.ui.SelectFileWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.IconElement.call( this, config );
+       OO.ui.mixin.IndicatorElement.call( this, config );
+       OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$info } ) );
+       OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { autoFitLabel: true } ) );
+
+       // Properties
+       this.$info = $( '<span>' );
+
+       // Properties
+       this.showDropTarget = config.showDropTarget;
+       this.isSupported = this.constructor.static.isSupported();
+       this.currentFile = null;
+       if ( Array.isArray( config.accept ) ) {
+               this.accept = config.accept;
+       } else {
+               this.accept = null;
+       }
+       this.placeholder = config.placeholder;
+       this.notsupported = config.notsupported;
+       this.onFileSelectedHandler = this.onFileSelected.bind( this );
+
+       this.selectButton = new OO.ui.ButtonWidget( {
+               classes: [ 'oo-ui-selectFileWidget-selectButton' ],
+               label: OO.ui.msg( 'ooui-selectfile-button-select' ),
+               disabled: this.disabled || !this.isSupported
+       } );
+
+       this.clearButton = new OO.ui.ButtonWidget( {
+               classes: [ 'oo-ui-selectFileWidget-clearButton' ],
+               framed: false,
+               icon: 'remove',
+               disabled: this.disabled
+       } );
+
+       // Events
+       this.selectButton.$button.on( {
+               keypress: this.onKeyPress.bind( this )
+       } );
+       this.clearButton.connect( this, {
+               click: 'onClearClick'
+       } );
+       if ( config.droppable ) {
+               dragHandler = this.onDragEnterOrOver.bind( this );
+               this.$element.on( {
+                       dragenter: dragHandler,
+                       dragover: dragHandler,
+                       dragleave: this.onDragLeave.bind( this ),
+                       drop: this.onDrop.bind( this )
+               } );
+       }
+
+       // Initialization
+       this.addInput();
+       this.updateUI();
+       this.$label.addClass( 'oo-ui-selectFileWidget-label' );
+       this.$info
+               .addClass( 'oo-ui-selectFileWidget-info' )
+               .append( this.$icon, this.$label, this.clearButton.$element, this.$indicator );
+       this.$element
+               .addClass( 'oo-ui-selectFileWidget' )
+               .append( this.$info, this.selectButton.$element );
+       if ( config.droppable && config.showDropTarget ) {
+               this.$dropTarget = $( '<div>' )
+                       .addClass( 'oo-ui-selectFileWidget-dropTarget' )
+                       .text( OO.ui.msg( 'ooui-selectfile-dragdrop-placeholder' ) )
+                       .on( {
+                               click: this.onDropTargetClick.bind( this )
+                       } );
+               this.$element.prepend( this.$dropTarget );
+       }
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.SelectFileWidget, OO.ui.Widget );
+OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.IconElement );
+OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.IndicatorElement );
+OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.PendingElement );
+OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.LabelElement );
+
+/* Static Properties */
+
+/**
+ * Check if this widget is supported
+ *
+ * @static
+ * @return {boolean}
+ */
+OO.ui.SelectFileWidget.static.isSupported = function () {
+       var $input;
+       if ( OO.ui.SelectFileWidget.static.isSupportedCache === null ) {
+               $input = $( '<input type="file">' );
+               OO.ui.SelectFileWidget.static.isSupportedCache = $input[ 0 ].files !== undefined;
+       }
+       return OO.ui.SelectFileWidget.static.isSupportedCache;
+};
+
+OO.ui.SelectFileWidget.static.isSupportedCache = null;
+
+/* Events */
+
+/**
+ * @event change
+ *
+ * A change event is emitted when the on/off state of the toggle changes.
+ *
+ * @param {File|null} value New value
+ */
+
+/* Methods */
+
+/**
+ * Get the current value of the field
+ *
+ * @return {File|null}
+ */
+OO.ui.SelectFileWidget.prototype.getValue = function () {
+       return this.currentFile;
+};
+
+/**
+ * Set the current value of the field
+ *
+ * @param {File|null} file File to select
+ */
+OO.ui.SelectFileWidget.prototype.setValue = function ( file ) {
+       if ( this.currentFile !== file ) {
+               this.currentFile = file;
+               this.updateUI();
+               this.emit( 'change', this.currentFile );
+       }
+};
+
+/**
+ * Focus the widget.
+ *
+ * Focusses the select file button.
+ *
+ * @chainable
+ */
+OO.ui.SelectFileWidget.prototype.focus = function () {
+       this.selectButton.$button[ 0 ].focus();
+       return this;
+};
+
+/**
+ * Update the user interface when a file is selected or unselected
+ *
+ * @protected
+ */
+OO.ui.SelectFileWidget.prototype.updateUI = function () {
+       var $label;
+       if ( !this.isSupported ) {
+               this.$element.addClass( 'oo-ui-selectFileWidget-notsupported' );
+               this.$element.removeClass( 'oo-ui-selectFileWidget-empty' );
+               this.setLabel( this.notsupported );
+       } else {
+               this.$element.addClass( 'oo-ui-selectFileWidget-supported' );
+               if ( this.currentFile ) {
+                       this.$element.removeClass( 'oo-ui-selectFileWidget-empty' );
+                       $label = $( [] );
+                       $label = $label.add(
+                               $( '<span>' )
+                                       .addClass( 'oo-ui-selectFileWidget-fileName' )
+                                       .text( this.currentFile.name )
+                       );
+                       if ( this.currentFile.type !== '' ) {
+                               $label = $label.add(
+                                       $( '<span>' )
+                                               .addClass( 'oo-ui-selectFileWidget-fileType' )
+                                               .text( this.currentFile.type )
+                               );
+                       }
+                       this.setLabel( $label );
+               } else {
+                       this.$element.addClass( 'oo-ui-selectFileWidget-empty' );
+                       this.setLabel( this.placeholder );
+               }
+       }
+};
+
+/**
+ * Add the input to the widget
+ *
+ * @private
+ */
+OO.ui.SelectFileWidget.prototype.addInput = function () {
+       if ( this.$input ) {
+               this.$input.remove();
+       }
+
+       if ( !this.isSupported ) {
+               this.$input = null;
+               return;
+       }
+
+       this.$input = $( '<input type="file">' );
+       this.$input.on( 'change', this.onFileSelectedHandler );
+       this.$input.attr( {
+               tabindex: -1
+       } );
+       if ( this.accept ) {
+               this.$input.attr( 'accept', this.accept.join( ', ' ) );
+       }
+       this.selectButton.$button.append( this.$input );
+};
+
+/**
+ * Determine if we should accept this file
+ *
+ * @private
+ * @param {string} File MIME type
+ * @return {boolean}
+ */
+OO.ui.SelectFileWidget.prototype.isAllowedType = function ( mimeType ) {
+       var i, mimeTest;
+
+       if ( !this.accept || !mimeType ) {
+               return true;
+       }
+
+       for ( i = 0; i < this.accept.length; i++ ) {
+               mimeTest = this.accept[ i ];
+               if ( mimeTest === mimeType ) {
+                       return true;
+               } else if ( mimeTest.substr( -2 ) === '/*' ) {
+                       mimeTest = mimeTest.substr( 0, mimeTest.length - 1 );
+                       if ( mimeType.substr( 0, mimeTest.length ) === mimeTest ) {
+                               return true;
+                       }
+               }
+       }
+
+       return false;
+};
+
+/**
+ * Handle file selection from the input
+ *
+ * @private
+ * @param {jQuery.Event} e
+ */
+OO.ui.SelectFileWidget.prototype.onFileSelected = function ( e ) {
+       var file = OO.getProp( e.target, 'files', 0 ) || null;
+
+       if ( file && !this.isAllowedType( file.type ) ) {
+               file = null;
+       }
+
+       this.setValue( file );
+       this.addInput();
+};
+
+/**
+ * Handle clear button click events.
+ *
+ * @private
+ */
+OO.ui.SelectFileWidget.prototype.onClearClick = function () {
+       this.setValue( null );
+       return false;
+};
+
+/**
+ * Handle key press events.
+ *
+ * @private
+ * @param {jQuery.Event} e Key press event
+ */
+OO.ui.SelectFileWidget.prototype.onKeyPress = function ( e ) {
+       if ( this.isSupported && !this.isDisabled() && this.$input &&
+               ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
+       ) {
+               this.$input.click();
+               return false;
+       }
+};
+
+/**
+ * Handle drop target click events.
+ *
+ * @private
+ * @param {jQuery.Event} e Key press event
+ */
+OO.ui.SelectFileWidget.prototype.onDropTargetClick = function () {
+       if ( this.isSupported && !this.isDisabled() && this.$input ) {
+               this.$input.click();
+               return false;
+       }
+};
+
+/**
+ * Handle drag enter and over events
+ *
+ * @private
+ * @param {jQuery.Event} e Drag event
+ */
+OO.ui.SelectFileWidget.prototype.onDragEnterOrOver = function ( e ) {
+       var itemOrFile,
+               droppableFile = false,
+               dt = e.originalEvent.dataTransfer;
+
+       e.preventDefault();
+       e.stopPropagation();
+
+       if ( this.isDisabled() || !this.isSupported ) {
+               this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
+               dt.dropEffect = 'none';
+               return false;
+       }
+
+       // DataTransferItem and File both have a type property, but in Chrome files
+       // have no information at this point.
+       itemOrFile = OO.getProp( dt, 'items', 0 ) || OO.getProp( dt, 'files', 0 );
+       if ( itemOrFile ) {
+               if ( this.isAllowedType( itemOrFile.type ) ) {
+                       droppableFile = true;
+               }
+       // dt.types is Array-like, but not an Array
+       } else if ( Array.prototype.indexOf.call( OO.getProp( dt, 'types' ) || [], 'Files' ) !== -1 ) {
+               // File information is not available at this point for security so just assume
+               // it is acceptable for now.
+               // https://bugzilla.mozilla.org/show_bug.cgi?id=640534
+               droppableFile = true;
+       }
+
+       this.$element.toggleClass( 'oo-ui-selectFileWidget-canDrop', droppableFile );
+       if ( !droppableFile ) {
+               dt.dropEffect = 'none';
+       }
+
+       return false;
+};
+
+/**
+ * Handle drag leave events
+ *
+ * @private
+ * @param {jQuery.Event} e Drag event
+ */
+OO.ui.SelectFileWidget.prototype.onDragLeave = function () {
+       this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
+};
+
+/**
+ * Handle drop events
+ *
+ * @private
+ * @param {jQuery.Event} e Drop event
+ */
+OO.ui.SelectFileWidget.prototype.onDrop = function ( e ) {
+       var file = null,
+               dt = e.originalEvent.dataTransfer;
+
+       e.preventDefault();
+       e.stopPropagation();
+       this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
+
+       if ( this.isDisabled() || !this.isSupported ) {
+               return false;
+       }
+
+       file = OO.getProp( dt, 'files', 0 );
+       if ( file && !this.isAllowedType( file.type ) ) {
+               file = null;
+       }
+       if ( file ) {
+               this.setValue( file );
+       }
+
+       return false;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.SelectFileWidget.prototype.setDisabled = function ( disabled ) {
+       OO.ui.SelectFileWidget.parent.prototype.setDisabled.call( this, disabled );
+       if ( this.selectButton ) {
+               this.selectButton.setDisabled( disabled );
+       }
+       if ( this.clearButton ) {
+               this.clearButton.setDisabled( disabled );
+       }
+       return this;
+};
+
+/**
+ * Progress bars visually display the status of an operation, such as a download,
+ * and can be either determinate or indeterminate:
+ *
+ * - **determinate** process bars show the percent of an operation that is complete.
+ *
+ * - **indeterminate** process bars use a visual display of motion to indicate that an operation
+ *   is taking place. Because the extent of an indeterminate operation is unknown, the bar does
+ *   not use percentages.
+ *
+ * The value of the `progress` configuration determines whether the bar is determinate or indeterminate.
+ *
+ *     @example
+ *     // Examples of determinate and indeterminate progress bars.
+ *     var progressBar1 = new OO.ui.ProgressBarWidget( {
+ *         progress: 33
+ *     } );
+ *     var progressBar2 = new OO.ui.ProgressBarWidget();
+ *
+ *     // Create a FieldsetLayout to layout progress bars
+ *     var fieldset = new OO.ui.FieldsetLayout;
+ *     fieldset.addItems( [
+ *        new OO.ui.FieldLayout( progressBar1, {label: 'Determinate', align: 'top'}),
+ *        new OO.ui.FieldLayout( progressBar2, {label: 'Indeterminate', align: 'top'})
+ *     ] );
+ *     $( 'body' ).append( fieldset.$element );
+ *
+ * @class
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
+ *  To create a determinate progress bar, specify a number that reflects the initial percent complete.
+ *  By default, the progress bar is indeterminate.
+ */
+OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.ProgressBarWidget.parent.call( this, config );
+
+       // Properties
+       this.$bar = $( '<div>' );
+       this.progress = null;
+
+       // Initialization
+       this.setProgress( config.progress !== undefined ? config.progress : false );
+       this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
+       this.$element
+               .attr( {
+                       role: 'progressbar',
+                       'aria-valuemin': 0,
+                       'aria-valuemax': 100
+               } )
+               .addClass( 'oo-ui-progressBarWidget' )
+               .append( this.$bar );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
+
+/* Static Properties */
+
+OO.ui.ProgressBarWidget.static.tagName = 'div';
+
+/* Methods */
+
+/**
+ * Get the percent of the progress that has been completed. Indeterminate progresses will return `false`.
+ *
+ * @return {number|boolean} Progress percent
+ */
+OO.ui.ProgressBarWidget.prototype.getProgress = function () {
+       return this.progress;
+};
+
+/**
+ * Set the percent of the process completed or `false` for an indeterminate process.
+ *
+ * @param {number|boolean} progress Progress percent or `false` for indeterminate
+ */
+OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
+       this.progress = progress;
+
+       if ( progress !== false ) {
+               this.$bar.css( 'width', this.progress + '%' );
+               this.$element.attr( 'aria-valuenow', this.progress );
+       } else {
+               this.$bar.css( 'width', '' );
+               this.$element.removeAttr( 'aria-valuenow' );
+       }
+       this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', !progress );
+};
+
+/**
+ * SearchWidgets combine a {@link OO.ui.TextInputWidget text input field}, where users can type a search query,
+ * and a menu of search results, which is displayed beneath the query
+ * field. Unlike {@link OO.ui.mixin.LookupElement lookup menus}, search result menus are always visible to the user.
+ * Users can choose an item from the menu or type a query into the text field to search for a matching result item.
+ * In general, search widgets are used inside a separate {@link OO.ui.Dialog dialog} window.
+ *
+ * Each time the query is changed, the search result menu is cleared and repopulated. Please see
+ * the [OOjs UI demos][1] for an example.
+ *
+ * [1]: https://tools.wmflabs.org/oojs-ui/oojs-ui/demos/#dialogs-mediawiki-vector-ltr
+ *
+ * @class
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {string|jQuery} [placeholder] Placeholder text for query input
+ * @cfg {string} [value] Initial query value
+ */
+OO.ui.SearchWidget = function OoUiSearchWidget( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.SearchWidget.parent.call( this, config );
+
+       // Properties
+       this.query = new OO.ui.TextInputWidget( {
+               icon: 'search',
+               placeholder: config.placeholder,
+               value: config.value
+       } );
+       this.results = new OO.ui.SelectWidget();
+       this.$query = $( '<div>' );
+       this.$results = $( '<div>' );
+
+       // Events
+       this.query.connect( this, {
+               change: 'onQueryChange',
+               enter: 'onQueryEnter'
+       } );
+       this.query.$input.on( 'keydown', this.onQueryKeydown.bind( this ) );
+
+       // Initialization
+       this.$query
+               .addClass( 'oo-ui-searchWidget-query' )
+               .append( this.query.$element );
+       this.$results
+               .addClass( 'oo-ui-searchWidget-results' )
+               .append( this.results.$element );
+       this.$element
+               .addClass( 'oo-ui-searchWidget' )
+               .append( this.$results, this.$query );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.SearchWidget, OO.ui.Widget );
+
+/* Methods */
+
+/**
+ * Handle query key down events.
+ *
+ * @private
+ * @param {jQuery.Event} e Key down event
+ */
+OO.ui.SearchWidget.prototype.onQueryKeydown = function ( e ) {
+       var highlightedItem, nextItem,
+               dir = e.which === OO.ui.Keys.DOWN ? 1 : ( e.which === OO.ui.Keys.UP ? -1 : 0 );
+
+       if ( dir ) {
+               highlightedItem = this.results.getHighlightedItem();
+               if ( !highlightedItem ) {
+                       highlightedItem = this.results.getSelectedItem();
+               }
+               nextItem = this.results.getRelativeSelectableItem( highlightedItem, dir );
+               this.results.highlightItem( nextItem );
+               nextItem.scrollElementIntoView();
+       }
+};
+
+/**
+ * Handle select widget select events.
+ *
+ * Clears existing results. Subclasses should repopulate items according to new query.
+ *
+ * @private
+ * @param {string} value New value
+ */
+OO.ui.SearchWidget.prototype.onQueryChange = function () {
+       // Reset
+       this.results.clearItems();
+};
+
+/**
+ * Handle select widget enter key events.
+ *
+ * Chooses highlighted item.
+ *
+ * @private
+ * @param {string} value New value
+ */
+OO.ui.SearchWidget.prototype.onQueryEnter = function () {
+       var highlightedItem = this.results.getHighlightedItem();
+       if ( highlightedItem ) {
+               this.results.chooseItem( highlightedItem );
+       }
+};
+
+/**
+ * Get the query input.
+ *
+ * @return {OO.ui.TextInputWidget} Query input
+ */
+OO.ui.SearchWidget.prototype.getQuery = function () {
+       return this.query;
+};
+
+/**
+ * Get the search results menu.
+ *
+ * @return {OO.ui.SelectWidget} Menu of search results
+ */
+OO.ui.SearchWidget.prototype.getResults = function () {
+       return this.results;
+};
+
+/**
+ * NumberInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
+ * can be entered manually) and two {@link OO.ui.ButtonWidget button widgets}
+ * (to adjust the value in increments) to allow the user to enter a number.
+ *
+ *     @example
+ *     // Example: A NumberInputWidget.
+ *     var numberInput = new OO.ui.NumberInputWidget( {
+ *         label: 'NumberInputWidget',
+ *         input: { value: 5, min: 1, max: 10 }
+ *     } );
+ *     $( 'body' ).append( numberInput.$element );
+ *
+ * @class
+ * @extends OO.ui.Widget
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {Object} [input] Configuration options to pass to the {@link OO.ui.TextInputWidget text input widget}.
+ * @cfg {Object} [minusButton] Configuration options to pass to the {@link OO.ui.ButtonWidget decrementing button widget}.
+ * @cfg {Object} [plusButton] Configuration options to pass to the {@link OO.ui.ButtonWidget incrementing button widget}.
+ * @cfg {boolean} [isInteger=false] Whether the field accepts only integer values.
+ * @cfg {number} [min=-Infinity] Minimum allowed value
+ * @cfg {number} [max=Infinity] Maximum allowed value
+ * @cfg {number} [step=1] Delta when using the buttons or up/down arrow keys
+ * @cfg {number|null} [pageStep] Delta when using the page-up/page-down keys. Defaults to 10 times #step.
+ */
+OO.ui.NumberInputWidget = function OoUiNumberInputWidget( config ) {
+       // Configuration initialization
+       config = $.extend( {
+               isInteger: false,
+               min: -Infinity,
+               max: Infinity,
+               step: 1,
+               pageStep: null
+       }, config );
+
+       // Parent constructor
+       OO.ui.NumberInputWidget.parent.call( this, config );
+
+       // Properties
+       this.input = new OO.ui.TextInputWidget( $.extend(
+               {
+                       disabled: this.isDisabled()
+               },
+               config.input
+       ) );
+       this.minusButton = new OO.ui.ButtonWidget( $.extend(
+               {
+                       disabled: this.isDisabled(),
+                       tabIndex: -1
+               },
+               config.minusButton,
+               {
+                       classes: [ 'oo-ui-numberInputWidget-minusButton' ],
+                       label: '−'
+               }
+       ) );
+       this.plusButton = new OO.ui.ButtonWidget( $.extend(
+               {
+                       disabled: this.isDisabled(),
+                       tabIndex: -1
+               },
+               config.plusButton,
+               {
+                       classes: [ 'oo-ui-numberInputWidget-plusButton' ],
+                       label: '+'
+               }
+       ) );
+
+       // Events
+       this.input.connect( this, {
+               change: this.emit.bind( this, 'change' ),
+               enter: this.emit.bind( this, 'enter' )
+       } );
+       this.input.$input.on( {
+               keydown: this.onKeyDown.bind( this ),
+               'wheel mousewheel DOMMouseScroll': this.onWheel.bind( this )
+       } );
+       this.plusButton.connect( this, {
+               click: [ 'onButtonClick', +1 ]
+       } );
+       this.minusButton.connect( this, {
+               click: [ 'onButtonClick', -1 ]
+       } );
+
+       // Initialization
+       this.setIsInteger( !!config.isInteger );
+       this.setRange( config.min, config.max );
+       this.setStep( config.step, config.pageStep );
+
+       this.$field = $( '<div>' ).addClass( 'oo-ui-numberInputWidget-field' )
+               .append(
+                       this.minusButton.$element,
+                       this.input.$element,
+                       this.plusButton.$element
+               );
+       this.$element.addClass( 'oo-ui-numberInputWidget' ).append( this.$field );
+       this.input.setValidation( this.validateNumber.bind( this ) );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.NumberInputWidget, OO.ui.Widget );
+
+/* Events */
+
+/**
+ * A `change` event is emitted when the value of the input changes.
+ *
+ * @event change
+ */
+
+/**
+ * An `enter` event is emitted when the user presses 'enter' inside the text box.
+ *
+ * @event enter
+ */
+
+/* Methods */
+
+/**
+ * Set whether only integers are allowed
+ * @param {boolean} flag
+ */
+OO.ui.NumberInputWidget.prototype.setIsInteger = function ( flag ) {
+       this.isInteger = !!flag;
+       this.input.setValidityFlag();
+};
+
+/**
+ * Get whether only integers are allowed
+ * @return {boolean} Flag value
+ */
+OO.ui.NumberInputWidget.prototype.getIsInteger = function () {
+       return this.isInteger;
+};
+
+/**
+ * Set the range of allowed values
+ * @param {number} min Minimum allowed value
+ * @param {number} max Maximum allowed value
+ */
+OO.ui.NumberInputWidget.prototype.setRange = function ( min, max ) {
+       if ( min > max ) {
+               throw new Error( 'Minimum (' + min + ') must not be greater than maximum (' + max + ')' );
+       }
+       this.min = min;
+       this.max = max;
+       this.input.setValidityFlag();
+};
+
+/**
+ * Get the current range
+ * @return {number[]} Minimum and maximum values
+ */
+OO.ui.NumberInputWidget.prototype.getRange = function () {
+       return [ this.min, this.max ];
+};
+
+/**
+ * Set the stepping deltas
+ * @param {number} step Normal step
+ * @param {number|null} pageStep Page step. If null, 10 * step will be used.
+ */
+OO.ui.NumberInputWidget.prototype.setStep = function ( step, pageStep ) {
+       if ( step <= 0 ) {
+               throw new Error( 'Step value must be positive' );
+       }
+       if ( pageStep === null ) {
+               pageStep = step * 10;
+       } else if ( pageStep <= 0 ) {
+               throw new Error( 'Page step value must be positive' );
+       }
+       this.step = step;
+       this.pageStep = pageStep;
+};
+
+/**
+ * Get the current stepping values
+ * @return {number[]} Step and page step
+ */
+OO.ui.NumberInputWidget.prototype.getStep = function () {
+       return [ this.step, this.pageStep ];
+};
+
+/**
+ * Get the current value of the widget
+ * @return {string}
+ */
+OO.ui.NumberInputWidget.prototype.getValue = function () {
+       return this.input.getValue();
+};
+
+/**
+ * Get the current value of the widget as a number
+ * @return {number} May be NaN, or an invalid number
+ */
+OO.ui.NumberInputWidget.prototype.getNumericValue = function () {
+       return +this.input.getValue();
+};
+
+/**
+ * Set the value of the widget
+ * @param {string} value Invalid values are allowed
+ */
+OO.ui.NumberInputWidget.prototype.setValue = function ( value ) {
+       this.input.setValue( value );
+};
+
+/**
+ * Adjust the value of the widget
+ * @param {number} delta Adjustment amount
+ */
+OO.ui.NumberInputWidget.prototype.adjustValue = function ( delta ) {
+       var n, v = this.getNumericValue();
+
+       delta = +delta;
+       if ( isNaN( delta ) || !isFinite( delta ) ) {
+               throw new Error( 'Delta must be a finite number' );
+       }
+
+       if ( isNaN( v ) ) {
+               n = 0;
+       } else {
+               n = v + delta;
+               n = Math.max( Math.min( n, this.max ), this.min );
+               if ( this.isInteger ) {
+                       n = Math.round( n );
+               }
+       }
+
+       if ( n !== v ) {
+               this.setValue( n );
+       }
+};
+
+/**
+ * Validate input
+ * @private
+ * @param {string} value Field value
+ * @return {boolean}
+ */
+OO.ui.NumberInputWidget.prototype.validateNumber = function ( value ) {
+       var n = +value;
+       if ( isNaN( n ) || !isFinite( n ) ) {
+               return false;
+       }
+
+       /*jshint bitwise: false */
+       if ( this.isInteger && ( n | 0 ) !== n ) {
+               return false;
+       }
+       /*jshint bitwise: true */
+
+       if ( n < this.min || n > this.max ) {
+               return false;
+       }
+
+       return true;
+};
+
+/**
+ * Handle mouse click events.
+ *
+ * @private
+ * @param {number} dir +1 or -1
+ */
+OO.ui.NumberInputWidget.prototype.onButtonClick = function ( dir ) {
+       this.adjustValue( dir * this.step );
+};
+
+/**
+ * Handle mouse wheel events.
+ *
+ * @private
+ * @param {jQuery.Event} event
+ */
+OO.ui.NumberInputWidget.prototype.onWheel = function ( event ) {
+       var delta = 0;
+
+       // Standard 'wheel' event
+       if ( event.originalEvent.deltaMode !== undefined ) {
+               this.sawWheelEvent = true;
+       }
+       if ( event.originalEvent.deltaY ) {
+               delta = -event.originalEvent.deltaY;
+       } else if ( event.originalEvent.deltaX ) {
+               delta = event.originalEvent.deltaX;
+       }
+
+       // Non-standard events
+       if ( !this.sawWheelEvent ) {
+               if ( event.originalEvent.wheelDeltaX ) {
+                       delta = -event.originalEvent.wheelDeltaX;
+               } else if ( event.originalEvent.wheelDeltaY ) {
+                       delta = event.originalEvent.wheelDeltaY;
+               } else if ( event.originalEvent.wheelDelta ) {
+                       delta = event.originalEvent.wheelDelta;
+               } else if ( event.originalEvent.detail ) {
+                       delta = -event.originalEvent.detail;
+               }
+       }
+
+       if ( delta ) {
+               delta = delta < 0 ? -1 : 1;
+               this.adjustValue( delta * this.step );
+       }
+
+       return false;
+};
+
+/**
+ * Handle key down events.
+ *
+ * @private
+ * @param {jQuery.Event} e Key down event
+ */
+OO.ui.NumberInputWidget.prototype.onKeyDown = function ( e ) {
+       if ( !this.isDisabled() ) {
+               switch ( e.which ) {
+                       case OO.ui.Keys.UP:
+                               this.adjustValue( this.step );
+                               return false;
+                       case OO.ui.Keys.DOWN:
+                               this.adjustValue( -this.step );
+                               return false;
+                       case OO.ui.Keys.PAGEUP:
+                               this.adjustValue( this.pageStep );
+                               return false;
+                       case OO.ui.Keys.PAGEDOWN:
+                               this.adjustValue( -this.pageStep );
+                               return false;
+               }
+       }
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.NumberInputWidget.prototype.setDisabled = function ( disabled ) {
+       // Parent method
+       OO.ui.NumberInputWidget.parent.prototype.setDisabled.call( this, disabled );
+
+       if ( this.input ) {
+               this.input.setDisabled( this.isDisabled() );
+       }
+       if ( this.minusButton ) {
+               this.minusButton.setDisabled( this.isDisabled() );
+       }
+       if ( this.plusButton ) {
+               this.plusButton.setDisabled( this.isDisabled() );
+       }
+
+       return this;
+};
+
+}( OO ) );
diff --git a/resources/lib/oojs-ui/oojs-ui-windows-apex.css b/resources/lib/oojs-ui/oojs-ui-windows-apex.css
new file mode 100644 (file)
index 0000000..788b70d
--- /dev/null
@@ -0,0 +1,449 @@
+/*!
+ * OOjs UI v0.15.2
+ * https://www.mediawiki.org/wiki/OOjs_UI
+ *
+ * Copyright 2011–2016 OOjs UI Team and other contributors.
+ * Released under the MIT license
+ * http://oojs.mit-license.org
+ *
+ * Date: 2016-02-02T22:07:06Z
+ */
+@-webkit-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@-moz-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@-ms-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@-o-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+.oo-ui-actionWidget.oo-ui-pendingElement-pending {
+       background-image: /* @embed */ url(themes/apex/images/textures/pending.gif);
+}
+.oo-ui-window {
+       background-color: transparent;
+       background-image: none;
+}
+.oo-ui-window-frame {
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-window-content:focus {
+       outline: none;
+}
+.oo-ui-window-head,
+.oo-ui-window-foot {
+       -webkit-touch-callout: none;
+       -webkit-user-select: none;
+          -moz-user-select: none;
+           -ms-user-select: none;
+               user-select: none;
+}
+.oo-ui-window-body {
+       margin: 0;
+       padding: 0;
+       background: none;
+}
+.oo-ui-window-overlay {
+       position: absolute;
+       top: 0;
+       /* @noflip */
+       left: 0;
+}
+.oo-ui-dialog-content > .oo-ui-window-head,
+.oo-ui-dialog-content > .oo-ui-window-body,
+.oo-ui-dialog-content > .oo-ui-window-foot {
+       position: absolute;
+       left: 0;
+       right: 0;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-dialog-content > .oo-ui-window-head {
+       overflow: hidden;
+       z-index: 1;
+       top: 0;
+}
+.oo-ui-dialog-content > .oo-ui-window-body {
+       overflow: auto;
+       z-index: 2;
+       top: 0;
+       bottom: 0;
+}
+.oo-ui-dialog-content > .oo-ui-window-foot {
+       overflow: hidden;
+       z-index: 1;
+       bottom: 0;
+}
+.oo-ui-dialog-content > .oo-ui-window-body {
+       box-shadow: 0 0 0.66em rgba(0, 0, 0, 0.25);
+}
+.oo-ui-messageDialog-actions-horizontal {
+       display: table;
+       table-layout: fixed;
+       width: 100%;
+}
+.oo-ui-messageDialog-actions-horizontal .oo-ui-actionWidget {
+       display: table-cell;
+       width: 1%;
+}
+.oo-ui-messageDialog-actions-vertical {
+       display: block;
+}
+.oo-ui-messageDialog-actions-vertical .oo-ui-actionWidget {
+       display: block;
+       overflow: hidden;
+       text-overflow: ellipsis;
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget {
+       position: relative;
+       text-align: center;
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget .oo-ui-buttonElement-button {
+       display: block;
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget .oo-ui-labelElement-label {
+       position: relative;
+       top: auto;
+       bottom: auto;
+       display: inline;
+       white-space: nowrap;
+}
+.oo-ui-messageDialog-content .oo-ui-window-body {
+       box-shadow: 0 0 0.33em rgba(0, 0, 0, 0.33);
+}
+.oo-ui-messageDialog-title,
+.oo-ui-messageDialog-message {
+       display: block;
+       text-align: center;
+}
+.oo-ui-messageDialog-title.oo-ui-labelElement,
+.oo-ui-messageDialog-message.oo-ui-labelElement {
+       padding-top: 0.5em;
+}
+.oo-ui-messageDialog-title {
+       font-size: 1.5em;
+       line-height: 1em;
+       color: #000000;
+}
+.oo-ui-messageDialog-message {
+       font-size: 0.9em;
+       line-height: 1.25em;
+       color: #666666;
+}
+.oo-ui-messageDialog-message-verbose {
+       font-size: 1.1em;
+       line-height: 1.5em;
+       text-align: left;
+}
+.oo-ui-messageDialog-actions-horizontal .oo-ui-actionWidget {
+       border-right: 1px solid #e5e5e5;
+       margin: 0;
+}
+.oo-ui-messageDialog-actions-horizontal .oo-ui-actionWidget:last-child {
+       border-right-width: 0;
+}
+.oo-ui-messageDialog-actions-vertical .oo-ui-actionWidget {
+       border-bottom: 1px solid #e5e5e5;
+       margin: 0;
+}
+.oo-ui-messageDialog-actions-vertical .oo-ui-actionWidget:last-child {
+       border-bottom-width: 0;
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget {
+       height: 3.4em;
+       margin-right: 0;
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-labelElement .oo-ui-labelElement-label {
+       text-align: center;
+       line-height: 3.4em;
+       padding: 0 2em;
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget:hover {
+       background-color: rgba(0, 0, 0, 0.05);
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget:active {
+       background-color: rgba(0, 0, 0, 0.1);
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-progressive:hover {
+       background-color: rgba(8, 126, 204, 0.05);
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-progressive:active {
+       background-color: rgba(8, 126, 204, 0.1);
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-progressive .oo-ui-labelElement-label {
+       font-weight: bold;
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-constructive:hover {
+       background-color: rgba(118, 171, 54, 0.05);
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-constructive:active {
+       background-color: rgba(118, 171, 54, 0.1);
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-destructive:hover {
+       background-color: rgba(212, 83, 83, 0.05);
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-destructive:active {
+       background-color: rgba(212, 83, 83, 0.1);
+}
+.oo-ui-processDialog-location {
+       overflow: hidden;
+       text-overflow: ellipsis;
+       white-space: nowrap;
+}
+.oo-ui-processDialog-title {
+       display: inline;
+       padding: 0;
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget,
+.oo-ui-processDialog-actions-other .oo-ui-actionWidget {
+       white-space: nowrap;
+}
+.oo-ui-processDialog-actions-safe,
+.oo-ui-processDialog-actions-primary {
+       position: absolute;
+       top: 0;
+       bottom: 0;
+}
+.oo-ui-processDialog-actions-safe {
+       left: 0;
+}
+.oo-ui-processDialog-actions-primary {
+       right: 0;
+}
+.oo-ui-processDialog-errors {
+       position: absolute;
+       top: 0;
+       left: 0;
+       right: 0;
+       bottom: 0;
+       z-index: 2;
+       overflow-x: hidden;
+       overflow-y: auto;
+}
+.oo-ui-processDialog-content .oo-ui-window-head {
+       height: 3.4em;
+}
+.oo-ui-processDialog-content .oo-ui-window-body {
+       top: 3.4em;
+       box-shadow: 0 0 0.33em rgba(0, 0, 0, 0.33);
+}
+.oo-ui-processDialog-navigation {
+       position: relative;
+       height: 3.4em;
+       padding: 0 1em;
+}
+.oo-ui-processDialog-location {
+       padding: 0.75em 0;
+       height: 1.875em;
+       cursor: default;
+       text-align: center;
+}
+.oo-ui-processDialog-title {
+       font-weight: bold;
+       line-height: 1.875em;
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget .oo-ui-buttonElement-button,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget .oo-ui-buttonElement-button,
+.oo-ui-processDialog-actions-other .oo-ui-actionWidget .oo-ui-buttonElement-button {
+       min-width: 1.875em;
+       min-height: 1.875em;
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget .oo-ui-labelElement-label,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget .oo-ui-labelElement-label,
+.oo-ui-processDialog-actions-other .oo-ui-actionWidget .oo-ui-labelElement-label {
+       line-height: 1.875em;
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-iconElement .oo-ui-iconElement-icon,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-iconElement .oo-ui-iconElement-icon,
+.oo-ui-processDialog-actions-other .oo-ui-actionWidget.oo-ui-iconElement .oo-ui-iconElement-icon {
+       margin-top: -0.125em;
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-framed,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-framed,
+.oo-ui-processDialog-actions-other .oo-ui-actionWidget.oo-ui-buttonElement-framed {
+       margin: 0.75em;
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-framed .oo-ui-buttonElement-button,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-framed .oo-ui-buttonElement-button,
+.oo-ui-processDialog-actions-other .oo-ui-actionWidget.oo-ui-buttonElement-framed .oo-ui-buttonElement-button {
+       padding: 0 1em;
+       vertical-align: middle;
+       margin: -1px;
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless,
+.oo-ui-processDialog-actions-other .oo-ui-actionWidget.oo-ui-buttonElement-frameless {
+       margin: 0;
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless .oo-ui-buttonElement-button,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless .oo-ui-buttonElement-button,
+.oo-ui-processDialog-actions-other .oo-ui-actionWidget.oo-ui-buttonElement-frameless .oo-ui-buttonElement-button {
+       padding: 0.75em 1em;
+       vertical-align: middle;
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget:hover,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget:hover {
+       background-color: rgba(0, 0, 0, 0.05);
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget:active,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget:active {
+       background-color: rgba(0, 0, 0, 0.1);
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-flaggedElement-progressive:hover,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-flaggedElement-progressive:hover {
+       background-color: rgba(8, 126, 204, 0.05);
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-flaggedElement-progressive:active,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-flaggedElement-progressive:active {
+       background-color: rgba(8, 126, 204, 0.1);
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-flaggedElement-progressive .oo-ui-labelElement-label,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-flaggedElement-progressive .oo-ui-labelElement-label {
+       font-weight: bold;
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-flaggedElement-constructive:hover,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-flaggedElement-constructive:hover {
+       background-color: rgba(118, 171, 54, 0.05);
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-flaggedElement-constructive:active,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-flaggedElement-constructive:active {
+       background-color: rgba(118, 171, 54, 0.1);
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-flaggedElement-destructive:hover,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-flaggedElement-destructive:hover {
+       background-color: rgba(212, 83, 83, 0.05);
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-flaggedElement-destructive:active,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-flaggedElement-destructive:active {
+       background-color: rgba(212, 83, 83, 0.1);
+}
+.oo-ui-processDialog-actions-other .oo-ui-actionWidget.oo-ui-buttonElement {
+       margin-right: 0;
+}
+.oo-ui-processDialog > .oo-ui-window-frame {
+       min-height: 5em;
+}
+.oo-ui-processDialog-errors {
+       background-color: rgba(255, 255, 255, 0.9);
+       padding: 3em 3em 1.5em 3em;
+       text-align: center;
+}
+.oo-ui-processDialog-errors .oo-ui-buttonWidget {
+       margin: 2em 1em 2em 1em;
+}
+.oo-ui-processDialog-errors-title {
+       font-size: 1.5em;
+       color: #000000;
+       margin-bottom: 2em;
+}
+.oo-ui-processDialog-error {
+       text-align: left;
+       margin: 1em;
+       padding: 1em;
+       border: 1px solid #ff9e9e;
+       background-color: #fff7f7;
+       border-radius: 0.25em;
+}
+.oo-ui-windowManager-modal > .oo-ui-dialog {
+       position: fixed;
+       width: 0;
+       height: 0;
+       overflow: hidden;
+}
+.oo-ui-windowManager-modal > .oo-ui-dialog.oo-ui-window-active {
+       width: auto;
+       height: auto;
+       top: 0;
+       right: 0;
+       bottom: 0;
+       left: 0;
+       padding: 1em;
+}
+.oo-ui-windowManager-modal > .oo-ui-dialog.oo-ui-window-setup > .oo-ui-window-frame {
+       position: absolute;
+       right: 0;
+       left: 0;
+       margin: auto;
+       overflow: hidden;
+       max-width: 100%;
+       max-height: 100%;
+}
+.oo-ui-windowManager-fullscreen > .oo-ui-dialog > .oo-ui-window-frame {
+       width: 100%;
+       height: 100%;
+       top: 0;
+       bottom: 0;
+}
+.oo-ui-windowManager-modal > .oo-ui-dialog {
+       background-color: rgba(255, 255, 255, 0.5);
+       opacity: 0;
+       -webkit-transition: opacity 250ms ease;
+          -moz-transition: opacity 250ms ease;
+               transition: opacity 250ms ease;
+}
+.oo-ui-windowManager-modal > .oo-ui-dialog > .oo-ui-window-frame {
+       background-color: #ffffff;
+       opacity: 0;
+       -webkit-transform: scale(0.5);
+          -moz-transform: scale(0.5);
+           -ms-transform: scale(0.5);
+               transform: scale(0.5);
+       -webkit-transition: all 250ms ease;
+          -moz-transition: all 250ms ease;
+               transition: all 250ms ease;
+}
+.oo-ui-windowManager-modal > .oo-ui-dialog.oo-ui-window-setup {
+       opacity: 1;
+}
+.oo-ui-windowManager-modal > .oo-ui-dialog.oo-ui-window-ready > .oo-ui-window-frame {
+       opacity: 1;
+       -webkit-transform: scale(1);
+          -moz-transform: scale(1);
+           -ms-transform: scale(1);
+               transform: scale(1);
+}
+.oo-ui-windowManager-modal.oo-ui-windowManager-floating > .oo-ui-dialog > .oo-ui-window-frame {
+       top: 1em;
+       bottom: 1em;
+       border: 1px solid #cccccc;
+       border-radius: 0.5em;
+       box-shadow: 0 0.2em 1em rgba(0, 0, 0, 0.3);
+}
diff --git a/resources/lib/oojs-ui/oojs-ui-windows-mediawiki.css b/resources/lib/oojs-ui/oojs-ui-windows-mediawiki.css
new file mode 100644 (file)
index 0000000..ead41ca
--- /dev/null
@@ -0,0 +1,408 @@
+/*!
+ * OOjs UI v0.15.2
+ * https://www.mediawiki.org/wiki/OOjs_UI
+ *
+ * Copyright 2011–2016 OOjs UI Team and other contributors.
+ * Released under the MIT license
+ * http://oojs.mit-license.org
+ *
+ * Date: 2016-02-02T22:07:06Z
+ */
+@-webkit-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@-moz-keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+@keyframes oo-ui-progressBarWidget-slide {
+       from {
+               margin-left: -40%;
+       }
+       to {
+               margin-left: 100%;
+       }
+}
+.oo-ui-window {
+       background: transparent;
+}
+.oo-ui-window-frame {
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-window-content:focus {
+       outline: none;
+}
+.oo-ui-window-head,
+.oo-ui-window-foot {
+       -webkit-touch-callout: none;
+       -webkit-user-select: none;
+          -moz-user-select: none;
+           -ms-user-select: none;
+               user-select: none;
+}
+.oo-ui-window-body {
+       margin: 0;
+       padding: 0;
+       background: none;
+}
+.oo-ui-window-overlay {
+       position: absolute;
+       top: 0;
+       /* @noflip */
+       left: 0;
+}
+.oo-ui-dialog-content > .oo-ui-window-head,
+.oo-ui-dialog-content > .oo-ui-window-body,
+.oo-ui-dialog-content > .oo-ui-window-foot {
+       position: absolute;
+       left: 0;
+       right: 0;
+       -webkit-box-sizing: border-box;
+          -moz-box-sizing: border-box;
+               box-sizing: border-box;
+}
+.oo-ui-dialog-content > .oo-ui-window-head {
+       overflow: hidden;
+       z-index: 1;
+       top: 0;
+}
+.oo-ui-dialog-content > .oo-ui-window-body {
+       overflow: auto;
+       z-index: 2;
+       top: 0;
+       bottom: 0;
+}
+.oo-ui-dialog-content > .oo-ui-window-foot {
+       overflow: hidden;
+       z-index: 1;
+       bottom: 0;
+}
+.oo-ui-dialog-content > .oo-ui-window-body {
+       outline: 1px solid #aaaaaa;
+}
+.oo-ui-messageDialog-actions-horizontal {
+       display: table;
+       table-layout: fixed;
+       width: 100%;
+}
+.oo-ui-messageDialog-actions-horizontal .oo-ui-actionWidget {
+       display: table-cell;
+       width: 1%;
+}
+.oo-ui-messageDialog-actions-vertical {
+       display: block;
+}
+.oo-ui-messageDialog-actions-vertical .oo-ui-actionWidget {
+       display: block;
+       overflow: hidden;
+       text-overflow: ellipsis;
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget {
+       position: relative;
+       text-align: center;
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget .oo-ui-buttonElement-button {
+       display: block;
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget .oo-ui-labelElement-label {
+       position: relative;
+       top: auto;
+       bottom: auto;
+       display: inline;
+       white-space: nowrap;
+}
+.oo-ui-messageDialog-title,
+.oo-ui-messageDialog-message {
+       display: block;
+       text-align: center;
+}
+.oo-ui-messageDialog-title.oo-ui-labelElement,
+.oo-ui-messageDialog-message.oo-ui-labelElement {
+       padding-top: 0.5em;
+}
+.oo-ui-messageDialog-title {
+       font-size: 1.5em;
+       line-height: 1em;
+       color: #000000;
+}
+.oo-ui-messageDialog-message {
+       font-size: 0.9em;
+       line-height: 1.25em;
+       color: #555555;
+}
+.oo-ui-messageDialog-message-verbose {
+       font-size: 1.1em;
+       line-height: 1.5em;
+       text-align: left;
+}
+.oo-ui-messageDialog-actions-horizontal .oo-ui-actionWidget {
+       border-right: 1px solid #e5e5e5;
+       margin: 0;
+}
+.oo-ui-messageDialog-actions-horizontal .oo-ui-actionWidget:last-child {
+       border-right-width: 0;
+}
+.oo-ui-messageDialog-actions-vertical .oo-ui-actionWidget {
+       border-bottom: 1px solid #e5e5e5;
+       margin: 0;
+}
+.oo-ui-messageDialog-actions-vertical .oo-ui-actionWidget:last-child {
+       border-bottom-width: 0;
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget {
+       height: 3.4em;
+       margin-right: 0;
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget:last-child {
+       margin-right: 0;
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-labelElement .oo-ui-labelElement-label {
+       text-align: center;
+       line-height: 3.4em;
+       padding: 0 2em;
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget:hover {
+       background-color: rgba(0, 0, 0, 0.05);
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget:active {
+       background-color: rgba(0, 0, 0, 0.1);
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-progressive:hover {
+       background-color: rgba(8, 126, 204, 0.05);
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-progressive:active {
+       background-color: rgba(8, 126, 204, 0.1);
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-progressive .oo-ui-labelElement-label {
+       font-weight: bold;
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-constructive:hover {
+       background-color: rgba(118, 171, 54, 0.05);
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-constructive:active {
+       background-color: rgba(118, 171, 54, 0.1);
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-destructive:hover {
+       background-color: rgba(212, 83, 83, 0.05);
+}
+.oo-ui-messageDialog-actions .oo-ui-actionWidget.oo-ui-flaggedElement-destructive:active {
+       background-color: rgba(212, 83, 83, 0.1);
+}
+.oo-ui-processDialog-location {
+       overflow: hidden;
+       text-overflow: ellipsis;
+       white-space: nowrap;
+}
+.oo-ui-processDialog-title {
+       display: inline;
+       padding: 0;
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget,
+.oo-ui-processDialog-actions-other .oo-ui-actionWidget {
+       white-space: nowrap;
+}
+.oo-ui-processDialog-actions-safe,
+.oo-ui-processDialog-actions-primary {
+       position: absolute;
+       top: 0;
+       bottom: 0;
+}
+.oo-ui-processDialog-actions-safe {
+       left: 0;
+}
+.oo-ui-processDialog-actions-primary {
+       right: 0;
+}
+.oo-ui-processDialog-errors {
+       position: absolute;
+       top: 0;
+       left: 0;
+       right: 0;
+       bottom: 0;
+       z-index: 2;
+       overflow-x: hidden;
+       overflow-y: auto;
+}
+.oo-ui-processDialog-content .oo-ui-window-head {
+       height: 3.4em;
+}
+.oo-ui-processDialog-content .oo-ui-window-body {
+       top: 3.4em;
+       outline: 1px solid rgba(0, 0, 0, 0.2);
+}
+.oo-ui-processDialog-navigation {
+       position: relative;
+       height: 3.4em;
+       padding: 0 1em;
+}
+.oo-ui-processDialog-location {
+       padding: 0.75em 0;
+       height: 1.875em;
+       cursor: default;
+       text-align: center;
+}
+.oo-ui-processDialog-title {
+       font-weight: bold;
+       line-height: 1.875em;
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-framed,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-framed,
+.oo-ui-processDialog-actions-other .oo-ui-actionWidget.oo-ui-buttonElement-framed {
+       margin: 0.5em;
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless,
+.oo-ui-processDialog-actions-other .oo-ui-actionWidget.oo-ui-buttonElement-frameless {
+       margin: 0;
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless .oo-ui-buttonElement-button,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless .oo-ui-buttonElement-button,
+.oo-ui-processDialog-actions-other .oo-ui-actionWidget.oo-ui-buttonElement-frameless .oo-ui-buttonElement-button {
+       padding: 0.75em 1em;
+       vertical-align: middle;
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless .oo-ui-labelElement-label,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless .oo-ui-labelElement-label,
+.oo-ui-processDialog-actions-other .oo-ui-actionWidget.oo-ui-buttonElement-frameless .oo-ui-labelElement-label {
+       line-height: 1.875em;
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless:hover,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless:hover {
+       background-color: rgba(0, 0, 0, 0.05);
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless:active,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless:active {
+       background-color: rgba(0, 0, 0, 0.1);
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-progressive:hover,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-progressive:hover {
+       background-color: rgba(8, 126, 204, 0.05);
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-progressive:active,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-progressive:active {
+       background-color: rgba(8, 126, 204, 0.1);
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-progressive .oo-ui-labelElement-label,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-progressive .oo-ui-labelElement-label {
+       font-weight: bold;
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-constructive:hover,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-constructive:hover {
+       background-color: rgba(118, 171, 54, 0.05);
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-constructive:active,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-constructive:active {
+       background-color: rgba(118, 171, 54, 0.1);
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-destructive:hover,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-destructive:hover {
+       background-color: rgba(212, 83, 83, 0.05);
+}
+.oo-ui-processDialog-actions-safe .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-destructive:active,
+.oo-ui-processDialog-actions-primary .oo-ui-actionWidget.oo-ui-buttonElement-frameless.oo-ui-flaggedElement-destructive:active {
+       background-color: rgba(212, 83, 83, 0.1);
+}
+.oo-ui-processDialog-actions-other .oo-ui-actionWidget.oo-ui-buttonElement {
+       margin-right: 0;
+}
+.oo-ui-processDialog > .oo-ui-window-frame {
+       min-height: 5em;
+}
+.oo-ui-processDialog-errors {
+       background-color: rgba(255, 255, 255, 0.9);
+       padding: 3em 3em 1.5em 3em;
+       text-align: center;
+}
+.oo-ui-processDialog-errors .oo-ui-buttonWidget {
+       margin: 2em 1em 2em 1em;
+}
+.oo-ui-processDialog-errors-title {
+       font-size: 1.5em;
+       color: #000000;
+       margin-bottom: 2em;
+}
+.oo-ui-processDialog-error {
+       text-align: left;
+       margin: 1em;
+       padding: 1em;
+       border: 1px solid #ff9e9e;
+       background-color: #fff7f7;
+       border-radius: 2px;
+}
+.oo-ui-windowManager-modal > .oo-ui-dialog {
+       position: fixed;
+       width: 0;
+       height: 0;
+       overflow: hidden;
+}
+.oo-ui-windowManager-modal > .oo-ui-dialog.oo-ui-window-active {
+       width: auto;
+       height: auto;
+       top: 0;
+       right: 0;
+       bottom: 0;
+       left: 0;
+       padding: 1em;
+}
+.oo-ui-windowManager-modal > .oo-ui-dialog.oo-ui-window-setup > .oo-ui-window-frame {
+       position: absolute;
+       right: 0;
+       left: 0;
+       margin: auto;
+       overflow: hidden;
+       max-width: 100%;
+       max-height: 100%;
+}
+.oo-ui-windowManager-fullscreen > .oo-ui-dialog > .oo-ui-window-frame {
+       width: 100%;
+       height: 100%;
+       top: 0;
+       bottom: 0;
+}
+.oo-ui-windowManager-modal > .oo-ui-dialog {
+       background-color: rgba(255, 255, 255, 0.5);
+       opacity: 0;
+       -webkit-transition: opacity 250ms ease;
+          -moz-transition: opacity 250ms ease;
+               transition: opacity 250ms ease;
+}
+.oo-ui-windowManager-modal > .oo-ui-dialog > .oo-ui-window-frame {
+       background-color: #ffffff;
+       opacity: 0;
+       -webkit-transform: scale(0.5);
+          -moz-transform: scale(0.5);
+           -ms-transform: scale(0.5);
+               transform: scale(0.5);
+       -webkit-transition: all 250ms ease;
+          -moz-transition: all 250ms ease;
+               transition: all 250ms ease;
+}
+.oo-ui-windowManager-modal > .oo-ui-dialog.oo-ui-window-setup {
+       opacity: 1;
+}
+.oo-ui-windowManager-modal > .oo-ui-dialog.oo-ui-window-ready > .oo-ui-window-frame {
+       opacity: 1;
+       -webkit-transform: scale(1);
+          -moz-transform: scale(1);
+           -ms-transform: scale(1);
+               transform: scale(1);
+}
+.oo-ui-windowManager-modal.oo-ui-windowManager-floating > .oo-ui-dialog > .oo-ui-window-frame {
+       top: 1em;
+       bottom: 1em;
+       border: 1px solid #aaaaaa;
+       border-radius: 2px;
+       box-shadow: 0 0.15em 0 0 rgba(0, 0, 0, 0.15);
+}
diff --git a/resources/lib/oojs-ui/oojs-ui-windows.js b/resources/lib/oojs-ui/oojs-ui-windows.js
new file mode 100644 (file)
index 0000000..1ccd433
--- /dev/null
@@ -0,0 +1,3395 @@
+/*!
+ * OOjs UI v0.15.2
+ * https://www.mediawiki.org/wiki/OOjs_UI
+ *
+ * Copyright 2011–2016 OOjs UI Team and other contributors.
+ * Released under the MIT license
+ * http://oojs.mit-license.org
+ *
+ * Date: 2016-02-02T22:07:00Z
+ */
+( function ( OO ) {
+
+'use strict';
+
+/**
+ * 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.
+ *
+ * Both actions and action sets are primarily used with {@link OO.ui.Dialog Dialogs}.
+ * 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
+ * @mixins OO.ui.mixin.PendingElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {string} [action] Symbolic name of the action (e.g., ‘continue’ or ‘cancel’).
+ * @cfg {string[]} [modes] Symbolic names of the modes (e.g., ‘edit’ or ‘read’) in which the action
+ *  should be made available. See the action set's {@link OO.ui.ActionSet#setMode setMode} method
+ *  for more information about setting modes.
+ * @cfg {boolean} [framed=false] Render the action button with a frame
+ */
+OO.ui.ActionWidget = function OoUiActionWidget( config ) {
+       // Configuration initialization
+       config = $.extend( { framed: false }, config );
+
+       // Parent constructor
+       OO.ui.ActionWidget.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.PendingElement.call( this, config );
+
+       // Properties
+       this.action = config.action || '';
+       this.modes = config.modes || [];
+       this.width = 0;
+       this.height = 0;
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-actionWidget' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.ActionWidget, OO.ui.ButtonWidget );
+OO.mixinClass( OO.ui.ActionWidget, OO.ui.mixin.PendingElement );
+
+/* Events */
+
+/**
+ * A resize event is emitted when the size of the widget changes.
+ *
+ * @event resize
+ */
+
+/* Methods */
+
+/**
+ * Check if the action is configured to be available in the specified `mode`.
+ *
+ * @param {string} mode Name of mode
+ * @return {boolean} The action is configured with the mode
+ */
+OO.ui.ActionWidget.prototype.hasMode = function ( mode ) {
+       return this.modes.indexOf( mode ) !== -1;
+};
+
+/**
+ * Get the symbolic name of the action (e.g., ‘continue’ or ‘cancel’).
+ *
+ * @return {string}
+ */
+OO.ui.ActionWidget.prototype.getAction = function () {
+       return this.action;
+};
+
+/**
+ * Get the symbolic name of the mode or modes for which the action is configured to be available.
+ *
+ * The current mode is set with the action set's {@link OO.ui.ActionSet#setMode setMode} method.
+ * Only actions that are configured to be avaiable in the current mode will be visible. All other actions
+ * are hidden.
+ *
+ * @return {string[]}
+ */
+OO.ui.ActionWidget.prototype.getModes = function () {
+       return this.modes.slice();
+};
+
+/**
+ * Emit a resize event if the size has changed.
+ *
+ * @private
+ * @chainable
+ */
+OO.ui.ActionWidget.prototype.propagateResize = function () {
+       var width, height;
+
+       if ( this.isElementAttached() ) {
+               width = this.$element.width();
+               height = this.$element.height();
+
+               if ( width !== this.width || height !== this.height ) {
+                       this.width = width;
+                       this.height = height;
+                       this.emit( 'resize' );
+               }
+       }
+
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.ActionWidget.prototype.setIcon = function () {
+       // Mixin method
+       OO.ui.mixin.IconElement.prototype.setIcon.apply( this, arguments );
+       this.propagateResize();
+
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.ActionWidget.prototype.setLabel = function () {
+       // Mixin method
+       OO.ui.mixin.LabelElement.prototype.setLabel.apply( this, arguments );
+       this.propagateResize();
+
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.ActionWidget.prototype.setFlags = function () {
+       // Mixin method
+       OO.ui.mixin.FlaggedElement.prototype.setFlags.apply( this, arguments );
+       this.propagateResize();
+
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.ActionWidget.prototype.clearFlags = function () {
+       // Mixin method
+       OO.ui.mixin.FlaggedElement.prototype.clearFlags.apply( this, arguments );
+       this.propagateResize();
+
+       return this;
+};
+
+/**
+ * Toggle the visibility of the action button.
+ *
+ * @param {boolean} [show] Show button, omit to toggle visibility
+ * @chainable
+ */
+OO.ui.ActionWidget.prototype.toggle = function () {
+       // Parent method
+       OO.ui.ActionWidget.parent.prototype.toggle.apply( this, arguments );
+       this.propagateResize();
+
+       return this;
+};
+
+/**
+ * ActionSets manage the behavior of the {@link OO.ui.ActionWidget action widgets} that comprise them.
+ * Actions can be made available for specific contexts (modes) and circumstances
+ * (abilities). Action sets are primarily used with {@link OO.ui.Dialog Dialogs}.
+ *
+ * ActionSets contain two types of actions:
+ *
+ * - Special: Special actions are the first visible actions with special flags, such as 'safe' and 'primary', the default special flags. Additional special flags can be configured in subclasses with the static #specialFlags property.
+ * - Other: Other actions include all non-special visible actions.
+ *
+ * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
+ *
+ *     @example
+ *     // Example: An action set used in a process dialog
+ *     function MyProcessDialog( config ) {
+ *         MyProcessDialog.parent.call( this, config );
+ *     }
+ *     OO.inheritClass( MyProcessDialog, OO.ui.ProcessDialog );
+ *     MyProcessDialog.static.title = 'An action set in a process dialog';
+ *     // An action set that uses modes ('edit' and 'help' mode, in this example).
+ *     MyProcessDialog.static.actions = [
+ *         { action: 'continue', modes: 'edit', label: 'Continue', flags: [ 'primary', 'constructive' ] },
+ *         { action: 'help', modes: 'edit', label: 'Help' },
+ *         { modes: 'edit', label: 'Cancel', flags: 'safe' },
+ *         { action: 'back', modes: 'help', label: 'Back', flags: 'safe' }
+ *     ];
+ *
+ *     MyProcessDialog.prototype.initialize = function () {
+ *         MyProcessDialog.parent.prototype.initialize.apply( this, arguments );
+ *         this.panel1 = new OO.ui.PanelLayout( { padded: true, expanded: false } );
+ *         this.panel1.$element.append( '<p>This dialog uses an action set (continue, help, cancel, back) configured with modes. This is edit mode. Click \'help\' to see help mode.</p>' );
+ *         this.panel2 = new OO.ui.PanelLayout( { padded: true, expanded: false } );
+ *         this.panel2.$element.append( '<p>This is help mode. Only the \'back\' action widget is configured to be visible here. Click \'back\' to return to \'edit\' mode.</p>' );
+ *         this.stackLayout = new OO.ui.StackLayout( {
+ *             items: [ this.panel1, this.panel2 ]
+ *         } );
+ *         this.$body.append( this.stackLayout.$element );
+ *     };
+ *     MyProcessDialog.prototype.getSetupProcess = function ( data ) {
+ *         return MyProcessDialog.parent.prototype.getSetupProcess.call( this, data )
+ *             .next( function () {
+ *                 this.actions.setMode( 'edit' );
+ *             }, this );
+ *     };
+ *     MyProcessDialog.prototype.getActionProcess = function ( action ) {
+ *         if ( action === 'help' ) {
+ *             this.actions.setMode( 'help' );
+ *             this.stackLayout.setItem( this.panel2 );
+ *         } else if ( action === 'back' ) {
+ *             this.actions.setMode( 'edit' );
+ *             this.stackLayout.setItem( this.panel1 );
+ *         } else if ( action === 'continue' ) {
+ *             var dialog = this;
+ *             return new OO.ui.Process( function () {
+ *                 dialog.close();
+ *             } );
+ *         }
+ *         return MyProcessDialog.parent.prototype.getActionProcess.call( this, action );
+ *     };
+ *     MyProcessDialog.prototype.getBodyHeight = function () {
+ *         return this.panel1.$element.outerHeight( true );
+ *     };
+ *     var windowManager = new OO.ui.WindowManager();
+ *     $( 'body' ).append( windowManager.$element );
+ *     var dialog = new MyProcessDialog( {
+ *         size: 'medium'
+ *     } );
+ *     windowManager.addWindows( [ dialog ] );
+ *     windowManager.openWindow( dialog );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Process_Dialogs#Action_sets
+ *
+ * @abstract
+ * @class
+ * @mixins OO.EventEmitter
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.ActionSet = function OoUiActionSet( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Mixin constructors
+       OO.EventEmitter.call( this );
+
+       // Properties
+       this.list = [];
+       this.categories = {
+               actions: 'getAction',
+               flags: 'getFlags',
+               modes: 'getModes'
+       };
+       this.categorized = {};
+       this.special = {};
+       this.others = [];
+       this.organized = false;
+       this.changing = false;
+       this.changed = false;
+};
+
+/* Setup */
+
+OO.mixinClass( OO.ui.ActionSet, OO.EventEmitter );
+
+/* Static Properties */
+
+/**
+ * Symbolic name of the flags used to identify special actions. Special actions are displayed in the
+ *  header of a {@link OO.ui.ProcessDialog process dialog}.
+ *  See the [OOjs UI documentation on MediaWiki][2] for more information and examples.
+ *
+ *  [2]:https://www.mediawiki.org/wiki/OOjs_UI/Windows/Process_Dialogs
+ *
+ * @abstract
+ * @static
+ * @inheritable
+ * @property {string}
+ */
+OO.ui.ActionSet.static.specialFlags = [ 'safe', 'primary' ];
+
+/* Events */
+
+/**
+ * @event click
+ *
+ * A 'click' event is emitted when an action is clicked.
+ *
+ * @param {OO.ui.ActionWidget} action Action that was clicked
+ */
+
+/**
+ * @event resize
+ *
+ * A 'resize' event is emitted when an action widget is resized.
+ *
+ * @param {OO.ui.ActionWidget} action Action that was resized
+ */
+
+/**
+ * @event add
+ *
+ * An 'add' event is emitted when actions are {@link #method-add added} to the action set.
+ *
+ * @param {OO.ui.ActionWidget[]} added Actions added
+ */
+
+/**
+ * @event remove
+ *
+ * A 'remove' event is emitted when actions are {@link #method-remove removed}
+ *  or {@link #clear cleared}.
+ *
+ * @param {OO.ui.ActionWidget[]} added Actions removed
+ */
+
+/**
+ * @event change
+ *
+ * A 'change' event is emitted when actions are {@link #method-add added}, {@link #clear cleared},
+ * or {@link #method-remove removed} from the action set or when the {@link #setMode mode} is changed.
+ *
+ */
+
+/* Methods */
+
+/**
+ * Handle action change events.
+ *
+ * @private
+ * @fires change
+ */
+OO.ui.ActionSet.prototype.onActionChange = function () {
+       this.organized = false;
+       if ( this.changing ) {
+               this.changed = true;
+       } else {
+               this.emit( 'change' );
+       }
+};
+
+/**
+ * Check if an action is one of the special actions.
+ *
+ * @param {OO.ui.ActionWidget} action Action to check
+ * @return {boolean} Action is special
+ */
+OO.ui.ActionSet.prototype.isSpecial = function ( action ) {
+       var flag;
+
+       for ( flag in this.special ) {
+               if ( action === this.special[ flag ] ) {
+                       return true;
+               }
+       }
+
+       return false;
+};
+
+/**
+ * Get action widgets based on the specified filter: ‘actions’, ‘flags’, ‘modes’, ‘visible’,
+ *  or ‘disabled’.
+ *
+ * @param {Object} [filters] Filters to use, omit to get all actions
+ * @param {string|string[]} [filters.actions] Actions that action widgets must have
+ * @param {string|string[]} [filters.flags] Flags that action widgets must have (e.g., 'safe')
+ * @param {string|string[]} [filters.modes] Modes that action widgets must have
+ * @param {boolean} [filters.visible] Action widgets must be visible
+ * @param {boolean} [filters.disabled] Action widgets must be disabled
+ * @return {OO.ui.ActionWidget[]} Action widgets matching all criteria
+ */
+OO.ui.ActionSet.prototype.get = function ( filters ) {
+       var i, len, list, category, actions, index, match, matches;
+
+       if ( filters ) {
+               this.organize();
+
+               // Collect category candidates
+               matches = [];
+               for ( category in this.categorized ) {
+                       list = filters[ category ];
+                       if ( list ) {
+                               if ( !Array.isArray( list ) ) {
+                                       list = [ list ];
+                               }
+                               for ( i = 0, len = list.length; i < len; i++ ) {
+                                       actions = this.categorized[ category ][ list[ i ] ];
+                                       if ( Array.isArray( actions ) ) {
+                                               matches.push.apply( matches, actions );
+                                       }
+                               }
+                       }
+               }
+               // Remove by boolean filters
+               for ( i = 0, len = matches.length; i < len; i++ ) {
+                       match = matches[ i ];
+                       if (
+                               ( filters.visible !== undefined && match.isVisible() !== filters.visible ) ||
+                               ( filters.disabled !== undefined && match.isDisabled() !== filters.disabled )
+                       ) {
+                               matches.splice( i, 1 );
+                               len--;
+                               i--;
+                       }
+               }
+               // Remove duplicates
+               for ( i = 0, len = matches.length; i < len; i++ ) {
+                       match = matches[ i ];
+                       index = matches.lastIndexOf( match );
+                       while ( index !== i ) {
+                               matches.splice( index, 1 );
+                               len--;
+                               index = matches.lastIndexOf( match );
+                       }
+               }
+               return matches;
+       }
+       return this.list.slice();
+};
+
+/**
+ * Get 'special' actions.
+ *
+ * Special actions are the first visible action widgets with special flags, such as 'safe' and 'primary'.
+ * Special flags can be configured in subclasses by changing the static #specialFlags property.
+ *
+ * @return {OO.ui.ActionWidget[]|null} 'Special' action widgets.
+ */
+OO.ui.ActionSet.prototype.getSpecial = function () {
+       this.organize();
+       return $.extend( {}, this.special );
+};
+
+/**
+ * Get 'other' actions.
+ *
+ * Other actions include all non-special visible action widgets.
+ *
+ * @return {OO.ui.ActionWidget[]} 'Other' action widgets
+ */
+OO.ui.ActionSet.prototype.getOthers = function () {
+       this.organize();
+       return this.others.slice();
+};
+
+/**
+ * Set the mode  (e.g., ‘edit’ or ‘view’). Only {@link OO.ui.ActionWidget#modes actions} configured
+ * to be available in the specified mode will be made visible. All other actions will be hidden.
+ *
+ * @param {string} mode The mode. Only actions configured to be available in the specified
+ *  mode will be made visible.
+ * @chainable
+ * @fires toggle
+ * @fires change
+ */
+OO.ui.ActionSet.prototype.setMode = function ( mode ) {
+       var i, len, action;
+
+       this.changing = true;
+       for ( i = 0, len = this.list.length; i < len; i++ ) {
+               action = this.list[ i ];
+               action.toggle( action.hasMode( mode ) );
+       }
+
+       this.organized = false;
+       this.changing = false;
+       this.emit( 'change' );
+
+       return this;
+};
+
+/**
+ * Set the abilities of the specified actions.
+ *
+ * Action widgets that are configured with the specified actions will be enabled
+ * or disabled based on the boolean values specified in the `actions`
+ * parameter.
+ *
+ * @param {Object.<string,boolean>} actions A list keyed by action name with boolean
+ *  values that indicate whether or not the action should be enabled.
+ * @chainable
+ */
+OO.ui.ActionSet.prototype.setAbilities = function ( actions ) {
+       var i, len, action, item;
+
+       for ( i = 0, len = this.list.length; i < len; i++ ) {
+               item = this.list[ i ];
+               action = item.getAction();
+               if ( actions[ action ] !== undefined ) {
+                       item.setDisabled( !actions[ action ] );
+               }
+       }
+
+       return this;
+};
+
+/**
+ * Executes a function once per action.
+ *
+ * When making changes to multiple actions, use this method instead of iterating over the actions
+ * manually to defer emitting a #change event until after all actions have been changed.
+ *
+ * @param {Object|null} actions Filters to use to determine which actions to iterate over; see #get
+ * @param {Function} callback Callback to run for each action; callback is invoked with three
+ *   arguments: the action, the action's index, the list of actions being iterated over
+ * @chainable
+ */
+OO.ui.ActionSet.prototype.forEach = function ( filter, callback ) {
+       this.changed = false;
+       this.changing = true;
+       this.get( filter ).forEach( callback );
+       this.changing = false;
+       if ( this.changed ) {
+               this.emit( 'change' );
+       }
+
+       return this;
+};
+
+/**
+ * Add action widgets to the action set.
+ *
+ * @param {OO.ui.ActionWidget[]} actions Action widgets to add
+ * @chainable
+ * @fires add
+ * @fires change
+ */
+OO.ui.ActionSet.prototype.add = function ( actions ) {
+       var i, len, action;
+
+       this.changing = true;
+       for ( i = 0, len = actions.length; i < len; i++ ) {
+               action = actions[ i ];
+               action.connect( this, {
+                       click: [ 'emit', 'click', action ],
+                       resize: [ 'emit', 'resize', action ],
+                       toggle: [ 'onActionChange' ]
+               } );
+               this.list.push( action );
+       }
+       this.organized = false;
+       this.emit( 'add', actions );
+       this.changing = false;
+       this.emit( 'change' );
+
+       return this;
+};
+
+/**
+ * Remove action widgets from the set.
+ *
+ * To remove all actions, you may wish to use the #clear method instead.
+ *
+ * @param {OO.ui.ActionWidget[]} actions Action widgets to remove
+ * @chainable
+ * @fires remove
+ * @fires change
+ */
+OO.ui.ActionSet.prototype.remove = function ( actions ) {
+       var i, len, index, action;
+
+       this.changing = true;
+       for ( i = 0, len = actions.length; i < len; i++ ) {
+               action = actions[ i ];
+               index = this.list.indexOf( action );
+               if ( index !== -1 ) {
+                       action.disconnect( this );
+                       this.list.splice( index, 1 );
+               }
+       }
+       this.organized = false;
+       this.emit( 'remove', actions );
+       this.changing = false;
+       this.emit( 'change' );
+
+       return this;
+};
+
+/**
+ * Remove all action widets from the set.
+ *
+ * To remove only specified actions, use the {@link #method-remove remove} method instead.
+ *
+ * @chainable
+ * @fires remove
+ * @fires change
+ */
+OO.ui.ActionSet.prototype.clear = function () {
+       var i, len, action,
+               removed = this.list.slice();
+
+       this.changing = true;
+       for ( i = 0, len = this.list.length; i < len; i++ ) {
+               action = this.list[ i ];
+               action.disconnect( this );
+       }
+
+       this.list = [];
+
+       this.organized = false;
+       this.emit( 'remove', removed );
+       this.changing = false;
+       this.emit( 'change' );
+
+       return this;
+};
+
+/**
+ * Organize actions.
+ *
+ * This is called whenever organized information is requested. It will only reorganize the actions
+ * if something has changed since the last time it ran.
+ *
+ * @private
+ * @chainable
+ */
+OO.ui.ActionSet.prototype.organize = function () {
+       var i, iLen, j, jLen, flag, action, category, list, item, special,
+               specialFlags = this.constructor.static.specialFlags;
+
+       if ( !this.organized ) {
+               this.categorized = {};
+               this.special = {};
+               this.others = [];
+               for ( i = 0, iLen = this.list.length; i < iLen; i++ ) {
+                       action = this.list[ i ];
+                       if ( action.isVisible() ) {
+                               // Populate categories
+                               for ( category in this.categories ) {
+                                       if ( !this.categorized[ category ] ) {
+                                               this.categorized[ category ] = {};
+                                       }
+                                       list = action[ this.categories[ category ] ]();
+                                       if ( !Array.isArray( list ) ) {
+                                               list = [ list ];
+                                       }
+                                       for ( j = 0, jLen = list.length; j < jLen; j++ ) {
+                                               item = list[ j ];
+                                               if ( !this.categorized[ category ][ item ] ) {
+                                                       this.categorized[ category ][ item ] = [];
+                                               }
+                                               this.categorized[ category ][ item ].push( action );
+                                       }
+                               }
+                               // Populate special/others
+                               special = false;
+                               for ( j = 0, jLen = specialFlags.length; j < jLen; j++ ) {
+                                       flag = specialFlags[ j ];
+                                       if ( !this.special[ flag ] && action.hasFlag( flag ) ) {
+                                               this.special[ flag ] = action;
+                                               special = true;
+                                               break;
+                                       }
+                               }
+                               if ( !special ) {
+                                       this.others.push( action );
+                               }
+                       }
+               }
+               this.organized = true;
+       }
+
+       return this;
+};
+
+/**
+ * Errors contain a required message (either a string or jQuery selection) that is used to describe what went wrong
+ * in a {@link OO.ui.Process process}. The error's #recoverable and #warning configurations are used to customize the
+ * appearance and functionality of the error interface.
+ *
+ * The basic error interface contains a formatted error message as well as two buttons: 'Dismiss' and 'Try again' (i.e., the error
+ * is 'recoverable' by default). If the error is not recoverable, the 'Try again' button will not be rendered and the widget
+ * that initiated the failed process will be disabled.
+ *
+ * If the error is a warning, the error interface will include a 'Dismiss' and a 'Continue' button, which will try the
+ * process again.
+ *
+ * For an example of error interfaces, please see the [OOjs UI documentation on MediaWiki][1].
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Process_Dialogs#Processes_and_errors
+ *
+ * @class
+ *
+ * @constructor
+ * @param {string|jQuery} message Description of error
+ * @param {Object} [config] Configuration options
+ * @cfg {boolean} [recoverable=true] Error is recoverable.
+ *  By default, errors are recoverable, and users can try the process again.
+ * @cfg {boolean} [warning=false] Error is a warning.
+ *  If the error is a warning, the error interface will include a
+ *  'Dismiss' and a 'Continue' button. It is the responsibility of the developer to ensure that the warning
+ *  is not triggered a second time if the user chooses to continue.
+ */
+OO.ui.Error = function OoUiError( message, config ) {
+       // Allow passing positional parameters inside the config object
+       if ( OO.isPlainObject( message ) && config === undefined ) {
+               config = message;
+               message = config.message;
+       }
+
+       // Configuration initialization
+       config = config || {};
+
+       // Properties
+       this.message = message instanceof jQuery ? message : String( message );
+       this.recoverable = config.recoverable === undefined || !!config.recoverable;
+       this.warning = !!config.warning;
+};
+
+/* Setup */
+
+OO.initClass( OO.ui.Error );
+
+/* Methods */
+
+/**
+ * Check if the error is recoverable.
+ *
+ * If the error is recoverable, users are able to try the process again.
+ *
+ * @return {boolean} Error is recoverable
+ */
+OO.ui.Error.prototype.isRecoverable = function () {
+       return this.recoverable;
+};
+
+/**
+ * Check if the error is a warning.
+ *
+ * If the error is a warning, the error interface will include a 'Dismiss' and a 'Continue' button.
+ *
+ * @return {boolean} Error is warning
+ */
+OO.ui.Error.prototype.isWarning = function () {
+       return this.warning;
+};
+
+/**
+ * Get error message as DOM nodes.
+ *
+ * @return {jQuery} Error message in DOM nodes
+ */
+OO.ui.Error.prototype.getMessage = function () {
+       return this.message instanceof jQuery ?
+               this.message.clone() :
+               $( '<div>' ).text( this.message ).contents();
+};
+
+/**
+ * Get the error message text.
+ *
+ * @return {string} Error message
+ */
+OO.ui.Error.prototype.getMessageText = function () {
+       return this.message instanceof jQuery ? this.message.text() : this.message;
+};
+
+/**
+ * A Process is a list of steps that are called in sequence. The step can be a number, a jQuery promise,
+ * or a function:
+ *
+ * - **number**: the process will wait for the specified number of milliseconds before proceeding.
+ * - **promise**: the process will continue to the next step when the promise is successfully resolved
+ *  or stop if the promise is rejected.
+ * - **function**: the process will execute the function. The process will stop if the function returns
+ *  either a boolean `false` or a promise that is rejected; if the function returns a number, the process
+ *  will wait for that number of milliseconds before proceeding.
+ *
+ * If the process fails, an {@link OO.ui.Error error} is generated. Depending on how the error is
+ * configured, users can dismiss the error and try the process again, or not. If a process is stopped,
+ * its remaining steps will not be performed.
+ *
+ * @class
+ *
+ * @constructor
+ * @param {number|jQuery.Promise|Function} step Number of miliseconds to wait before proceeding, promise
+ *  that must be resolved before proceeding, or a function to execute. See #createStep for more information. see #createStep for more information
+ * @param {Object} [context=null] Execution context of the function. The context is ignored if the step is
+ *  a number or promise.
+ * @return {Object} Step object, with `callback` and `context` properties
+ */
+OO.ui.Process = function ( step, context ) {
+       // Properties
+       this.steps = [];
+
+       // Initialization
+       if ( step !== undefined ) {
+               this.next( step, context );
+       }
+};
+
+/* Setup */
+
+OO.initClass( OO.ui.Process );
+
+/* Methods */
+
+/**
+ * Start the process.
+ *
+ * @return {jQuery.Promise} Promise that is resolved when all steps have successfully completed.
+ *  If any of the steps return a promise that is rejected or a boolean false, this promise is rejected
+ *  and any remaining steps are not performed.
+ */
+OO.ui.Process.prototype.execute = function () {
+       var i, len, promise;
+
+       /**
+        * Continue execution.
+        *
+        * @ignore
+        * @param {Array} step A function and the context it should be called in
+        * @return {Function} Function that continues the process
+        */
+       function proceed( step ) {
+               return function () {
+                       // Execute step in the correct context
+                       var deferred,
+                               result = step.callback.call( step.context );
+
+                       if ( result === false ) {
+                               // Use rejected promise for boolean false results
+                               return $.Deferred().reject( [] ).promise();
+                       }
+                       if ( typeof result === 'number' ) {
+                               if ( result < 0 ) {
+                                       throw new Error( 'Cannot go back in time: flux capacitor is out of service' );
+                               }
+                               // Use a delayed promise for numbers, expecting them to be in milliseconds
+                               deferred = $.Deferred();
+                               setTimeout( deferred.resolve, result );
+                               return deferred.promise();
+                       }
+                       if ( result instanceof OO.ui.Error ) {
+                               // Use rejected promise for error
+                               return $.Deferred().reject( [ result ] ).promise();
+                       }
+                       if ( Array.isArray( result ) && result.length && result[ 0 ] instanceof OO.ui.Error ) {
+                               // Use rejected promise for list of errors
+                               return $.Deferred().reject( result ).promise();
+                       }
+                       // Duck-type the object to see if it can produce a promise
+                       if ( result && $.isFunction( result.promise ) ) {
+                               // Use a promise generated from the result
+                               return result.promise();
+                       }
+                       // Use resolved promise for other results
+                       return $.Deferred().resolve().promise();
+               };
+       }
+
+       if ( this.steps.length ) {
+               // Generate a chain reaction of promises
+               promise = proceed( this.steps[ 0 ] )();
+               for ( i = 1, len = this.steps.length; i < len; i++ ) {
+                       promise = promise.then( proceed( this.steps[ i ] ) );
+               }
+       } else {
+               promise = $.Deferred().resolve().promise();
+       }
+
+       return promise;
+};
+
+/**
+ * Create a process step.
+ *
+ * @private
+ * @param {number|jQuery.Promise|Function} step
+ *
+ * - Number of milliseconds to wait before proceeding
+ * - Promise that must be resolved before proceeding
+ * - Function to execute
+ *   - If the function returns a boolean false the process will stop
+ *   - If the function returns a promise, the process will continue to the next
+ *     step when the promise is resolved or stop if the promise is rejected
+ *   - If the function returns a number, the process will wait for that number of
+ *     milliseconds before proceeding
+ * @param {Object} [context=null] Execution context of the function. The context is
+ *  ignored if the step is a number or promise.
+ * @return {Object} Step object, with `callback` and `context` properties
+ */
+OO.ui.Process.prototype.createStep = function ( step, context ) {
+       if ( typeof step === 'number' || $.isFunction( step.promise ) ) {
+               return {
+                       callback: function () {
+                               return step;
+                       },
+                       context: null
+               };
+       }
+       if ( $.isFunction( step ) ) {
+               return {
+                       callback: step,
+                       context: context
+               };
+       }
+       throw new Error( 'Cannot create process step: number, promise or function expected' );
+};
+
+/**
+ * Add step to the beginning of the process.
+ *
+ * @inheritdoc #createStep
+ * @return {OO.ui.Process} this
+ * @chainable
+ */
+OO.ui.Process.prototype.first = function ( step, context ) {
+       this.steps.unshift( this.createStep( step, context ) );
+       return this;
+};
+
+/**
+ * Add step to the end of the process.
+ *
+ * @inheritdoc #createStep
+ * @return {OO.ui.Process} this
+ * @chainable
+ */
+OO.ui.Process.prototype.next = function ( step, context ) {
+       this.steps.push( this.createStep( step, context ) );
+       return this;
+};
+
+/**
+ * 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
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {OO.Factory} [factory] Window factory to use for automatic instantiation
+ *  Note that window classes that are instantiated with a factory must have
+ *  a {@link OO.ui.Dialog#static-name static name} property that specifies a symbolic name.
+ * @cfg {boolean} [modal=true] Prevent interaction outside the dialog
+ */
+OO.ui.WindowManager = function OoUiWindowManager( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.WindowManager.parent.call( this, config );
+
+       // Mixin constructors
+       OO.EventEmitter.call( this );
+
+       // Properties
+       this.factory = config.factory;
+       this.modal = config.modal === undefined || !!config.modal;
+       this.windows = {};
+       this.opening = null;
+       this.opened = null;
+       this.closing = null;
+       this.preparingToOpen = null;
+       this.preparingToClose = null;
+       this.currentWindow = null;
+       this.globalEvents = false;
+       this.$ariaHidden = null;
+       this.onWindowResizeTimeout = null;
+       this.onWindowResizeHandler = this.onWindowResize.bind( this );
+       this.afterWindowResizeHandler = this.afterWindowResize.bind( this );
+
+       // Initialization
+       this.$element
+               .addClass( 'oo-ui-windowManager' )
+               .toggleClass( 'oo-ui-windowManager-modal', this.modal );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.WindowManager, OO.ui.Element );
+OO.mixinClass( OO.ui.WindowManager, OO.EventEmitter );
+
+/* Events */
+
+/**
+ * An 'opening' event is emitted when the window begins to be opened.
+ *
+ * @event opening
+ * @param {OO.ui.Window} win Window that's being opened
+ * @param {jQuery.Promise} opening An `opening` promise resolved with a value when the window is opened successfully.
+ *  When the `opening` promise is resolved, the first argument of the value is an 'opened' promise, the second argument
+ *  is the opening data. The `opening` promise emits `setup` and `ready` notifications when those processes are complete.
+ * @param {Object} data Window opening data
+ */
+
+/**
+ * A 'closing' event is emitted when the window begins to be closed.
+ *
+ * @event closing
+ * @param {OO.ui.Window} win Window that's being closed
+ * @param {jQuery.Promise} closing A `closing` promise is resolved with a value when the window
+ *  is closed successfully. The promise emits `hold` and `teardown` notifications when those
+ *  processes are complete. When the `closing` promise is resolved, the first argument of its value
+ *  is the closing data.
+ * @param {Object} data Window closing data
+ */
+
+/**
+ * A 'resize' event is emitted when a window is resized.
+ *
+ * @event resize
+ * @param {OO.ui.Window} win Window that was resized
+ */
+
+/* Static Properties */
+
+/**
+ * Map of the symbolic name of each window size and its CSS properties.
+ *
+ * @static
+ * @inheritable
+ * @property {Object}
+ */
+OO.ui.WindowManager.static.sizes = {
+       small: {
+               width: 300
+       },
+       medium: {
+               width: 500
+       },
+       large: {
+               width: 700
+       },
+       larger: {
+               width: 900
+       },
+       full: {
+               // These can be non-numeric because they are never used in calculations
+               width: '100%',
+               height: '100%'
+       }
+};
+
+/**
+ * Symbolic name of the default window size.
+ *
+ * The default size is used if the window's requested size is not recognized.
+ *
+ * @static
+ * @inheritable
+ * @property {string}
+ */
+OO.ui.WindowManager.static.defaultSize = 'medium';
+
+/* Methods */
+
+/**
+ * Handle window resize events.
+ *
+ * @private
+ * @param {jQuery.Event} e Window resize event
+ */
+OO.ui.WindowManager.prototype.onWindowResize = function () {
+       clearTimeout( this.onWindowResizeTimeout );
+       this.onWindowResizeTimeout = setTimeout( this.afterWindowResizeHandler, 200 );
+};
+
+/**
+ * Handle window resize events.
+ *
+ * @private
+ * @param {jQuery.Event} e Window resize event
+ */
+OO.ui.WindowManager.prototype.afterWindowResize = function () {
+       if ( this.currentWindow ) {
+               this.updateWindowSize( this.currentWindow );
+       }
+};
+
+/**
+ * Check if window is opening.
+ *
+ * @return {boolean} Window is opening
+ */
+OO.ui.WindowManager.prototype.isOpening = function ( win ) {
+       return win === this.currentWindow && !!this.opening && this.opening.state() === 'pending';
+};
+
+/**
+ * Check if window is closing.
+ *
+ * @return {boolean} Window is closing
+ */
+OO.ui.WindowManager.prototype.isClosing = function ( win ) {
+       return win === this.currentWindow && !!this.closing && this.closing.state() === 'pending';
+};
+
+/**
+ * Check if window is opened.
+ *
+ * @return {boolean} Window is opened
+ */
+OO.ui.WindowManager.prototype.isOpened = function ( win ) {
+       return win === this.currentWindow && !!this.opened && this.opened.state() === 'pending';
+};
+
+/**
+ * Check if a window is being managed.
+ *
+ * @param {OO.ui.Window} win Window to check
+ * @return {boolean} Window is being managed
+ */
+OO.ui.WindowManager.prototype.hasWindow = function ( win ) {
+       var name;
+
+       for ( name in this.windows ) {
+               if ( this.windows[ name ] === win ) {
+                       return true;
+               }
+       }
+
+       return false;
+};
+
+/**
+ * Get the number of milliseconds to wait after opening begins before executing the ‘setup’ process.
+ *
+ * @param {OO.ui.Window} win Window being opened
+ * @param {Object} [data] Window opening data
+ * @return {number} Milliseconds to wait
+ */
+OO.ui.WindowManager.prototype.getSetupDelay = function () {
+       return 0;
+};
+
+/**
+ * Get the number of milliseconds to wait after setup has finished before executing the ‘ready’ process.
+ *
+ * @param {OO.ui.Window} win Window being opened
+ * @param {Object} [data] Window opening data
+ * @return {number} Milliseconds to wait
+ */
+OO.ui.WindowManager.prototype.getReadyDelay = function () {
+       return 0;
+};
+
+/**
+ * Get the number of milliseconds to wait after closing has begun before executing the 'hold' process.
+ *
+ * @param {OO.ui.Window} win Window being closed
+ * @param {Object} [data] Window closing data
+ * @return {number} Milliseconds to wait
+ */
+OO.ui.WindowManager.prototype.getHoldDelay = function () {
+       return 0;
+};
+
+/**
+ * Get the number of milliseconds to wait after the ‘hold’ process has finished before
+ * executing the ‘teardown’ process.
+ *
+ * @param {OO.ui.Window} win Window being closed
+ * @param {Object} [data] Window closing data
+ * @return {number} Milliseconds to wait
+ */
+OO.ui.WindowManager.prototype.getTeardownDelay = function () {
+       return this.modal ? 250 : 0;
+};
+
+/**
+ * Get a window by its symbolic name.
+ *
+ * If the window is not yet instantiated and its symbolic name is recognized by a factory, it will be
+ * instantiated and added to the window manager automatically. Please see the [OOjs UI documentation on MediaWiki][3]
+ * for more information about using factories.
+ * [3]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Window_managers
+ *
+ * @param {string} name Symbolic name of the window
+ * @return {jQuery.Promise} Promise resolved with matching window, or rejected with an OO.ui.Error
+ * @throws {Error} An error is thrown if the symbolic name is not recognized by the factory.
+ * @throws {Error} An error is thrown if the named window is not recognized as a managed window.
+ */
+OO.ui.WindowManager.prototype.getWindow = function ( name ) {
+       var deferred = $.Deferred(),
+               win = this.windows[ name ];
+
+       if ( !( win instanceof OO.ui.Window ) ) {
+               if ( this.factory ) {
+                       if ( !this.factory.lookup( name ) ) {
+                               deferred.reject( new OO.ui.Error(
+                                       'Cannot auto-instantiate window: symbolic name is unrecognized by the factory'
+                               ) );
+                       } else {
+                               win = this.factory.create( name );
+                               this.addWindows( [ win ] );
+                               deferred.resolve( win );
+                       }
+               } else {
+                       deferred.reject( new OO.ui.Error(
+                               'Cannot get unmanaged window: symbolic name unrecognized as a managed window'
+                       ) );
+               }
+       } else {
+               deferred.resolve( win );
+       }
+
+       return deferred.promise();
+};
+
+/**
+ * Get current window.
+ *
+ * @return {OO.ui.Window|null} Currently opening/opened/closing window
+ */
+OO.ui.WindowManager.prototype.getCurrentWindow = function () {
+       return this.currentWindow;
+};
+
+/**
+ * Open a window.
+ *
+ * @param {OO.ui.Window|string} win Window object or symbolic name of window to open
+ * @param {Object} [data] Window opening data
+ * @return {jQuery.Promise} An `opening` promise resolved when the window is done opening.
+ *  See {@link #event-opening 'opening' event}  for more information about `opening` promises.
+ * @fires opening
+ */
+OO.ui.WindowManager.prototype.openWindow = function ( win, data ) {
+       var manager = this,
+               opening = $.Deferred();
+
+       // Argument handling
+       if ( typeof win === 'string' ) {
+               return this.getWindow( win ).then( function ( win ) {
+                       return manager.openWindow( win, data );
+               } );
+       }
+
+       // Error handling
+       if ( !this.hasWindow( win ) ) {
+               opening.reject( new OO.ui.Error(
+                       'Cannot open window: window is not attached to manager'
+               ) );
+       } else if ( this.preparingToOpen || this.opening || this.opened ) {
+               opening.reject( new OO.ui.Error(
+                       'Cannot open window: another window is opening or open'
+               ) );
+       }
+
+       // Window opening
+       if ( opening.state() !== 'rejected' ) {
+               // 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 ) {
+                               manager.toggleGlobalEvents( true );
+                               manager.toggleAriaIsolation( true );
+                       }
+                       manager.currentWindow = win;
+                       manager.opening = opening;
+                       manager.preparingToOpen = null;
+                       manager.emit( 'opening', win, opening, data );
+                       setTimeout( function () {
+                               win.setup( data ).then( function () {
+                                       manager.updateWindowSize( win );
+                                       manager.opening.notify( { state: 'setup' } );
+                                       setTimeout( function () {
+                                               win.ready( data ).then( function () {
+                                                       manager.opening.notify( { state: 'ready' } );
+                                                       manager.opening = null;
+                                                       manager.opened = $.Deferred();
+                                                       opening.resolve( manager.opened.promise(), data );
+                                               }, function () {
+                                                       manager.opening = null;
+                                                       manager.opened = $.Deferred();
+                                                       opening.reject();
+                                                       manager.closeWindow( win );
+                                               } );
+                                       }, manager.getReadyDelay() );
+                               }, function () {
+                                       manager.opening = null;
+                                       manager.opened = $.Deferred();
+                                       opening.reject();
+                                       manager.closeWindow( win );
+                               } );
+                       }, manager.getSetupDelay() );
+               } );
+       }
+
+       return opening.promise();
+};
+
+/**
+ * Close a window.
+ *
+ * @param {OO.ui.Window|string} win Window object or symbolic name of window to close
+ * @param {Object} [data] Window closing data
+ * @return {jQuery.Promise} A `closing` promise resolved when the window is done closing.
+ *  See {@link #event-closing 'closing' event} for more information about closing promises.
+ * @throws {Error} An error is thrown if the window is not managed by the window manager.
+ * @fires closing
+ */
+OO.ui.WindowManager.prototype.closeWindow = function ( win, data ) {
+       var manager = this,
+               closing = $.Deferred(),
+               opened;
+
+       // Argument handling
+       if ( typeof win === 'string' ) {
+               win = this.windows[ win ];
+       } else if ( !this.hasWindow( win ) ) {
+               win = null;
+       }
+
+       // Error handling
+       if ( !win ) {
+               closing.reject( new OO.ui.Error(
+                       'Cannot close window: window is not attached to manager'
+               ) );
+       } else if ( win !== this.currentWindow ) {
+               closing.reject( new OO.ui.Error(
+                       'Cannot close window: window already closed with different data'
+               ) );
+       } else if ( this.preparingToClose || this.closing ) {
+               closing.reject( new OO.ui.Error(
+                       'Cannot close window: window already closing with different data'
+               ) );
+       }
+
+       // Window closing
+       if ( closing.state() !== 'rejected' ) {
+               // 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.always( function () {
+                       manager.closing = closing;
+                       manager.preparingToClose = null;
+                       manager.emit( 'closing', win, closing, data );
+                       opened = manager.opened;
+                       manager.opened = null;
+                       opened.resolve( closing.promise(), data );
+                       setTimeout( function () {
+                               win.hold( data ).then( function () {
+                                       closing.notify( { state: 'hold' } );
+                                       setTimeout( function () {
+                                               win.teardown( data ).then( function () {
+                                                       closing.notify( { state: 'teardown' } );
+                                                       if ( manager.modal ) {
+                                                               manager.toggleGlobalEvents( false );
+                                                               manager.toggleAriaIsolation( false );
+                                                       }
+                                                       manager.closing = null;
+                                                       manager.currentWindow = null;
+                                                       closing.resolve( data );
+                                               } );
+                                       }, manager.getTeardownDelay() );
+                               } );
+                       }, manager.getHoldDelay() );
+               } );
+       }
+
+       return closing.promise();
+};
+
+/**
+ * Add windows to the window manager.
+ *
+ * Windows can be added by reference, symbolic name, or explicitly defined symbolic names.
+ * See the [OOjs ui documentation on MediaWiki] [2] for examples.
+ * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Window_managers
+ *
+ * @param {Object.<string,OO.ui.Window>|OO.ui.Window[]} windows An array of window objects specified
+ *  by reference, symbolic name, or explicitly defined symbolic names.
+ * @throws {Error} An error is thrown if a window is added by symbolic name, but has neither an
+ *  explicit nor a statically configured symbolic name.
+ */
+OO.ui.WindowManager.prototype.addWindows = function ( windows ) {
+       var i, len, win, name, list;
+
+       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;
+                       if ( typeof name !== 'string' ) {
+                               throw new Error( 'Cannot add window' );
+                       }
+                       list[ name ] = windows[ i ];
+               }
+       } else if ( OO.isPlainObject( windows ) ) {
+               list = windows;
+       }
+
+       // Add windows
+       for ( name in list ) {
+               win = list[ name ];
+               this.windows[ name ] = win.toggle( false );
+               this.$element.append( win.$element );
+               win.setManager( this );
+       }
+};
+
+/**
+ * Remove the specified windows from the windows manager.
+ *
+ * Windows will be closed before they are removed. If you wish to remove all windows, you may wish to use
+ * the #clearWindows method instead. If you no longer need the window manager and want to ensure that it no
+ * longer listens to events, use the #destroy method.
+ *
+ * @param {string[]} names Symbolic names of windows to remove
+ * @return {jQuery.Promise} Promise resolved when window is closed and removed
+ * @throws {Error} An error is thrown if the named windows are not managed by the window manager.
+ */
+OO.ui.WindowManager.prototype.removeWindows = function ( names ) {
+       var i, len, win, name, cleanupWindow,
+               manager = this,
+               promises = [],
+               cleanup = function ( name, win ) {
+                       delete manager.windows[ name ];
+                       win.$element.detach();
+               };
+
+       for ( i = 0, len = names.length; i < len; i++ ) {
+               name = names[ i ];
+               win = this.windows[ name ];
+               if ( !win ) {
+                       throw new Error( 'Cannot remove window' );
+               }
+               cleanupWindow = cleanup.bind( null, name, win );
+               promises.push( this.closeWindow( name ).then( cleanupWindow, cleanupWindow ) );
+       }
+
+       return $.when.apply( $, promises );
+};
+
+/**
+ * Remove all windows from the window manager.
+ *
+ * Windows will be closed before they are removed. Note that the window manager, though not in use, will still
+ * listen to events. If the window manager will not be used again, you may wish to use the #destroy method instead.
+ * To remove just a subset of windows, use the #removeWindows method.
+ *
+ * @return {jQuery.Promise} Promise resolved when all windows are closed and removed
+ */
+OO.ui.WindowManager.prototype.clearWindows = function () {
+       return this.removeWindows( Object.keys( this.windows ) );
+};
+
+/**
+ * Set dialog size. In general, this method should not be called directly.
+ *
+ * Fullscreen mode will be used if the dialog is too wide to fit in the screen.
+ *
+ * @chainable
+ */
+OO.ui.WindowManager.prototype.updateWindowSize = function ( win ) {
+       var isFullscreen;
+
+       // Bypass for non-current, and thus invisible, windows
+       if ( win !== this.currentWindow ) {
+               return;
+       }
+
+       isFullscreen = win.getSize() === 'full';
+
+       this.$element.toggleClass( 'oo-ui-windowManager-fullscreen', isFullscreen );
+       this.$element.toggleClass( 'oo-ui-windowManager-floating', !isFullscreen );
+       win.setDimensions( win.getSizeProperties() );
+
+       this.emit( 'resize', win );
+
+       return this;
+};
+
+/**
+ * Bind or unbind global events for scrolling.
+ *
+ * @private
+ * @param {boolean} [on] Bind global events
+ * @chainable
+ */
+OO.ui.WindowManager.prototype.toggleGlobalEvents = function ( on ) {
+       var scrollWidth, bodyMargin,
+               $body = $( this.getElementDocument().body ),
+               // We could have multiple window managers open so only modify
+               // the body css at the bottom of the stack
+               stackDepth = $body.data( 'windowManagerGlobalEvents' ) || 0 ;
+
+       on = on === undefined ? !!this.globalEvents : !!on;
+
+       if ( on ) {
+               if ( !this.globalEvents ) {
+                       $( this.getElementWindow() ).on( {
+                               // Start listening for top-level window dimension changes
+                               'orientationchange resize': this.onWindowResizeHandler
+                       } );
+                       if ( stackDepth === 0 ) {
+                               scrollWidth = window.innerWidth - document.documentElement.clientWidth;
+                               bodyMargin = parseFloat( $body.css( 'margin-right' ) ) || 0;
+                               $body.css( {
+                                       overflow: 'hidden',
+                                       'margin-right': bodyMargin + scrollWidth
+                               } );
+                       }
+                       stackDepth++;
+                       this.globalEvents = true;
+               }
+       } else if ( this.globalEvents ) {
+               $( this.getElementWindow() ).off( {
+                       // Stop listening for top-level window dimension changes
+                       'orientationchange resize': this.onWindowResizeHandler
+               } );
+               stackDepth--;
+               if ( stackDepth === 0 ) {
+                       $body.css( {
+                               overflow: '',
+                               'margin-right': ''
+                       } );
+               }
+               this.globalEvents = false;
+       }
+       $body.data( 'windowManagerGlobalEvents', stackDepth );
+
+       return this;
+};
+
+/**
+ * Toggle screen reader visibility of content other than the window manager.
+ *
+ * @private
+ * @param {boolean} [isolate] Make only the window manager visible to screen readers
+ * @chainable
+ */
+OO.ui.WindowManager.prototype.toggleAriaIsolation = function ( isolate ) {
+       isolate = isolate === undefined ? !this.$ariaHidden : !!isolate;
+
+       if ( isolate ) {
+               if ( !this.$ariaHidden ) {
+                       // Hide everything other than the window manager from screen readers
+                       this.$ariaHidden = $( 'body' )
+                               .children()
+                               .not( this.$element.parentsUntil( 'body' ).last() )
+                               .attr( 'aria-hidden', '' );
+               }
+       } else if ( this.$ariaHidden ) {
+               // Restore screen reader visibility
+               this.$ariaHidden.removeAttr( 'aria-hidden' );
+               this.$ariaHidden = null;
+       }
+
+       return this;
+};
+
+/**
+ * Destroy the window manager.
+ *
+ * Destroying the window manager ensures that it will no longer listen to events. If you would like to
+ * continue using the window manager, but wish to remove all windows from it, use the #clearWindows method
+ * instead.
+ */
+OO.ui.WindowManager.prototype.destroy = function () {
+       this.toggleGlobalEvents( false );
+       this.toggleAriaIsolation( false );
+       this.clearWindows();
+       this.$element.remove();
+};
+
+/**
+ * A window is a container for elements that are in a child frame. They are used with
+ * a window manager (OO.ui.WindowManager), which is used to open and close the window and control
+ * its presentation. The size of a window is specified using a symbolic name (e.g., ‘small’, ‘medium’,
+ * ‘large’), which is interpreted by the window manager. If the requested size is not recognized,
+ * the window manager will choose a sensible fallback.
+ *
+ * The lifecycle of a window has three primary stages (opening, opened, and closing) in which
+ * different processes are executed:
+ *
+ * **opening**: The opening stage begins when the window manager's {@link OO.ui.WindowManager#openWindow
+ * openWindow} or the window's {@link #open open} methods are used, and the window manager begins to open
+ * the window.
+ *
+ * - {@link #getSetupProcess} method is called and its result executed
+ * - {@link #getReadyProcess} method is called and its result executed
+ *
+ * **opened**: The window is now open
+ *
+ * **closing**: The closing stage begins when the window manager's
+ * {@link OO.ui.WindowManager#closeWindow closeWindow}
+ * or the window's {@link #close} methods are used, and the window manager begins to close the window.
+ *
+ * - {@link #getHoldProcess} method is called and its result executed
+ * - {@link #getTeardownProcess} method is called and its result executed. The window is now closed
+ *
+ * Each of the window's processes (setup, ready, hold, and teardown) can be extended in subclasses
+ * by overriding the window's #getSetupProcess, #getReadyProcess, #getHoldProcess and #getTeardownProcess
+ * methods. Note that each {@link OO.ui.Process process} is executed in series, so asynchronous
+ * processing can complete. Always assume window processes are executed asynchronously.
+ *
+ * For more information, please see the [OOjs UI documentation on MediaWiki] [1].
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Windows
+ *
+ * @abstract
+ * @class
+ * @extends OO.ui.Element
+ * @mixins OO.EventEmitter
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ * @cfg {string} [size] Symbolic name of the dialog size: `small`, `medium`, `large`, `larger` or
+ *  `full`.  If omitted, the value of the {@link #static-size static size} property will be used.
+ */
+OO.ui.Window = function OoUiWindow( config ) {
+       // Configuration initialization
+       config = config || {};
+
+       // Parent constructor
+       OO.ui.Window.parent.call( this, config );
+
+       // Mixin constructors
+       OO.EventEmitter.call( this );
+
+       // Properties
+       this.manager = null;
+       this.size = config.size || this.constructor.static.size;
+       this.$frame = $( '<div>' );
+       this.$overlay = $( '<div>' );
+       this.$content = $( '<div>' );
+
+       this.$focusTrapBefore = $( '<div>' ).prop( 'tabIndex', 0 );
+       this.$focusTrapAfter = $( '<div>' ).prop( 'tabIndex', 0 );
+       this.$focusTraps = this.$focusTrapBefore.add( this.$focusTrapAfter );
+
+       // Initialization
+       this.$overlay.addClass( 'oo-ui-window-overlay' );
+       this.$content
+               .addClass( 'oo-ui-window-content' )
+               .attr( 'tabindex', 0 );
+       this.$frame
+               .addClass( 'oo-ui-window-frame' )
+               .append( this.$focusTrapBefore, this.$content, this.$focusTrapAfter );
+
+       this.$element
+               .addClass( 'oo-ui-window' )
+               .append( this.$frame, this.$overlay );
+
+       // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
+       // that reference properties not initialized at that time of parent class construction
+       // TODO: Find a better way to handle post-constructor setup
+       this.visible = false;
+       this.$element.addClass( 'oo-ui-element-hidden' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.Window, OO.ui.Element );
+OO.mixinClass( OO.ui.Window, OO.EventEmitter );
+
+/* Static Properties */
+
+/**
+ * Symbolic name of the window size: `small`, `medium`, `large`, `larger` or `full`.
+ *
+ * The static size is used if no #size is configured during construction.
+ *
+ * @static
+ * @inheritable
+ * @property {string}
+ */
+OO.ui.Window.static.size = 'medium';
+
+/* Methods */
+
+/**
+ * Handle mouse down events.
+ *
+ * @private
+ * @param {jQuery.Event} e Mouse down event
+ */
+OO.ui.Window.prototype.onMouseDown = function ( e ) {
+       // Prevent clicking on the click-block from stealing focus
+       if ( e.target === this.$element[ 0 ] ) {
+               return false;
+       }
+};
+
+/**
+ * Check if the window has been initialized.
+ *
+ * Initialization occurs when a window is added to a manager.
+ *
+ * @return {boolean} Window has been initialized
+ */
+OO.ui.Window.prototype.isInitialized = function () {
+       return !!this.manager;
+};
+
+/**
+ * Check if the window is visible.
+ *
+ * @return {boolean} Window is visible
+ */
+OO.ui.Window.prototype.isVisible = function () {
+       return this.visible;
+};
+
+/**
+ * Check if the window is opening.
+ *
+ * This method is a wrapper around the window manager's {@link OO.ui.WindowManager#isOpening isOpening}
+ * method.
+ *
+ * @return {boolean} Window is opening
+ */
+OO.ui.Window.prototype.isOpening = function () {
+       return this.manager.isOpening( this );
+};
+
+/**
+ * Check if the window is closing.
+ *
+ * This method is a wrapper around the window manager's {@link OO.ui.WindowManager#isClosing isClosing} method.
+ *
+ * @return {boolean} Window is closing
+ */
+OO.ui.Window.prototype.isClosing = function () {
+       return this.manager.isClosing( this );
+};
+
+/**
+ * Check if the window is opened.
+ *
+ * This method is a wrapper around the window manager's {@link OO.ui.WindowManager#isOpened isOpened} method.
+ *
+ * @return {boolean} Window is opened
+ */
+OO.ui.Window.prototype.isOpened = function () {
+       return this.manager.isOpened( this );
+};
+
+/**
+ * Get the window manager.
+ *
+ * All windows must be attached to a window manager, which is used to open
+ * and close the window and control its presentation.
+ *
+ * @return {OO.ui.WindowManager} Manager of window
+ */
+OO.ui.Window.prototype.getManager = function () {
+       return this.manager;
+};
+
+/**
+ * Get the symbolic name of the window size (e.g., `small` or `medium`).
+ *
+ * @return {string} Symbolic name of the size: `small`, `medium`, `large`, `larger`, `full`
+ */
+OO.ui.Window.prototype.getSize = function () {
+       var viewport = OO.ui.Element.static.getDimensions( this.getElementWindow() ),
+               sizes = this.manager.constructor.static.sizes,
+               size = this.size;
+
+       if ( !sizes[ size ] ) {
+               size = this.manager.constructor.static.defaultSize;
+       }
+       if ( size !== 'full' && viewport.rect.right - viewport.rect.left < sizes[ size ].width ) {
+               size = 'full';
+       }
+
+       return size;
+};
+
+/**
+ * Get the size properties associated with the current window size
+ *
+ * @return {Object} Size properties
+ */
+OO.ui.Window.prototype.getSizeProperties = function () {
+       return this.manager.constructor.static.sizes[ this.getSize() ];
+};
+
+/**
+ * Disable transitions on window's frame for the duration of the callback function, then enable them
+ * back.
+ *
+ * @private
+ * @param {Function} callback Function to call while transitions are disabled
+ */
+OO.ui.Window.prototype.withoutSizeTransitions = function ( callback ) {
+       // Temporarily resize the frame so getBodyHeight() can use scrollHeight measurements.
+       // Disable transitions first, otherwise we'll get values from when the window was animating.
+       var oldTransition,
+               styleObj = this.$frame[ 0 ].style;
+       oldTransition = styleObj.transition || styleObj.OTransition || styleObj.MsTransition ||
+               styleObj.MozTransition || styleObj.WebkitTransition;
+       styleObj.transition = styleObj.OTransition = styleObj.MsTransition =
+               styleObj.MozTransition = styleObj.WebkitTransition = 'none';
+       callback();
+       // Force reflow to make sure the style changes done inside callback really are not transitioned
+       this.$frame.height();
+       styleObj.transition = styleObj.OTransition = styleObj.MsTransition =
+               styleObj.MozTransition = styleObj.WebkitTransition = oldTransition;
+};
+
+/**
+ * Get the height of the full window contents (i.e., the window head, body and foot together).
+ *
+ * What consistitutes the head, body, and foot varies depending on the window type.
+ * A {@link OO.ui.MessageDialog message dialog} displays a title and message in its body,
+ * and any actions in the foot. A {@link OO.ui.ProcessDialog process dialog} displays a title
+ * and special actions in the head, and dialog content in the body.
+ *
+ * To get just the height of the dialog body, use the #getBodyHeight method.
+ *
+ * @return {number} The height of the window contents (the dialog head, body and foot) in pixels
+ */
+OO.ui.Window.prototype.getContentHeight = function () {
+       var bodyHeight,
+               win = this,
+               bodyStyleObj = this.$body[ 0 ].style,
+               frameStyleObj = this.$frame[ 0 ].style;
+
+       // Temporarily resize the frame so getBodyHeight() can use scrollHeight measurements.
+       // Disable transitions first, otherwise we'll get values from when the window was animating.
+       this.withoutSizeTransitions( function () {
+               var oldHeight = frameStyleObj.height,
+                       oldPosition = bodyStyleObj.position;
+               frameStyleObj.height = '1px';
+               // Force body to resize to new width
+               bodyStyleObj.position = 'relative';
+               bodyHeight = win.getBodyHeight();
+               frameStyleObj.height = oldHeight;
+               bodyStyleObj.position = oldPosition;
+       } );
+
+       return (
+               // Add buffer for border
+               ( this.$frame.outerHeight() - this.$frame.innerHeight() ) +
+               // Use combined heights of children
+               ( this.$head.outerHeight( true ) + bodyHeight + this.$foot.outerHeight( true ) )
+       );
+};
+
+/**
+ * Get the height of the window body.
+ *
+ * To get the height of the full window contents (the window body, head, and foot together),
+ * use #getContentHeight.
+ *
+ * When this function is called, the window will temporarily have been resized
+ * to height=1px, so .scrollHeight measurements can be taken accurately.
+ *
+ * @return {number} Height of the window body in pixels
+ */
+OO.ui.Window.prototype.getBodyHeight = function () {
+       return this.$body[ 0 ].scrollHeight;
+};
+
+/**
+ * Get the directionality of the frame (right-to-left or left-to-right).
+ *
+ * @return {string} Directionality: `'ltr'` or `'rtl'`
+ */
+OO.ui.Window.prototype.getDir = function () {
+       return OO.ui.Element.static.getDir( this.$content ) || 'ltr';
+};
+
+/**
+ * Get the 'setup' process.
+ *
+ * The setup process is used to set up a window for use in a particular context,
+ * based on the `data` argument. This method is called during the opening phase of the window’s
+ * lifecycle.
+ *
+ * Override this method to add additional steps to the ‘setup’ process the parent method provides
+ * using the {@link OO.ui.Process#first first} and {@link OO.ui.Process#next next} methods
+ * of OO.ui.Process.
+ *
+ * To add window content that persists between openings, you may wish to use the #initialize method
+ * instead.
+ *
+ * @param {Object} [data] Window opening data
+ * @return {OO.ui.Process} Setup process
+ */
+OO.ui.Window.prototype.getSetupProcess = function () {
+       return new OO.ui.Process();
+};
+
+/**
+ * Get the ‘ready’ process.
+ *
+ * The ready process is used to ready a window for use in a particular
+ * context, based on the `data` argument. This method is called during the opening phase of
+ * the window’s lifecycle, after the window has been {@link #getSetupProcess setup}.
+ *
+ * Override this method to add additional steps to the ‘ready’ process the parent method
+ * provides using the {@link OO.ui.Process#first first} and {@link OO.ui.Process#next next}
+ * methods of OO.ui.Process.
+ *
+ * @param {Object} [data] Window opening data
+ * @return {OO.ui.Process} Ready process
+ */
+OO.ui.Window.prototype.getReadyProcess = function () {
+       return new OO.ui.Process();
+};
+
+/**
+ * Get the 'hold' process.
+ *
+ * The hold proccess is used to keep a window from being used in a particular context,
+ * based on the `data` argument. This method is called during the closing phase of the window’s
+ * lifecycle.
+ *
+ * Override this method to add additional steps to the 'hold' process the parent method provides
+ * using the {@link OO.ui.Process#first first} and {@link OO.ui.Process#next next} methods
+ * of OO.ui.Process.
+ *
+ * @param {Object} [data] Window closing data
+ * @return {OO.ui.Process} Hold process
+ */
+OO.ui.Window.prototype.getHoldProcess = function () {
+       return new OO.ui.Process();
+};
+
+/**
+ * Get the ‘teardown’ process.
+ *
+ * The teardown process is used to teardown a window after use. During teardown,
+ * user interactions within the window are conveyed and the window is closed, based on the `data`
+ * argument. This method is called during the closing phase of the window’s lifecycle.
+ *
+ * Override this method to add additional steps to the ‘teardown’ process the parent method provides
+ * using the {@link OO.ui.Process#first first} and {@link OO.ui.Process#next next} methods
+ * of OO.ui.Process.
+ *
+ * @param {Object} [data] Window closing data
+ * @return {OO.ui.Process} Teardown process
+ */
+OO.ui.Window.prototype.getTeardownProcess = function () {
+       return new OO.ui.Process();
+};
+
+/**
+ * Set the window manager.
+ *
+ * This will cause the window to initialize. Calling it more than once will cause an error.
+ *
+ * @param {OO.ui.WindowManager} manager Manager for this window
+ * @throws {Error} An error is thrown if the method is called more than once
+ * @chainable
+ */
+OO.ui.Window.prototype.setManager = function ( manager ) {
+       if ( this.manager ) {
+               throw new Error( 'Cannot set window manager, window already has a manager' );
+       }
+
+       this.manager = manager;
+       this.initialize();
+
+       return this;
+};
+
+/**
+ * Set the window size by symbolic name (e.g., 'small' or 'medium')
+ *
+ * @param {string} size Symbolic name of size: `small`, `medium`, `large`, `larger` or
+ *  `full`
+ * @chainable
+ */
+OO.ui.Window.prototype.setSize = function ( size ) {
+       this.size = size;
+       this.updateSize();
+       return this;
+};
+
+/**
+ * Update the window size.
+ *
+ * @throws {Error} An error is thrown if the window is not attached to a window manager
+ * @chainable
+ */
+OO.ui.Window.prototype.updateSize = function () {
+       if ( !this.manager ) {
+               throw new Error( 'Cannot update window size, must be attached to a manager' );
+       }
+
+       this.manager.updateWindowSize( this );
+
+       return this;
+};
+
+/**
+ * Set window dimensions. This method is called by the {@link OO.ui.WindowManager window manager}
+ * when the window is opening. In general, setDimensions should not be called directly.
+ *
+ * To set the size of the window, use the #setSize method.
+ *
+ * @param {Object} dim CSS dimension properties
+ * @param {string|number} [dim.width] Width
+ * @param {string|number} [dim.minWidth] Minimum width
+ * @param {string|number} [dim.maxWidth] Maximum width
+ * @param {string|number} [dim.width] Height, omit to set based on height of contents
+ * @param {string|number} [dim.minWidth] Minimum height
+ * @param {string|number} [dim.maxWidth] Maximum height
+ * @chainable
+ */
+OO.ui.Window.prototype.setDimensions = function ( dim ) {
+       var height,
+               win = this,
+               styleObj = this.$frame[ 0 ].style;
+
+       // Calculate the height we need to set using the correct width
+       if ( dim.height === undefined ) {
+               this.withoutSizeTransitions( function () {
+                       var oldWidth = styleObj.width;
+                       win.$frame.css( 'width', dim.width || '' );
+                       height = win.getContentHeight();
+                       styleObj.width = oldWidth;
+               } );
+       } else {
+               height = dim.height;
+       }
+
+       this.$frame.css( {
+               width: dim.width || '',
+               minWidth: dim.minWidth || '',
+               maxWidth: dim.maxWidth || '',
+               height: height || '',
+               minHeight: dim.minHeight || '',
+               maxHeight: dim.maxHeight || ''
+       } );
+
+       return this;
+};
+
+/**
+ * Initialize window contents.
+ *
+ * Before the window is opened for the first time, #initialize is called so that content that
+ * persists between openings can be added to the window.
+ *
+ * To set up a window with new content each time the window opens, use #getSetupProcess.
+ *
+ * @throws {Error} An error is thrown if the window is not attached to a window manager
+ * @chainable
+ */
+OO.ui.Window.prototype.initialize = function () {
+       if ( !this.manager ) {
+               throw new Error( 'Cannot initialize window, must be attached to a manager' );
+       }
+
+       // Properties
+       this.$head = $( '<div>' );
+       this.$body = $( '<div>' );
+       this.$foot = $( '<div>' );
+       this.$document = $( this.getElementDocument() );
+
+       // Events
+       this.$element.on( 'mousedown', this.onMouseDown.bind( this ) );
+
+       // Initialization
+       this.$head.addClass( 'oo-ui-window-head' );
+       this.$body.addClass( 'oo-ui-window-body' );
+       this.$foot.addClass( 'oo-ui-window-foot' );
+       this.$content.append( this.$head, this.$body, this.$foot );
+
+       return this;
+};
+
+/**
+ * Called when someone tries to focus the hidden element at the end of the dialog.
+ * Sends focus back to the start of the dialog.
+ *
+ * @param {jQuery.Event} event Focus event
+ */
+OO.ui.Window.prototype.onFocusTrapFocused = function ( event ) {
+       if ( this.$focusTrapBefore.is( event.target ) ) {
+               OO.ui.findFocusable( this.$content, true ).focus();
+       } else {
+               // this.$content is the part of the focus cycle, and is the first focusable element
+               this.$content.focus();
+       }
+};
+
+/**
+ * Open the window.
+ *
+ * This method is a wrapper around a call to the window manager’s {@link OO.ui.WindowManager#openWindow openWindow}
+ * method, which returns a promise resolved when the window is done opening.
+ *
+ * To customize the window each time it opens, use #getSetupProcess or #getReadyProcess.
+ *
+ * @param {Object} [data] Window opening data
+ * @return {jQuery.Promise} Promise resolved with a value when the window is opened, or rejected
+ *  if the window fails to open. When the promise is resolved successfully, the first argument of the
+ *  value is a new promise, which is resolved when the window begins closing.
+ * @throws {Error} An error is thrown if the window is not attached to a window manager
+ */
+OO.ui.Window.prototype.open = function ( data ) {
+       if ( !this.manager ) {
+               throw new Error( 'Cannot open window, must be attached to a manager' );
+       }
+
+       return this.manager.openWindow( this, data );
+};
+
+/**
+ * Close the window.
+ *
+ * This method is a wrapper around a call to the window
+ * manager’s {@link OO.ui.WindowManager#closeWindow closeWindow} method,
+ * which returns a closing promise resolved when the window is done closing.
+ *
+ * The window's #getHoldProcess and #getTeardownProcess methods are called during the closing
+ * phase of the window’s lifecycle and can be used to specify closing behavior each time
+ * the window closes.
+ *
+ * @param {Object} [data] Window closing data
+ * @return {jQuery.Promise} Promise resolved when window is closed
+ * @throws {Error} An error is thrown if the window is not attached to a window manager
+ */
+OO.ui.Window.prototype.close = function ( data ) {
+       if ( !this.manager ) {
+               throw new Error( 'Cannot close window, must be attached to a manager' );
+       }
+
+       return this.manager.closeWindow( this, data );
+};
+
+/**
+ * Setup window.
+ *
+ * This is called by OO.ui.WindowManager during window opening, and should not be called directly
+ * by other systems.
+ *
+ * @param {Object} [data] Window opening data
+ * @return {jQuery.Promise} Promise resolved when window is setup
+ */
+OO.ui.Window.prototype.setup = function ( data ) {
+       var win = this;
+
+       this.toggle( true );
+
+       this.focusTrapHandler = OO.ui.bind( this.onFocusTrapFocused, this );
+       this.$focusTraps.on( 'focus', this.focusTrapHandler );
+
+       return this.getSetupProcess( data ).execute().then( function () {
+               // Force redraw by asking the browser to measure the elements' widths
+               win.$element.addClass( 'oo-ui-window-active oo-ui-window-setup' ).width();
+               win.$content.addClass( 'oo-ui-window-content-setup' ).width();
+       } );
+};
+
+/**
+ * Ready window.
+ *
+ * This is called by OO.ui.WindowManager during window opening, and should not be called directly
+ * by other systems.
+ *
+ * @param {Object} [data] Window opening data
+ * @return {jQuery.Promise} Promise resolved when window is ready
+ */
+OO.ui.Window.prototype.ready = function ( data ) {
+       var win = this;
+
+       this.$content.focus();
+       return this.getReadyProcess( data ).execute().then( function () {
+               // Force redraw by asking the browser to measure the elements' widths
+               win.$element.addClass( 'oo-ui-window-ready' ).width();
+               win.$content.addClass( 'oo-ui-window-content-ready' ).width();
+       } );
+};
+
+/**
+ * Hold window.
+ *
+ * This is called by OO.ui.WindowManager during window closing, and should not be called directly
+ * by other systems.
+ *
+ * @param {Object} [data] Window closing data
+ * @return {jQuery.Promise} Promise resolved when window is held
+ */
+OO.ui.Window.prototype.hold = function ( data ) {
+       var win = this;
+
+       return this.getHoldProcess( data ).execute().then( function () {
+               // Get the focused element within the window's content
+               var $focus = win.$content.find( OO.ui.Element.static.getDocument( win.$content ).activeElement );
+
+               // Blur the focused element
+               if ( $focus.length ) {
+                       $focus[ 0 ].blur();
+               }
+
+               // Force redraw by asking the browser to measure the elements' widths
+               win.$element.removeClass( 'oo-ui-window-ready' ).width();
+               win.$content.removeClass( 'oo-ui-window-content-ready' ).width();
+       } );
+};
+
+/**
+ * Teardown window.
+ *
+ * This is called by OO.ui.WindowManager during window closing, and should not be called directly
+ * by other systems.
+ *
+ * @param {Object} [data] Window closing data
+ * @return {jQuery.Promise} Promise resolved when window is torn down
+ */
+OO.ui.Window.prototype.teardown = function ( data ) {
+       var win = this;
+
+       return this.getTeardownProcess( data ).execute().then( 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.$focusTraps.off( 'focus', win.focusTrapHandler );
+               win.toggle( false );
+       } );
+};
+
+/**
+ * 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.parent.call( this, config );
+ *     }
+ *     OO.inheritClass( MyDialog, OO.ui.Dialog );
+ *     MyDialog.prototype.initialize = function () {
+ *         MyDialog.parent.prototype.initialize.call( this );
+ *         this.content = new OO.ui.PanelLayout( { padded: true, expanded: false } );
+ *         this.content.$element.append( '<p>A simple dialog window. Press \'Esc\' to close.</p>' );
+ *         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
+ * @extends OO.ui.Window
+ * @mixins OO.ui.mixin.PendingElement
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.Dialog = function OoUiDialog( config ) {
+       // Parent constructor
+       OO.ui.Dialog.parent.call( this, config );
+
+       // Mixin constructors
+       OO.ui.mixin.PendingElement.call( this );
+
+       // Properties
+       this.actions = new OO.ui.ActionSet();
+       this.attachedActions = [];
+       this.currentAction = null;
+       this.onDialogKeyDownHandler = this.onDialogKeyDown.bind( this );
+
+       // Events
+       this.actions.connect( this, {
+               click: 'onActionClick',
+               resize: 'onActionResize',
+               change: 'onActionsChange'
+       } );
+
+       // Initialization
+       this.$element
+               .addClass( 'oo-ui-dialog' )
+               .attr( 'role', 'dialog' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.Dialog, OO.ui.Window );
+OO.mixinClass( OO.ui.Dialog, OO.ui.mixin.PendingElement );
+
+/* Static Properties */
+
+/**
+ * Symbolic name of dialog.
+ *
+ * The dialog class must have a symbolic name in order to be registered with OO.Factory.
+ * Please see the [OOjs UI documentation on MediaWiki] [3] for more information.
+ *
+ * [3]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Window_managers
+ *
+ * @abstract
+ * @static
+ * @inheritable
+ * @property {string}
+ */
+OO.ui.Dialog.static.name = '';
+
+/**
+ * The dialog title.
+ *
+ * The title can be specified as a plaintext string, a {@link OO.ui.mixin.LabelElement Label} node, or a function
+ * that will produce a Label node or string. The title can also be specified with data passed to the
+ * constructor (see #getSetupProcess). In this case, the static value will be overridden.
+ *
+ * @abstract
+ * @static
+ * @inheritable
+ * @property {jQuery|string|Function}
+ */
+OO.ui.Dialog.static.title = '';
+
+/**
+ * An array of configured {@link OO.ui.ActionWidget action widgets}.
+ *
+ * Actions can also be specified with data passed to the constructor (see #getSetupProcess). In this case, the static
+ * value will be overridden.
+ *
+ * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Process_Dialogs#Action_sets
+ *
+ * @static
+ * @inheritable
+ * @property {Object[]}
+ */
+OO.ui.Dialog.static.actions = [];
+
+/**
+ * Close the dialog when the 'Esc' key is pressed.
+ *
+ * @static
+ * @abstract
+ * @inheritable
+ * @property {boolean}
+ */
+OO.ui.Dialog.static.escapable = true;
+
+/* Methods */
+
+/**
+ * Handle frame document key down events.
+ *
+ * @private
+ * @param {jQuery.Event} e Key down event
+ */
+OO.ui.Dialog.prototype.onDialogKeyDown = function ( e ) {
+       if ( e.which === OO.ui.Keys.ESCAPE ) {
+               this.executeAction( '' );
+               e.preventDefault();
+               e.stopPropagation();
+       }
+};
+
+/**
+ * Handle action resized events.
+ *
+ * @private
+ * @param {OO.ui.ActionWidget} action Action that was resized
+ */
+OO.ui.Dialog.prototype.onActionResize = function () {
+       // Override in subclass
+};
+
+/**
+ * Handle action click events.
+ *
+ * @private
+ * @param {OO.ui.ActionWidget} action Action that was clicked
+ */
+OO.ui.Dialog.prototype.onActionClick = function ( action ) {
+       if ( !this.isPending() ) {
+               this.executeAction( action.getAction() );
+       }
+};
+
+/**
+ * Handle actions change event.
+ *
+ * @private
+ */
+OO.ui.Dialog.prototype.onActionsChange = function () {
+       this.detachActions();
+       if ( !this.isClosing() ) {
+               this.attachActions();
+       }
+};
+
+/**
+ * Get the set of actions used by the dialog.
+ *
+ * @return {OO.ui.ActionSet}
+ */
+OO.ui.Dialog.prototype.getActions = function () {
+       return this.actions;
+};
+
+/**
+ * Get a process for taking action.
+ *
+ * When you override this method, you can create a new OO.ui.Process and return it, or add additional
+ * accept steps to the process the parent method provides using the {@link OO.ui.Process#first 'first'}
+ * and {@link OO.ui.Process#next 'next'} methods of OO.ui.Process.
+ *
+ * @param {string} [action] Symbolic name of action
+ * @return {OO.ui.Process} Action process
+ */
+OO.ui.Dialog.prototype.getActionProcess = function ( action ) {
+       return new OO.ui.Process()
+               .next( function () {
+                       if ( !action ) {
+                               // An empty action always closes the dialog without data, which should always be
+                               // safe and make no changes
+                               this.close();
+                       }
+               }, this );
+};
+
+/**
+ * @inheritdoc
+ *
+ * @param {Object} [data] Dialog opening data
+ * @param {jQuery|string|Function|null} [data.title] Dialog title, omit to use
+ *  the {@link #static-title static title}
+ * @param {Object[]} [data.actions] List of configuration options for each
+ *   {@link OO.ui.ActionWidget action widget}, omit to use {@link #static-actions static actions}.
+ */
+OO.ui.Dialog.prototype.getSetupProcess = function ( data ) {
+       data = data || {};
+
+       // Parent method
+       return OO.ui.Dialog.parent.prototype.getSetupProcess.call( this, data )
+               .next( function () {
+                       var config = this.constructor.static,
+                               actions = data.actions !== undefined ? data.actions : config.actions;
+
+                       this.title.setLabel(
+                               data.title !== undefined ? data.title : this.constructor.static.title
+                       );
+                       this.actions.add( this.getActionWidgets( actions ) );
+
+                       if ( this.constructor.static.escapable ) {
+                               this.$element.on( 'keydown', this.onDialogKeyDownHandler );
+                       }
+               }, this );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.Dialog.prototype.getTeardownProcess = function ( data ) {
+       // Parent method
+       return OO.ui.Dialog.parent.prototype.getTeardownProcess.call( this, data )
+               .first( function () {
+                       if ( this.constructor.static.escapable ) {
+                               this.$element.off( 'keydown', this.onDialogKeyDownHandler );
+                       }
+
+                       this.actions.clear();
+                       this.currentAction = null;
+               }, this );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.Dialog.prototype.initialize = function () {
+       var titleId;
+
+       // Parent method
+       OO.ui.Dialog.parent.prototype.initialize.call( this );
+
+       titleId = OO.ui.generateElementId();
+
+       // Properties
+       this.title = new OO.ui.LabelWidget( {
+               id: titleId
+       } );
+
+       // Initialization
+       this.$content.addClass( 'oo-ui-dialog-content' );
+       this.$element.attr( 'aria-labelledby', titleId );
+       this.setPendingElement( this.$head );
+};
+
+/**
+ * Get action widgets from a list of configs
+ *
+ * @param {Object[]} actions Action widget configs
+ * @return {OO.ui.ActionWidget[]} Action widgets
+ */
+OO.ui.Dialog.prototype.getActionWidgets = function ( actions ) {
+       var i, len, widgets = [];
+       for ( i = 0, len = actions.length; i < len; i++ ) {
+               widgets.push(
+                       new OO.ui.ActionWidget( actions[ i ] )
+               );
+       }
+       return widgets;
+};
+
+/**
+ * Attach action actions.
+ *
+ * @protected
+ */
+OO.ui.Dialog.prototype.attachActions = function () {
+       // Remember the list of potentially attached actions
+       this.attachedActions = this.actions.get();
+};
+
+/**
+ * Detach action actions.
+ *
+ * @protected
+ * @chainable
+ */
+OO.ui.Dialog.prototype.detachActions = function () {
+       var i, len;
+
+       // 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 = [];
+};
+
+/**
+ * Execute an action.
+ *
+ * @param {string} action Symbolic name of action to execute
+ * @return {jQuery.Promise} Promise resolved when action completes, rejected if it fails
+ */
+OO.ui.Dialog.prototype.executeAction = function ( action ) {
+       this.pushPending();
+       this.currentAction = action;
+       return this.getActionProcess( action ).execute()
+               .always( this.popPending.bind( this ) );
+};
+
+/**
+ * MessageDialogs display a confirmation or alert message. By default, the rendered dialog box
+ * consists of a header that contains the dialog title, a body with the message, and a footer that
+ * contains any {@link OO.ui.ActionWidget action widgets}. The MessageDialog class is the only type
+ * of {@link OO.ui.Dialog dialog} that is usually instantiated directly.
+ *
+ * There are two basic types of message dialogs, confirmation and alert:
+ *
+ * - **confirmation**: the dialog title describes what a progressive action will do and the message provides
+ *  more details about the consequences.
+ * - **alert**: the dialog title describes which event occurred and the message provides more information
+ *  about why the event occurred.
+ *
+ * The MessageDialog class specifies two actions: ‘accept’, the primary
+ * action (e.g., ‘ok’) and ‘reject,’ the safe action (e.g., ‘cancel’). Both will close the window,
+ * passing along the selected action.
+ *
+ * For more information and examples, please see the [OOjs UI documentation on MediaWiki][1].
+ *
+ *     @example
+ *     // Example: Creating and opening a message dialog window.
+ *     var messageDialog = new OO.ui.MessageDialog();
+ *
+ *     // Create and append a window manager.
+ *     var windowManager = new OO.ui.WindowManager();
+ *     $( 'body' ).append( windowManager.$element );
+ *     windowManager.addWindows( [ messageDialog ] );
+ *     // Open the window.
+ *     windowManager.openWindow( messageDialog, {
+ *         title: 'Basic message dialog',
+ *         message: 'This is the message'
+ *     } );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Message_Dialogs
+ *
+ * @class
+ * @extends OO.ui.Dialog
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.MessageDialog = function OoUiMessageDialog( config ) {
+       // Parent constructor
+       OO.ui.MessageDialog.parent.call( this, config );
+
+       // Properties
+       this.verticalActionLayout = null;
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-messageDialog' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.MessageDialog, OO.ui.Dialog );
+
+/* Static Properties */
+
+OO.ui.MessageDialog.static.name = 'message';
+
+OO.ui.MessageDialog.static.size = 'small';
+
+OO.ui.MessageDialog.static.verbose = false;
+
+/**
+ * Dialog title.
+ *
+ * The title of a confirmation dialog describes what a progressive action will do. The
+ * title of an alert dialog describes which event occurred.
+ *
+ * @static
+ * @inheritable
+ * @property {jQuery|string|Function|null}
+ */
+OO.ui.MessageDialog.static.title = null;
+
+/**
+ * The message displayed in the dialog body.
+ *
+ * A confirmation message describes the consequences of a progressive action. An alert
+ * message describes why an event occurred.
+ *
+ * @static
+ * @inheritable
+ * @property {jQuery|string|Function|null}
+ */
+OO.ui.MessageDialog.static.message = null;
+
+// Note that OO.ui.alert() and OO.ui.confirm() rely on these.
+OO.ui.MessageDialog.static.actions = [
+       { action: 'accept', label: OO.ui.deferMsg( 'ooui-dialog-message-accept' ), flags: 'primary' },
+       { action: 'reject', label: OO.ui.deferMsg( 'ooui-dialog-message-reject' ), flags: 'safe' }
+];
+
+/* Methods */
+
+/**
+ * @inheritdoc
+ */
+OO.ui.MessageDialog.prototype.setManager = function ( manager ) {
+       OO.ui.MessageDialog.parent.prototype.setManager.call( this, manager );
+
+       // Events
+       this.manager.connect( this, {
+               resize: 'onResize'
+       } );
+
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.MessageDialog.prototype.onActionResize = function ( action ) {
+       this.fitActions();
+       return OO.ui.MessageDialog.parent.prototype.onActionResize.call( this, action );
+};
+
+/**
+ * Handle window resized events.
+ *
+ * @private
+ */
+OO.ui.MessageDialog.prototype.onResize = function () {
+       var dialog = this;
+       dialog.fitActions();
+       // Wait for CSS transition to finish and do it again :(
+       setTimeout( function () {
+               dialog.fitActions();
+       }, 300 );
+};
+
+/**
+ * Toggle action layout between vertical and horizontal.
+ *
+ * @private
+ * @param {boolean} [value] Layout actions vertically, omit to toggle
+ * @chainable
+ */
+OO.ui.MessageDialog.prototype.toggleVerticalActionLayout = function ( value ) {
+       value = value === undefined ? !this.verticalActionLayout : !!value;
+
+       if ( value !== this.verticalActionLayout ) {
+               this.verticalActionLayout = value;
+               this.$actions
+                       .toggleClass( 'oo-ui-messageDialog-actions-vertical', value )
+                       .toggleClass( 'oo-ui-messageDialog-actions-horizontal', !value );
+       }
+
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.MessageDialog.prototype.getActionProcess = function ( action ) {
+       if ( action ) {
+               return new OO.ui.Process( function () {
+                       this.close( { action: action } );
+               }, this );
+       }
+       return OO.ui.MessageDialog.parent.prototype.getActionProcess.call( this, action );
+};
+
+/**
+ * @inheritdoc
+ *
+ * @param {Object} [data] Dialog opening data
+ * @param {jQuery|string|Function|null} [data.title] Description of the action being confirmed
+ * @param {jQuery|string|Function|null} [data.message] Description of the action's consequence
+ * @param {boolean} [data.verbose] Message is verbose and should be styled as a long message
+ * @param {Object[]} [data.actions] List of OO.ui.ActionOptionWidget configuration options for each
+ *   action item
+ */
+OO.ui.MessageDialog.prototype.getSetupProcess = function ( data ) {
+       data = data || {};
+
+       // Parent method
+       return OO.ui.MessageDialog.parent.prototype.getSetupProcess.call( this, data )
+               .next( function () {
+                       this.title.setLabel(
+                               data.title !== undefined ? data.title : this.constructor.static.title
+                       );
+                       this.message.setLabel(
+                               data.message !== undefined ? data.message : this.constructor.static.message
+                       );
+                       this.message.$element.toggleClass(
+                               'oo-ui-messageDialog-message-verbose',
+                               data.verbose !== undefined ? data.verbose : this.constructor.static.verbose
+                       );
+               }, this );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.MessageDialog.prototype.getReadyProcess = function ( data ) {
+       data = data || {};
+
+       // Parent method
+       return OO.ui.MessageDialog.parent.prototype.getReadyProcess.call( this, data )
+               .next( function () {
+                       // Focus the primary action button
+                       var actions = this.actions.get();
+                       actions = actions.filter( function ( action ) {
+                               return action.getFlags().indexOf( 'primary' ) > -1;
+                       } );
+                       if ( actions.length > 0 ) {
+                               actions[ 0 ].$button.focus();
+                       }
+               }, this );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.MessageDialog.prototype.getBodyHeight = function () {
+       var bodyHeight, oldOverflow,
+               $scrollable = this.container.$element;
+
+       oldOverflow = $scrollable[ 0 ].style.overflow;
+       $scrollable[ 0 ].style.overflow = 'hidden';
+
+       OO.ui.Element.static.reconsiderScrollbars( $scrollable[ 0 ] );
+
+       bodyHeight = this.text.$element.outerHeight( true );
+       $scrollable[ 0 ].style.overflow = oldOverflow;
+
+       return bodyHeight;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.MessageDialog.prototype.setDimensions = function ( dim ) {
+       var $scrollable = this.container.$element;
+       OO.ui.MessageDialog.parent.prototype.setDimensions.call( this, 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';
+
+               OO.ui.Element.static.reconsiderScrollbars( $scrollable[ 0 ] );
+
+               $scrollable[ 0 ].style.overflow = oldOverflow;
+       }, 300 );
+
+       return this;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.MessageDialog.prototype.initialize = function () {
+       // Parent method
+       OO.ui.MessageDialog.parent.prototype.initialize.call( this );
+
+       // Properties
+       this.$actions = $( '<div>' );
+       this.container = new OO.ui.PanelLayout( {
+               scrollable: true, classes: [ 'oo-ui-messageDialog-container' ]
+       } );
+       this.text = new OO.ui.PanelLayout( {
+               padded: true, expanded: false, classes: [ 'oo-ui-messageDialog-text' ]
+       } );
+       this.message = new OO.ui.LabelWidget( {
+               classes: [ 'oo-ui-messageDialog-message' ]
+       } );
+
+       // Initialization
+       this.title.$element.addClass( 'oo-ui-messageDialog-title' );
+       this.$content.addClass( 'oo-ui-messageDialog-content' );
+       this.container.$element.append( this.text.$element );
+       this.text.$element.append( this.title.$element, this.message.$element );
+       this.$body.append( this.container.$element );
+       this.$actions.addClass( 'oo-ui-messageDialog-actions' );
+       this.$foot.append( this.$actions );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.MessageDialog.prototype.attachActions = function () {
+       var i, len, other, special, others;
+
+       // Parent method
+       OO.ui.MessageDialog.parent.prototype.attachActions.call( this );
+
+       special = this.actions.getSpecial();
+       others = this.actions.getOthers();
+
+       if ( special.safe ) {
+               this.$actions.append( special.safe.$element );
+               special.safe.toggleFramed( false );
+       }
+       if ( others.length ) {
+               for ( i = 0, len = others.length; i < len; i++ ) {
+                       other = others[ i ];
+                       this.$actions.append( other.$element );
+                       other.toggleFramed( false );
+               }
+       }
+       if ( special.primary ) {
+               this.$actions.append( special.primary.$element );
+               special.primary.toggleFramed( false );
+       }
+
+       if ( !this.isOpening() ) {
+               // If the dialog is currently opening, this will be called automatically soon.
+               // This also calls #fitActions.
+               this.updateSize();
+       }
+};
+
+/**
+ * Fit action actions into columns or rows.
+ *
+ * Columns will be used if all labels can fit without overflow, otherwise rows will be used.
+ *
+ * @private
+ */
+OO.ui.MessageDialog.prototype.fitActions = function () {
+       var i, len, action,
+               previous = this.verticalActionLayout,
+               actions = this.actions.get();
+
+       // Detect clipping
+       this.toggleVerticalActionLayout( false );
+       for ( i = 0, len = actions.length; i < len; 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 ) {
+               // We changed the layout, window height might need to be updated.
+               this.updateSize();
+       }
+};
+
+/**
+ * ProcessDialog windows encapsulate a {@link OO.ui.Process process} and all of the code necessary
+ * to complete it. If the process terminates with an error, a customizable {@link OO.ui.Error error
+ * interface} alerts users to the trouble, permitting the user to dismiss the error and try again when
+ * relevant. The ProcessDialog class is always extended and customized with the actions and content
+ * required for each process.
+ *
+ * The process dialog box consists of a header that visually represents the ‘working’ state of long
+ * processes with an animation. The header contains the dialog title as well as
+ * two {@link OO.ui.ActionWidget action widgets}:  a ‘safe’ action on the left (e.g., ‘Cancel’) and
+ * a ‘primary’ action on the right (e.g., ‘Done’).
+ *
+ * Like other windows, the process dialog is managed by a {@link OO.ui.WindowManager window manager}.
+ * Please see the [OOjs UI documentation on MediaWiki][1] for more information and examples.
+ *
+ *     @example
+ *     // Example: Creating and opening a process dialog window.
+ *     function MyProcessDialog( config ) {
+ *         MyProcessDialog.parent.call( this, config );
+ *     }
+ *     OO.inheritClass( MyProcessDialog, OO.ui.ProcessDialog );
+ *
+ *     MyProcessDialog.static.title = 'Process dialog';
+ *     MyProcessDialog.static.actions = [
+ *         { action: 'save', label: 'Done', flags: 'primary' },
+ *         { label: 'Cancel', flags: 'safe' }
+ *     ];
+ *
+ *     MyProcessDialog.prototype.initialize = function () {
+ *         MyProcessDialog.parent.prototype.initialize.apply( this, arguments );
+ *         this.content = new OO.ui.PanelLayout( { padded: true, expanded: false } );
+ *         this.content.$element.append( '<p>This is a process dialog window. The header contains the title and two buttons: \'Cancel\' (a safe action) on the left and \'Done\' (a primary action)  on the right.</p>' );
+ *         this.$body.append( this.content.$element );
+ *     };
+ *     MyProcessDialog.prototype.getActionProcess = function ( action ) {
+ *         var dialog = this;
+ *         if ( action ) {
+ *             return new OO.ui.Process( function () {
+ *                 dialog.close( { action: action } );
+ *             } );
+ *         }
+ *         return MyProcessDialog.parent.prototype.getActionProcess.call( this, action );
+ *     };
+ *
+ *     var windowManager = new OO.ui.WindowManager();
+ *     $( 'body' ).append( windowManager.$element );
+ *
+ *     var dialog = new MyProcessDialog();
+ *     windowManager.addWindows( [ dialog ] );
+ *     windowManager.openWindow( dialog );
+ *
+ * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Process_Dialogs
+ *
+ * @abstract
+ * @class
+ * @extends OO.ui.Dialog
+ *
+ * @constructor
+ * @param {Object} [config] Configuration options
+ */
+OO.ui.ProcessDialog = function OoUiProcessDialog( config ) {
+       // Parent constructor
+       OO.ui.ProcessDialog.parent.call( this, config );
+
+       // Properties
+       this.fitOnOpen = false;
+
+       // Initialization
+       this.$element.addClass( 'oo-ui-processDialog' );
+};
+
+/* Setup */
+
+OO.inheritClass( OO.ui.ProcessDialog, OO.ui.Dialog );
+
+/* Methods */
+
+/**
+ * Handle dismiss button click events.
+ *
+ * Hides errors.
+ *
+ * @private
+ */
+OO.ui.ProcessDialog.prototype.onDismissErrorButtonClick = function () {
+       this.hideErrors();
+};
+
+/**
+ * Handle retry button click events.
+ *
+ * Hides errors and then tries again.
+ *
+ * @private
+ */
+OO.ui.ProcessDialog.prototype.onRetryButtonClick = function () {
+       this.hideErrors();
+       this.executeAction( this.currentAction );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.ProcessDialog.prototype.onActionResize = function ( action ) {
+       if ( this.actions.isSpecial( action ) ) {
+               this.fitLabel();
+       }
+       return OO.ui.ProcessDialog.parent.prototype.onActionResize.call( this, action );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.ProcessDialog.prototype.initialize = function () {
+       // Parent method
+       OO.ui.ProcessDialog.parent.prototype.initialize.call( this );
+
+       // Properties
+       this.$navigation = $( '<div>' );
+       this.$location = $( '<div>' );
+       this.$safeActions = $( '<div>' );
+       this.$primaryActions = $( '<div>' );
+       this.$otherActions = $( '<div>' );
+       this.dismissButton = new OO.ui.ButtonWidget( {
+               label: OO.ui.msg( 'ooui-dialog-process-dismiss' )
+       } );
+       this.retryButton = new OO.ui.ButtonWidget();
+       this.$errors = $( '<div>' );
+       this.$errorsTitle = $( '<div>' );
+
+       // Events
+       this.dismissButton.connect( this, { click: 'onDismissErrorButtonClick' } );
+       this.retryButton.connect( this, { click: 'onRetryButtonClick' } );
+
+       // Initialization
+       this.title.$element.addClass( 'oo-ui-processDialog-title' );
+       this.$location
+               .append( this.title.$element )
+               .addClass( 'oo-ui-processDialog-location' );
+       this.$safeActions.addClass( 'oo-ui-processDialog-actions-safe' );
+       this.$primaryActions.addClass( 'oo-ui-processDialog-actions-primary' );
+       this.$otherActions.addClass( 'oo-ui-processDialog-actions-other' );
+       this.$errorsTitle
+               .addClass( 'oo-ui-processDialog-errors-title' )
+               .text( OO.ui.msg( 'ooui-dialog-process-error' ) );
+       this.$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' )
+               .append( this.$errors );
+       this.$navigation
+               .addClass( 'oo-ui-processDialog-navigation' )
+               .append( this.$safeActions, this.$location, this.$primaryActions );
+       this.$head.append( this.$navigation );
+       this.$foot.append( this.$otherActions );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.ProcessDialog.prototype.getActionWidgets = function ( actions ) {
+       var i, len, widgets = [];
+       for ( i = 0, len = actions.length; i < len; i++ ) {
+               widgets.push(
+                       new OO.ui.ActionWidget( $.extend( { framed: true }, actions[ i ] ) )
+               );
+       }
+       return widgets;
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.ProcessDialog.prototype.attachActions = function () {
+       var i, len, other, special, others;
+
+       // Parent method
+       OO.ui.ProcessDialog.parent.prototype.attachActions.call( this );
+
+       special = this.actions.getSpecial();
+       others = this.actions.getOthers();
+       if ( special.primary ) {
+               this.$primaryActions.append( special.primary.$element );
+       }
+       for ( i = 0, len = others.length; i < len; i++ ) {
+               other = others[ i ];
+               this.$otherActions.append( other.$element );
+       }
+       if ( special.safe ) {
+               this.$safeActions.append( special.safe.$element );
+       }
+
+       this.fitLabel();
+       this.$body.css( 'bottom', this.$foot.outerHeight( true ) );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.ProcessDialog.prototype.executeAction = function ( action ) {
+       var process = this;
+       return OO.ui.ProcessDialog.parent.prototype.executeAction.call( this, action )
+               .fail( function ( errors ) {
+                       process.showErrors( errors || [] );
+               } );
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.ProcessDialog.prototype.setDimensions = function () {
+       // Parent method
+       OO.ui.ProcessDialog.parent.prototype.setDimensions.apply( this, arguments );
+
+       this.fitLabel();
+};
+
+/**
+ * Fit label between actions.
+ *
+ * @private
+ * @chainable
+ */
+OO.ui.ProcessDialog.prototype.fitLabel = function () {
+       var safeWidth, primaryWidth, biggerWidth, labelWidth, navigationWidth, leftWidth, rightWidth,
+               size = this.getSizeProperties();
+
+       if ( typeof size.width !== 'number' ) {
+               if ( this.isOpened() ) {
+                       navigationWidth = this.$head.width() - 20;
+               } else if ( this.isOpening() ) {
+                       if ( !this.fitOnOpen ) {
+                               // Size is relative and the dialog isn't open yet, so wait.
+                               this.manager.opening.done( this.fitLabel.bind( this ) );
+                               this.fitOnOpen = true;
+                       }
+                       return;
+               } else {
+                       return;
+               }
+       } else {
+               navigationWidth = size.width - 20;
+       }
+
+       safeWidth = this.$safeActions.is( ':visible' ) ? this.$safeActions.width() : 0;
+       primaryWidth = this.$primaryActions.is( ':visible' ) ? this.$primaryActions.width() : 0;
+       biggerWidth = Math.max( safeWidth, primaryWidth );
+
+       labelWidth = this.title.$element.width();
+
+       if ( 2 * biggerWidth + labelWidth < navigationWidth ) {
+               // We have enough space to center the label
+               leftWidth = rightWidth = biggerWidth;
+       } else {
+               // Let's hope we at least have enough space not to overlap, because we can't wrap the label…
+               if ( this.getDir() === 'ltr' ) {
+                       leftWidth = safeWidth;
+                       rightWidth = primaryWidth;
+               } else {
+                       leftWidth = primaryWidth;
+                       rightWidth = safeWidth;
+               }
+       }
+
+       this.$location.css( { paddingLeft: leftWidth, paddingRight: rightWidth } );
+
+       return this;
+};
+
+/**
+ * Handle errors that occurred during accept or reject processes.
+ *
+ * @private
+ * @param {OO.ui.Error[]|OO.ui.Error} errors Errors to be handled
+ */
+OO.ui.ProcessDialog.prototype.showErrors = function ( errors ) {
+       var i, len, $item, actions,
+               items = [],
+               abilities = {},
+               recoverable = true,
+               warning = false;
+
+       if ( errors instanceof OO.ui.Error ) {
+               errors = [ errors ];
+       }
+
+       for ( i = 0, len = errors.length; i < len; i++ ) {
+               if ( !errors[ i ].isRecoverable() ) {
+                       recoverable = false;
+               }
+               if ( errors[ i ].isWarning() ) {
+                       warning = true;
+               }
+               $item = $( '<div>' )
+                       .addClass( 'oo-ui-processDialog-error' )
+                       .append( errors[ i ].getMessage() );
+               items.push( $item[ 0 ] );
+       }
+       this.$errorItems = $( items );
+       if ( recoverable ) {
+               abilities[ this.currentAction ] = true;
+               // Copy the flags from the first matching action
+               actions = this.actions.get( { actions: this.currentAction } );
+               if ( actions.length ) {
+                       this.retryButton.clearFlags().setFlags( actions[ 0 ].getFlags() );
+               }
+       } else {
+               abilities[ this.currentAction ] = false;
+               this.actions.setAbilities( abilities );
+       }
+       if ( warning ) {
+               this.retryButton.setLabel( OO.ui.msg( 'ooui-dialog-process-continue' ) );
+       } else {
+               this.retryButton.setLabel( OO.ui.msg( 'ooui-dialog-process-retry' ) );
+       }
+       this.retryButton.toggle( recoverable );
+       this.$errorsTitle.after( this.$errorItems );
+       this.$errors.removeClass( 'oo-ui-element-hidden' ).scrollTop( 0 );
+};
+
+/**
+ * Hide errors.
+ *
+ * @private
+ */
+OO.ui.ProcessDialog.prototype.hideErrors = function () {
+       this.$errors.addClass( 'oo-ui-element-hidden' );
+       if ( this.$errorItems ) {
+               this.$errorItems.remove();
+               this.$errorItems = null;
+       }
+};
+
+/**
+ * @inheritdoc
+ */
+OO.ui.ProcessDialog.prototype.getTeardownProcess = function ( data ) {
+       // Parent method
+       return OO.ui.ProcessDialog.parent.prototype.getTeardownProcess.call( this, data )
+               .first( function () {
+                       // Make sure to hide errors
+                       this.hideErrors();
+                       this.fitOnOpen = false;
+               }, this );
+};
+
+/**
+ * @class OO.ui
+ */
+
+/**
+ * Lazy-initialize and return a global OO.ui.WindowManager instance, used by OO.ui.alert and
+ * OO.ui.confirm.
+ *
+ * @private
+ * @return {OO.ui.WindowManager}
+ */
+OO.ui.getWindowManager = function () {
+       if ( !OO.ui.windowManager ) {
+               OO.ui.windowManager = new OO.ui.WindowManager();
+               $( 'body' ).append( OO.ui.windowManager.$element );
+               OO.ui.windowManager.addWindows( {
+                       messageDialog: new OO.ui.MessageDialog()
+               } );
+       }
+       return OO.ui.windowManager;
+};
+
+/**
+ * Display a quick modal alert dialog, using a OO.ui.MessageDialog. While the dialog is open, the
+ * rest of the page will be dimmed out and the user won't be able to interact with it. The dialog
+ * has only one action button, labelled "OK", clicking it will simply close the dialog.
+ *
+ * A window manager is created automatically when this function is called for the first time.
+ *
+ *     @example
+ *     OO.ui.alert( 'Something happened!' ).done( function () {
+ *         console.log( 'User closed the dialog.' );
+ *     } );
+ *
+ * @param {jQuery|string} text Message text to display
+ * @param {Object} [options] Additional options, see OO.ui.MessageDialog#getSetupProcess
+ * @return {jQuery.Promise} Promise resolved when the user closes the dialog
+ */
+OO.ui.alert = function ( text, options ) {
+       return OO.ui.getWindowManager().openWindow( 'messageDialog', $.extend( {
+               message: text,
+               verbose: true,
+               actions: [ OO.ui.MessageDialog.static.actions[ 0 ] ]
+       }, options ) ).then( function ( opened ) {
+               return opened.then( function ( closing ) {
+                       return closing.then( function () {
+                               return $.Deferred().resolve();
+                       } );
+               } );
+       } );
+};
+
+/**
+ * Display a quick modal confirmation dialog, using a OO.ui.MessageDialog. While the dialog is open,
+ * the rest of the page will be dimmed out and the user won't be able to interact with it. The
+ * dialog has two action buttons, one to confirm an operation (labelled "OK") and one to cancel it
+ * (labelled "Cancel").
+ *
+ * A window manager is created automatically when this function is called for the first time.
+ *
+ *     @example
+ *     OO.ui.confirm( 'Are you sure?' ).done( function ( confirmed ) {
+ *         if ( confirmed ) {
+ *             console.log( 'User clicked "OK"!' );
+ *         } else {
+ *             console.log( 'User clicked "Cancel" or closed the dialog.' );
+ *         }
+ *     } );
+ *
+ * @param {jQuery|string} text Message text to display
+ * @param {Object} [options] Additional options, see OO.ui.MessageDialog#getSetupProcess
+ * @return {jQuery.Promise} Promise resolved when the user closes the dialog. If the user chose to
+ *  confirm, the promise will resolve to boolean `true`; otherwise, it will resolve to boolean
+ *  `false`.
+ */
+OO.ui.confirm = function ( text, options ) {
+       return OO.ui.getWindowManager().openWindow( 'messageDialog', $.extend( {
+               message: text,
+               verbose: true
+       }, options ) ).then( function ( opened ) {
+               return opened.then( function ( closing ) {
+                       return closing.then( function ( data ) {
+                               return $.Deferred().resolve( !!( data && data.action === 'accept' ) );
+                       } );
+               } );
+       } );
+};
+
+}( OO ) );
diff --git a/resources/lib/oojs-ui/oojs-ui.js b/resources/lib/oojs-ui/oojs-ui.js
deleted file mode 100644 (file)
index 9bcdf7e..0000000
+++ /dev/null
@@ -1,20201 +0,0 @@
-/*!
- * OOjs UI v0.15.2
- * https://www.mediawiki.org/wiki/OOjs_UI
- *
- * Copyright 2011–2016 OOjs UI Team and other contributors.
- * Released under the MIT license
- * http://oojs.mit-license.org
- *
- * Date: 2016-02-02T22:07:00Z
- */
-( function ( OO ) {
-
-'use strict';
-
-/**
- * Namespace for all classes, static methods and static properties.
- *
- * @class
- * @singleton
- */
-OO.ui = {};
-
-OO.ui.bind = $.proxy;
-
-/**
- * @property {Object}
- */
-OO.ui.Keys = {
-       UNDEFINED: 0,
-       BACKSPACE: 8,
-       DELETE: 46,
-       LEFT: 37,
-       RIGHT: 39,
-       UP: 38,
-       DOWN: 40,
-       ENTER: 13,
-       END: 35,
-       HOME: 36,
-       TAB: 9,
-       PAGEUP: 33,
-       PAGEDOWN: 34,
-       ESCAPE: 27,
-       SHIFT: 16,
-       SPACE: 32
-};
-
-/**
- * Constants for MouseEvent.which
- *
- * @property {Object}
- */
-OO.ui.MouseButtons = {
-       LEFT: 1,
-       MIDDLE: 2,
-       RIGHT: 3
-};
-
-/**
- * @property {Number}
- */
-OO.ui.elementId = 0;
-
-/**
- * Generate a unique ID for element
- *
- * @return {String} [id]
- */
-OO.ui.generateElementId = function () {
-       OO.ui.elementId += 1;
-       return 'oojsui-' + OO.ui.elementId;
-};
-
-/**
- * Check if an element is focusable.
- * Inspired from :focusable in jQueryUI v1.11.4 - 2015-04-14
- *
- * @param {jQuery} element Element to test
- * @return {boolean}
- */
-OO.ui.isFocusableElement = function ( $element ) {
-       var nodeName,
-               element = $element[ 0 ];
-
-       // Anything disabled is not focusable
-       if ( element.disabled ) {
-               return false;
-       }
-
-       // Check if the element is visible
-       if ( !(
-               // This is quicker than calling $element.is( ':visible' )
-               $.expr.filters.visible( element ) &&
-               // Check that all parents are visible
-               !$element.parents().addBack().filter( function () {
-                       return $.css( this, 'visibility' ) === 'hidden';
-               } ).length
-       ) ) {
-               return false;
-       }
-
-       // Check if the element is ContentEditable, which is the string 'true'
-       if ( element.contentEditable === 'true' ) {
-               return true;
-       }
-
-       // Anything with a non-negative numeric tabIndex is focusable.
-       // Use .prop to avoid browser bugs
-       if ( $element.prop( 'tabIndex' ) >= 0 ) {
-               return true;
-       }
-
-       // Some element types are naturally focusable
-       // (indexOf is much faster than regex in Chrome and about the
-       // same in FF: https://jsperf.com/regex-vs-indexof-array2)
-       nodeName = element.nodeName.toLowerCase();
-       if ( [ 'input', 'select', 'textarea', 'button', 'object' ].indexOf( nodeName ) !== -1 ) {
-               return true;
-       }
-
-       // Links and areas are focusable if they have an href
-       if ( ( nodeName === 'a' || nodeName === 'area' ) && $element.attr( 'href' ) !== undefined ) {
-               return true;
-       }
-
-       return false;
-};
-
-/**
- * Find a focusable child
- *
- * @param {jQuery} $container Container to search in
- * @param {boolean} [backwards] Search backwards
- * @return {jQuery} Focusable child, an empty jQuery object if none found
- */
-OO.ui.findFocusable = function ( $container, backwards ) {
-       var $focusable = $( [] ),
-               // $focusableCandidates is a superset of things that
-               // could get matched by isFocusableElement
-               $focusableCandidates = $container
-                       .find( 'input, select, textarea, button, object, a, area, [contenteditable], [tabindex]' );
-
-       if ( backwards ) {
-               $focusableCandidates = Array.prototype.reverse.call( $focusableCandidates );
-       }
-
-       $focusableCandidates.each( function () {
-               var $this = $( this );
-               if ( OO.ui.isFocusableElement( $this ) ) {
-                       $focusable = $this;
-                       return false;
-               }
-       } );
-       return $focusable;
-};
-
-/**
- * Get the user's language and any fallback languages.
- *
- * These language codes are used to localize user interface elements in the user's language.
- *
- * In environments that provide a localization system, this function should be overridden to
- * return the user's language(s). The default implementation returns English (en) only.
- *
- * @return {string[]} Language codes, in descending order of priority
- */
-OO.ui.getUserLanguages = function () {
-       return [ 'en' ];
-};
-
-/**
- * Get a value in an object keyed by language code.
- *
- * @param {Object.<string,Mixed>} obj Object keyed by language code
- * @param {string|null} [lang] Language code, if omitted or null defaults to any user language
- * @param {string} [fallback] Fallback code, used if no matching language can be found
- * @return {Mixed} Local value
- */
-OO.ui.getLocalValue = function ( obj, lang, fallback ) {
-       var i, len, langs;
-
-       // Requested language
-       if ( obj[ lang ] ) {
-               return obj[ lang ];
-       }
-       // Known user language
-       langs = OO.ui.getUserLanguages();
-       for ( i = 0, len = langs.length; i < len; i++ ) {
-               lang = langs[ i ];
-               if ( obj[ lang ] ) {
-                       return obj[ lang ];
-               }
-       }
-       // Fallback language
-       if ( obj[ fallback ] ) {
-               return obj[ fallback ];
-       }
-       // First existing language
-       for ( lang in obj ) {
-               return obj[ lang ];
-       }
-
-       return undefined;
-};
-
-/**
- * Check if a node is contained within another node
- *
- * Similar to jQuery#contains except a list of containers can be supplied
- * and a boolean argument allows you to include the container in the match list
- *
- * @param {HTMLElement|HTMLElement[]} containers Container node(s) to search in
- * @param {HTMLElement} contained Node to find
- * @param {boolean} [matchContainers] Include the container(s) in the list of nodes to match, otherwise only match descendants
- * @return {boolean} The node is in the list of target nodes
- */
-OO.ui.contains = function ( containers, contained, matchContainers ) {
-       var i;
-       if ( !Array.isArray( containers ) ) {
-               containers = [ containers ];
-       }
-       for ( i = containers.length - 1; i >= 0; i-- ) {
-               if ( ( matchContainers && contained === containers[ i ] ) || $.contains( containers[ i ], contained ) ) {
-                       return true;
-               }
-       }
-       return false;
-};
-
-/**
- * Return a function, that, as long as it continues to be invoked, will not
- * be triggered. The function will be called after it stops being called for
- * N milliseconds. If `immediate` is passed, trigger the function on the
- * leading edge, instead of the trailing.
- *
- * Ported from: http://underscorejs.org/underscore.js
- *
- * @param {Function} func
- * @param {number} wait
- * @param {boolean} immediate
- * @return {Function}
- */
-OO.ui.debounce = function ( func, wait, immediate ) {
-       var timeout;
-       return function () {
-               var context = this,
-                       args = arguments,
-                       later = function () {
-                               timeout = null;
-                               if ( !immediate ) {
-                                       func.apply( context, args );
-                               }
-                       };
-               if ( immediate && !timeout ) {
-                       func.apply( context, args );
-               }
-               clearTimeout( timeout );
-               timeout = setTimeout( later, wait );
-       };
-};
-
-/**
- * Proxy for `node.addEventListener( eventName, handler, true )`.
- *
- * @param {HTMLElement} node
- * @param {string} eventName
- * @param {Function} handler
- * @deprecated
- */
-OO.ui.addCaptureEventListener = function ( node, eventName, handler ) {
-       node.addEventListener( eventName, handler, true );
-};
-
-/**
- * Proxy for `node.removeEventListener( eventName, handler, true )`.
- *
- * @param {HTMLElement} node
- * @param {string} eventName
- * @param {Function} handler
- * @deprecated
- */
-OO.ui.removeCaptureEventListener = function ( node, eventName, handler ) {
-       node.removeEventListener( eventName, handler, true );
-};
-
-/**
- * Reconstitute a JavaScript object corresponding to a widget created by
- * the PHP implementation.
- *
- * This is an alias for `OO.ui.Element.static.infuse()`.
- *
- * @param {string|HTMLElement|jQuery} idOrNode
- *   A DOM id (if a string) or node for the widget to infuse.
- * @return {OO.ui.Element}
- *   The `OO.ui.Element` corresponding to this (infusable) document node.
- */
-OO.ui.infuse = function ( idOrNode ) {
-       return OO.ui.Element.static.infuse( idOrNode );
-};
-
-( function () {
-       /**
-        * Message store for the default implementation of OO.ui.msg
-        *
-        * Environments that provide a localization system should not use this, but should override
-        * OO.ui.msg altogether.
-        *
-        * @private
-        */
-       var messages = {
-               // Tool tip for a button that moves items in a list down one place
-               'ooui-outline-control-move-down': 'Move item down',
-               // Tool tip for a button that moves items in a list up one place
-               'ooui-outline-control-move-up': 'Move item up',
-               // Tool tip for a button that removes items from a list
-               'ooui-outline-control-remove': 'Remove item',
-               // Label for the toolbar group that contains a list of all other available tools
-               'ooui-toolbar-more': 'More',
-               // Label for the fake tool that expands the full list of tools in a toolbar group
-               'ooui-toolgroup-expand': 'More',
-               // Label for the fake tool that collapses the full list of tools in a toolbar group
-               'ooui-toolgroup-collapse': 'Fewer',
-               // Default label for the accept button of a confirmation dialog
-               'ooui-dialog-message-accept': 'OK',
-               // Default label for the reject button of a confirmation dialog
-               'ooui-dialog-message-reject': 'Cancel',
-               // Title for process dialog error description
-               'ooui-dialog-process-error': 'Something went wrong',
-               // Label for process dialog dismiss error button, visible when describing errors
-               'ooui-dialog-process-dismiss': 'Dismiss',
-               // Label for process dialog retry action button, visible when describing only recoverable errors
-               'ooui-dialog-process-retry': 'Try again',
-               // Label for process dialog retry action button, visible when describing only warnings
-               'ooui-dialog-process-continue': 'Continue',
-               // Label for the file selection widget's select file button
-               'ooui-selectfile-button-select': 'Select a file',
-               // Label for the file selection widget if file selection is not supported
-               'ooui-selectfile-not-supported': 'File selection is not supported',
-               // Label for the file selection widget when no file is currently selected
-               'ooui-selectfile-placeholder': 'No file is selected',
-               // Label for the file selection widget's drop target
-               'ooui-selectfile-dragdrop-placeholder': 'Drop file here'
-       };
-
-       /**
-        * Get a localized message.
-        *
-        * In environments that provide a localization system, this function should be overridden to
-        * return the message translated in the user's language. The default implementation always returns
-        * English messages.
-        *
-        * After the message key, message parameters may optionally be passed. In the default implementation,
-        * any occurrences of $1 are replaced with the first parameter, $2 with the second parameter, etc.
-        * Alternative implementations of OO.ui.msg may use any substitution system they like, as long as
-        * they support unnamed, ordered message parameters.
-        *
-        * @param {string} key Message key
-        * @param {Mixed...} [params] Message parameters
-        * @return {string} Translated message with parameters substituted
-        */
-       OO.ui.msg = function ( key ) {
-               var message = messages[ key ],
-                       params = Array.prototype.slice.call( arguments, 1 );
-               if ( typeof message === 'string' ) {
-                       // Perform $1 substitution
-                       message = message.replace( /\$(\d+)/g, function ( unused, n ) {
-                               var i = parseInt( n, 10 );
-                               return params[ i - 1 ] !== undefined ? params[ i - 1 ] : '$' + n;
-                       } );
-               } else {
-                       // Return placeholder if message not found
-                       message = '[' + key + ']';
-               }
-               return message;
-       };
-} )();
-
-/**
- * Package a message and arguments for deferred resolution.
- *
- * Use this when you are statically specifying a message and the message may not yet be present.
- *
- * @param {string} key Message key
- * @param {Mixed...} [params] Message parameters
- * @return {Function} Function that returns the resolved message when executed
- */
-OO.ui.deferMsg = function () {
-       var args = arguments;
-       return function () {
-               return OO.ui.msg.apply( OO.ui, args );
-       };
-};
-
-/**
- * Resolve a message.
- *
- * If the message is a function it will be executed, otherwise it will pass through directly.
- *
- * @param {Function|string} msg Deferred message, or message text
- * @return {string} Resolved message
- */
-OO.ui.resolveMsg = function ( msg ) {
-       if ( $.isFunction( msg ) ) {
-               return msg();
-       }
-       return msg;
-};
-
-/**
- * @param {string} url
- * @return {boolean}
- */
-OO.ui.isSafeUrl = function ( url ) {
-       // Keep this function in sync with php/Tag.php
-       var i, protocolWhitelist;
-
-       function stringStartsWith( haystack, needle ) {
-               return haystack.substr( 0, needle.length ) === needle;
-       }
-
-       protocolWhitelist = [
-               'bitcoin', 'ftp', 'ftps', 'geo', 'git', 'gopher', 'http', 'https', 'irc', 'ircs',
-               'magnet', 'mailto', 'mms', 'news', 'nntp', 'redis', 'sftp', 'sip', 'sips', 'sms', 'ssh',
-               'svn', 'tel', 'telnet', 'urn', 'worldwind', 'xmpp'
-       ];
-
-       if ( url === '' ) {
-               return true;
-       }
-
-       for ( i = 0; i < protocolWhitelist.length; i++ ) {
-               if ( stringStartsWith( url, protocolWhitelist[ i ] + ':' ) ) {
-                       return true;
-               }
-       }
-
-       // This matches '//' too
-       if ( stringStartsWith( url, '/' ) || stringStartsWith( url, './' ) ) {
-               return true;
-       }
-       if ( stringStartsWith( url, '?' ) || stringStartsWith( url, '#' ) ) {
-               return true;
-       }
-
-       return false;
-};
-
-/*!
- * Mixin namespace.
- */
-
-/**
- * Namespace for OOjs UI mixins.
- *
- * Mixins are named according to the type of object they are intended to
- * be mixed in to.  For example, OO.ui.mixin.GroupElement is intended to be
- * mixed in to an instance of OO.ui.Element, and OO.ui.mixin.GroupWidget
- * is intended to be mixed in to an instance of OO.ui.Widget.
- *
- * @class
- * @singleton
- */
-OO.ui.mixin = {};
-
-/**
- * Each Element represents a rendering in the DOM—a button or an icon, for example, or anything
- * that is visible to a user. Unlike {@link OO.ui.Widget widgets}, plain elements usually do not have events
- * connected to them and can't be interacted with.
- *
- * @abstract
- * @class
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {string[]} [classes] The names of the CSS classes to apply to the element. CSS styles are added
- *  to the top level (e.g., the outermost div) of the element. See the [OOjs UI documentation on MediaWiki][2]
- *  for an example.
- *  [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches#cssExample
- * @cfg {string} [id] The HTML id attribute used in the rendered tag.
- * @cfg {string} [text] Text to insert
- * @cfg {Array} [content] An array of content elements to append (after #text).
- *  Strings will be html-escaped; use an OO.ui.HtmlSnippet to append raw HTML.
- *  Instances of OO.ui.Element will have their $element appended.
- * @cfg {jQuery} [$content] Content elements to append (after #text).
- * @cfg {jQuery} [$element] Wrapper element. Defaults to a new element with #getTagName.
- * @cfg {Mixed} [data] Custom data of any type or combination of types (e.g., string, number, array, object).
- *  Data can also be specified with the #setData method.
- */
-OO.ui.Element = function OoUiElement( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Properties
-       this.$ = $;
-       this.visible = true;
-       this.data = config.data;
-       this.$element = config.$element ||
-               $( document.createElement( this.getTagName() ) );
-       this.elementGroup = null;
-       this.debouncedUpdateThemeClassesHandler = OO.ui.debounce( this.debouncedUpdateThemeClasses );
-
-       // Initialization
-       if ( Array.isArray( config.classes ) ) {
-               this.$element.addClass( config.classes.join( ' ' ) );
-       }
-       if ( config.id ) {
-               this.$element.attr( 'id', config.id );
-       }
-       if ( config.text ) {
-               this.$element.text( config.text );
-       }
-       if ( config.content ) {
-               // The `content` property treats plain strings as text; use an
-               // HtmlSnippet to append HTML content.  `OO.ui.Element`s get their
-               // appropriate $element appended.
-               this.$element.append( config.content.map( function ( v ) {
-                       if ( typeof v === 'string' ) {
-                               // Escape string so it is properly represented in HTML.
-                               return document.createTextNode( v );
-                       } else if ( v instanceof OO.ui.HtmlSnippet ) {
-                               // Bypass escaping.
-                               return v.toString();
-                       } else if ( v instanceof OO.ui.Element ) {
-                               return v.$element;
-                       }
-                       return v;
-               } ) );
-       }
-       if ( config.$content ) {
-               // The `$content` property treats plain strings as HTML.
-               this.$element.append( config.$content );
-       }
-};
-
-/* Setup */
-
-OO.initClass( OO.ui.Element );
-
-/* Static Properties */
-
-/**
- * The name of the HTML tag used by the element.
- *
- * The static value may be ignored if the #getTagName method is overridden.
- *
- * @static
- * @inheritable
- * @property {string}
- */
-OO.ui.Element.static.tagName = 'div';
-
-/* Static Methods */
-
-/**
- * Reconstitute a JavaScript object corresponding to a widget created
- * by the PHP implementation.
- *
- * @param {string|HTMLElement|jQuery} idOrNode
- *   A DOM id (if a string) or node for the widget to infuse.
- * @return {OO.ui.Element}
- *   The `OO.ui.Element` corresponding to this (infusable) document node.
- *   For `Tag` objects emitted on the HTML side (used occasionally for content)
- *   the value returned is a newly-created Element wrapping around the existing
- *   DOM node.
- */
-OO.ui.Element.static.infuse = function ( idOrNode ) {
-       var obj = OO.ui.Element.static.unsafeInfuse( idOrNode, false );
-       // Verify that the type matches up.
-       // FIXME: uncomment after T89721 is fixed (see T90929)
-       /*
-       if ( !( obj instanceof this['class'] ) ) {
-               throw new Error( 'Infusion type mismatch!' );
-       }
-       */
-       return obj;
-};
-
-/**
- * Implementation helper for `infuse`; skips the type check and has an
- * extra property so that only the top-level invocation touches the DOM.
- * @private
- * @param {string|HTMLElement|jQuery} idOrNode
- * @param {jQuery.Promise|boolean} domPromise A promise that will be resolved
- *     when the top-level widget of this infusion is inserted into DOM,
- *     replacing the original node; or false for top-level invocation.
- * @return {OO.ui.Element}
- */
-OO.ui.Element.static.unsafeInfuse = function ( idOrNode, domPromise ) {
-       // look for a cached result of a previous infusion.
-       var id, $elem, data, cls, parts, parent, obj, top, state;
-       if ( typeof idOrNode === 'string' ) {
-               id = idOrNode;
-               $elem = $( document.getElementById( id ) );
-       } else {
-               $elem = $( idOrNode );
-               id = $elem.attr( 'id' );
-       }
-       if ( !$elem.length ) {
-               throw new Error( 'Widget not found: ' + id );
-       }
-       data = $elem.data( 'ooui-infused' ) || $elem[ 0 ].oouiInfused;
-       if ( data ) {
-               // cached!
-               if ( data === true ) {
-                       throw new Error( 'Circular dependency! ' + id );
-               }
-               return data;
-       }
-       data = $elem.attr( 'data-ooui' );
-       if ( !data ) {
-               throw new Error( 'No infusion data found: ' + id );
-       }
-       try {
-               data = $.parseJSON( data );
-       } catch ( _ ) {
-               data = null;
-       }
-       if ( !( data && data._ ) ) {
-               throw new Error( 'No valid infusion data found: ' + id );
-       }
-       if ( data._ === 'Tag' ) {
-               // Special case: this is a raw Tag; wrap existing node, don't rebuild.
-               return new OO.ui.Element( { $element: $elem } );
-       }
-       parts = data._.split( '.' );
-       cls = OO.getProp.apply( OO, [ window ].concat( parts ) );
-       if ( cls === undefined ) {
-               // The PHP output might be old and not including the "OO.ui" prefix
-               // TODO: Remove this back-compat after next major release
-               cls = OO.getProp.apply( OO, [ OO.ui ].concat( parts ) );
-               if ( cls === undefined ) {
-                       throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
-               }
-       }
-
-       // Verify that we're creating an OO.ui.Element instance
-       parent = cls.parent;
-
-       while ( parent !== undefined ) {
-               if ( parent === OO.ui.Element ) {
-                       // Safe
-                       break;
-               }
-
-               parent = parent.parent;
-       }
-
-       if ( parent !== OO.ui.Element ) {
-               throw new Error( 'Unknown widget type: id: ' + id + ', class: ' + data._ );
-       }
-
-       if ( domPromise === false ) {
-               top = $.Deferred();
-               domPromise = top.promise();
-       }
-       $elem.data( 'ooui-infused', true ); // prevent loops
-       data.id = id; // implicit
-       data = OO.copy( data, null, function deserialize( value ) {
-               if ( OO.isPlainObject( value ) ) {
-                       if ( value.tag ) {
-                               return OO.ui.Element.static.unsafeInfuse( value.tag, domPromise );
-                       }
-                       if ( value.html ) {
-                               return new OO.ui.HtmlSnippet( value.html );
-                       }
-               }
-       } );
-       // allow widgets to reuse parts of the DOM
-       data = cls.static.reusePreInfuseDOM( $elem[ 0 ], data );
-       // pick up dynamic state, like focus, value of form inputs, scroll position, etc.
-       state = cls.static.gatherPreInfuseState( $elem[ 0 ], data );
-       // rebuild widget
-       // jscs:disable requireCapitalizedConstructors
-       obj = new cls( data );
-       // jscs:enable requireCapitalizedConstructors
-       // now replace old DOM with this new DOM.
-       if ( top ) {
-               // An efficient constructor might be able to reuse the entire DOM tree of the original element,
-               // so only mutate the DOM if we need to.
-               if ( $elem[ 0 ] !== obj.$element[ 0 ] ) {
-                       $elem.replaceWith( obj.$element );
-                       // This element is now gone from the DOM, but if anyone is holding a reference to it,
-                       // let's allow them to OO.ui.infuse() it and do what they expect (T105828).
-                       // Do not use jQuery.data(), as using it on detached nodes leaks memory in 1.x line by design.
-                       $elem[ 0 ].oouiInfused = obj;
-               }
-               top.resolve();
-       }
-       obj.$element.data( 'ooui-infused', obj );
-       // set the 'data-ooui' attribute so we can identify infused widgets
-       obj.$element.attr( 'data-ooui', '' );
-       // restore dynamic state after the new element is inserted into DOM
-       domPromise.done( obj.restorePreInfuseState.bind( obj, state ) );
-       return obj;
-};
-
-/**
- * Pick out parts of `node`'s DOM to be reused when infusing a widget.
- *
- * This method **must not** make any changes to the DOM, only find interesting pieces and add them
- * to `config` (which should then be returned). Actual DOM juggling should then be done by the
- * constructor, which will be given the enhanced config.
- *
- * @protected
- * @param {HTMLElement} node
- * @param {Object} config
- * @return {Object}
- */
-OO.ui.Element.static.reusePreInfuseDOM = function ( node, config ) {
-       return config;
-};
-
-/**
- * Gather the dynamic state (focus, value of form inputs, scroll position, etc.) of a HTML DOM node
- * (and its children) that represent an Element of the same class and the given configuration,
- * generated by the PHP implementation.
- *
- * This method is called just before `node` is detached from the DOM. The return value of this
- * function will be passed to #restorePreInfuseState after the newly created widget's #$element
- * is inserted into DOM to replace `node`.
- *
- * @protected
- * @param {HTMLElement} node
- * @param {Object} config
- * @return {Object}
- */
-OO.ui.Element.static.gatherPreInfuseState = function () {
-       return {};
-};
-
-/**
- * Get a jQuery function within a specific document.
- *
- * @static
- * @param {jQuery|HTMLElement|HTMLDocument|Window} context Context to bind the function to
- * @param {jQuery} [$iframe] HTML iframe element that contains the document, omit if document is
- *   not in an iframe
- * @return {Function} Bound jQuery function
- */
-OO.ui.Element.static.getJQuery = function ( context, $iframe ) {
-       function wrapper( selector ) {
-               return $( selector, wrapper.context );
-       }
-
-       wrapper.context = this.getDocument( context );
-
-       if ( $iframe ) {
-               wrapper.$iframe = $iframe;
-       }
-
-       return wrapper;
-};
-
-/**
- * Get the document of an element.
- *
- * @static
- * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Object to get the document for
- * @return {HTMLDocument|null} Document object
- */
-OO.ui.Element.static.getDocument = function ( obj ) {
-       // jQuery - selections created "offscreen" won't have a context, so .context isn't reliable
-       return ( obj[ 0 ] && obj[ 0 ].ownerDocument ) ||
-               // Empty jQuery selections might have a context
-               obj.context ||
-               // HTMLElement
-               obj.ownerDocument ||
-               // Window
-               obj.document ||
-               // HTMLDocument
-               ( obj.nodeType === 9 && obj ) ||
-               null;
-};
-
-/**
- * Get the window of an element or document.
- *
- * @static
- * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the window for
- * @return {Window} Window object
- */
-OO.ui.Element.static.getWindow = function ( obj ) {
-       var doc = this.getDocument( obj );
-       return doc.defaultView;
-};
-
-/**
- * Get the direction of an element or document.
- *
- * @static
- * @param {jQuery|HTMLElement|HTMLDocument|Window} obj Context to get the direction for
- * @return {string} Text direction, either 'ltr' or 'rtl'
- */
-OO.ui.Element.static.getDir = function ( obj ) {
-       var isDoc, isWin;
-
-       if ( obj instanceof jQuery ) {
-               obj = obj[ 0 ];
-       }
-       isDoc = obj.nodeType === 9;
-       isWin = obj.document !== undefined;
-       if ( isDoc || isWin ) {
-               if ( isWin ) {
-                       obj = obj.document;
-               }
-               obj = obj.body;
-       }
-       return $( obj ).css( 'direction' );
-};
-
-/**
- * Get the offset between two frames.
- *
- * TODO: Make this function not use recursion.
- *
- * @static
- * @param {Window} from Window of the child frame
- * @param {Window} [to=window] Window of the parent frame
- * @param {Object} [offset] Offset to start with, used internally
- * @return {Object} Offset object, containing left and top properties
- */
-OO.ui.Element.static.getFrameOffset = function ( from, to, offset ) {
-       var i, len, frames, frame, rect;
-
-       if ( !to ) {
-               to = window;
-       }
-       if ( !offset ) {
-               offset = { top: 0, left: 0 };
-       }
-       if ( from.parent === from ) {
-               return offset;
-       }
-
-       // Get iframe element
-       frames = from.parent.document.getElementsByTagName( 'iframe' );
-       for ( i = 0, len = frames.length; i < len; i++ ) {
-               if ( frames[ i ].contentWindow === from ) {
-                       frame = frames[ i ];
-                       break;
-               }
-       }
-
-       // Recursively accumulate offset values
-       if ( frame ) {
-               rect = frame.getBoundingClientRect();
-               offset.left += rect.left;
-               offset.top += rect.top;
-               if ( from !== to ) {
-                       this.getFrameOffset( from.parent, offset );
-               }
-       }
-       return offset;
-};
-
-/**
- * Get the offset between two elements.
- *
- * The two elements may be in a different frame, but in that case the frame $element is in must
- * be contained in the frame $anchor is in.
- *
- * @static
- * @param {jQuery} $element Element whose position to get
- * @param {jQuery} $anchor Element to get $element's position relative to
- * @return {Object} Translated position coordinates, containing top and left properties
- */
-OO.ui.Element.static.getRelativePosition = function ( $element, $anchor ) {
-       var iframe, iframePos,
-               pos = $element.offset(),
-               anchorPos = $anchor.offset(),
-               elementDocument = this.getDocument( $element ),
-               anchorDocument = this.getDocument( $anchor );
-
-       // If $element isn't in the same document as $anchor, traverse up
-       while ( elementDocument !== anchorDocument ) {
-               iframe = elementDocument.defaultView.frameElement;
-               if ( !iframe ) {
-                       throw new Error( '$element frame is not contained in $anchor frame' );
-               }
-               iframePos = $( iframe ).offset();
-               pos.left += iframePos.left;
-               pos.top += iframePos.top;
-               elementDocument = iframe.ownerDocument;
-       }
-       pos.left -= anchorPos.left;
-       pos.top -= anchorPos.top;
-       return pos;
-};
-
-/**
- * Get element border sizes.
- *
- * @static
- * @param {HTMLElement} el Element to measure
- * @return {Object} Dimensions object with `top`, `left`, `bottom` and `right` properties
- */
-OO.ui.Element.static.getBorders = function ( el ) {
-       var doc = el.ownerDocument,
-               win = doc.defaultView,
-               style = win.getComputedStyle( el, null ),
-               $el = $( el ),
-               top = parseFloat( style ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0,
-               left = parseFloat( style ? style.borderLeftWidth : $el.css( 'borderLeftWidth' ) ) || 0,
-               bottom = parseFloat( style ? style.borderBottomWidth : $el.css( 'borderBottomWidth' ) ) || 0,
-               right = parseFloat( style ? style.borderRightWidth : $el.css( 'borderRightWidth' ) ) || 0;
-
-       return {
-               top: top,
-               left: left,
-               bottom: bottom,
-               right: right
-       };
-};
-
-/**
- * Get dimensions of an element or window.
- *
- * @static
- * @param {HTMLElement|Window} el Element to measure
- * @return {Object} Dimensions object with `borders`, `scroll`, `scrollbar` and `rect` properties
- */
-OO.ui.Element.static.getDimensions = function ( el ) {
-       var $el, $win,
-               doc = el.ownerDocument || el.document,
-               win = doc.defaultView;
-
-       if ( win === el || el === doc.documentElement ) {
-               $win = $( win );
-               return {
-                       borders: { top: 0, left: 0, bottom: 0, right: 0 },
-                       scroll: {
-                               top: $win.scrollTop(),
-                               left: $win.scrollLeft()
-                       },
-                       scrollbar: { right: 0, bottom: 0 },
-                       rect: {
-                               top: 0,
-                               left: 0,
-                               bottom: $win.innerHeight(),
-                               right: $win.innerWidth()
-                       }
-               };
-       } else {
-               $el = $( el );
-               return {
-                       borders: this.getBorders( el ),
-                       scroll: {
-                               top: $el.scrollTop(),
-                               left: $el.scrollLeft()
-                       },
-                       scrollbar: {
-                               right: $el.innerWidth() - el.clientWidth,
-                               bottom: $el.innerHeight() - el.clientHeight
-                       },
-                       rect: el.getBoundingClientRect()
-               };
-       }
-};
-
-/**
- * Get scrollable object parent
- *
- * documentElement can't be used to get or set the scrollTop
- * property on Blink. Changing and testing its value lets us
- * use 'body' or 'documentElement' based on what is working.
- *
- * https://code.google.com/p/chromium/issues/detail?id=303131
- *
- * @static
- * @param {HTMLElement} el Element to find scrollable parent for
- * @return {HTMLElement} Scrollable parent
- */
-OO.ui.Element.static.getRootScrollableElement = function ( el ) {
-       var scrollTop, body;
-
-       if ( OO.ui.scrollableElement === undefined ) {
-               body = el.ownerDocument.body;
-               scrollTop = body.scrollTop;
-               body.scrollTop = 1;
-
-               if ( body.scrollTop === 1 ) {
-                       body.scrollTop = scrollTop;
-                       OO.ui.scrollableElement = 'body';
-               } else {
-                       OO.ui.scrollableElement = 'documentElement';
-               }
-       }
-
-       return el.ownerDocument[ OO.ui.scrollableElement ];
-};
-
-/**
- * Get closest scrollable container.
- *
- * Traverses up until either a scrollable element or the root is reached, in which case the window
- * will be returned.
- *
- * @static
- * @param {HTMLElement} el Element to find scrollable container for
- * @param {string} [dimension] Dimension of scrolling to look for; `x`, `y` or omit for either
- * @return {HTMLElement} Closest scrollable container
- */
-OO.ui.Element.static.getClosestScrollableContainer = function ( el, dimension ) {
-       var i, val,
-               // props = [ 'overflow' ] doesn't work due to https://bugzilla.mozilla.org/show_bug.cgi?id=889091
-               props = [ 'overflow-x', 'overflow-y' ],
-               $parent = $( el ).parent();
-
-       if ( dimension === 'x' || dimension === 'y' ) {
-               props = [ 'overflow-' + dimension ];
-       }
-
-       while ( $parent.length ) {
-               if ( $parent[ 0 ] === this.getRootScrollableElement( el ) ) {
-                       return $parent[ 0 ];
-               }
-               i = props.length;
-               while ( i-- ) {
-                       val = $parent.css( props[ i ] );
-                       if ( val === 'auto' || val === 'scroll' ) {
-                               return $parent[ 0 ];
-                       }
-               }
-               $parent = $parent.parent();
-       }
-       return this.getDocument( el ).body;
-};
-
-/**
- * Scroll element into view.
- *
- * @static
- * @param {HTMLElement} el Element to scroll into view
- * @param {Object} [config] Configuration options
- * @param {string} [config.duration] jQuery animation duration value
- * @param {string} [config.direction] Scroll in only one direction, e.g. 'x' or 'y', omit
- *  to scroll in both directions
- * @param {Function} [config.complete] Function to call when scrolling completes
- */
-OO.ui.Element.static.scrollIntoView = function ( el, config ) {
-       var rel, anim, callback, sc, $sc, eld, scd, $win;
-
-       // Configuration initialization
-       config = config || {};
-
-       anim = {};
-       callback = typeof config.complete === 'function' && config.complete;
-       sc = this.getClosestScrollableContainer( el, config.direction );
-       $sc = $( sc );
-       eld = this.getDimensions( el );
-       scd = this.getDimensions( sc );
-       $win = $( this.getWindow( el ) );
-
-       // Compute the distances between the edges of el and the edges of the scroll viewport
-       if ( $sc.is( 'html, body' ) ) {
-               // If the scrollable container is the root, this is easy
-               rel = {
-                       top: eld.rect.top,
-                       bottom: $win.innerHeight() - eld.rect.bottom,
-                       left: eld.rect.left,
-                       right: $win.innerWidth() - eld.rect.right
-               };
-       } else {
-               // Otherwise, we have to subtract el's coordinates from sc's coordinates
-               rel = {
-                       top: eld.rect.top - ( scd.rect.top + scd.borders.top ),
-                       bottom: scd.rect.bottom - scd.borders.bottom - scd.scrollbar.bottom - eld.rect.bottom,
-                       left: eld.rect.left - ( scd.rect.left + scd.borders.left ),
-                       right: scd.rect.right - scd.borders.right - scd.scrollbar.right - eld.rect.right
-               };
-       }
-
-       if ( !config.direction || config.direction === 'y' ) {
-               if ( rel.top < 0 ) {
-                       anim.scrollTop = scd.scroll.top + rel.top;
-               } else if ( rel.top > 0 && rel.bottom < 0 ) {
-                       anim.scrollTop = scd.scroll.top + Math.min( rel.top, -rel.bottom );
-               }
-       }
-       if ( !config.direction || config.direction === 'x' ) {
-               if ( rel.left < 0 ) {
-                       anim.scrollLeft = scd.scroll.left + rel.left;
-               } else if ( rel.left > 0 && rel.right < 0 ) {
-                       anim.scrollLeft = scd.scroll.left + Math.min( rel.left, -rel.right );
-               }
-       }
-       if ( !$.isEmptyObject( anim ) ) {
-               $sc.stop( true ).animate( anim, config.duration || 'fast' );
-               if ( callback ) {
-                       $sc.queue( function ( next ) {
-                               callback();
-                               next();
-                       } );
-               }
-       } else {
-               if ( callback ) {
-                       callback();
-               }
-       }
-};
-
-/**
- * Force the browser to reconsider whether it really needs to render scrollbars inside the element
- * and reserve space for them, because it probably doesn't.
- *
- * Workaround primarily for <https://code.google.com/p/chromium/issues/detail?id=387290>, but also
- * similar bugs in other browsers. "Just" forcing a reflow is not sufficient in all cases, we need
- * to first actually detach (or hide, but detaching is simpler) all children, *then* force a reflow,
- * and then reattach (or show) them back.
- *
- * @static
- * @param {HTMLElement} el Element to reconsider the scrollbars on
- */
-OO.ui.Element.static.reconsiderScrollbars = function ( el ) {
-       var i, len, scrollLeft, scrollTop, nodes = [];
-       // Save scroll position
-       scrollLeft = el.scrollLeft;
-       scrollTop = el.scrollTop;
-       // Detach all children
-       while ( el.firstChild ) {
-               nodes.push( el.firstChild );
-               el.removeChild( el.firstChild );
-       }
-       // Force reflow
-       void el.offsetHeight;
-       // Reattach all children
-       for ( i = 0, len = nodes.length; i < len; i++ ) {
-               el.appendChild( nodes[ i ] );
-       }
-       // Restore scroll position (no-op if scrollbars disappeared)
-       el.scrollLeft = scrollLeft;
-       el.scrollTop = scrollTop;
-};
-
-/* Methods */
-
-/**
- * Toggle visibility of an element.
- *
- * @param {boolean} [show] Make element visible, omit to toggle visibility
- * @fires visible
- * @chainable
- */
-OO.ui.Element.prototype.toggle = function ( show ) {
-       show = show === undefined ? !this.visible : !!show;
-
-       if ( show !== this.isVisible() ) {
-               this.visible = show;
-               this.$element.toggleClass( 'oo-ui-element-hidden', !this.visible );
-               this.emit( 'toggle', show );
-       }
-
-       return this;
-};
-
-/**
- * Check if element is visible.
- *
- * @return {boolean} element is visible
- */
-OO.ui.Element.prototype.isVisible = function () {
-       return this.visible;
-};
-
-/**
- * Get element data.
- *
- * @return {Mixed} Element data
- */
-OO.ui.Element.prototype.getData = function () {
-       return this.data;
-};
-
-/**
- * Set element data.
- *
- * @param {Mixed} Element data
- * @chainable
- */
-OO.ui.Element.prototype.setData = function ( data ) {
-       this.data = data;
-       return this;
-};
-
-/**
- * Check if element supports one or more methods.
- *
- * @param {string|string[]} methods Method or list of methods to check
- * @return {boolean} All methods are supported
- */
-OO.ui.Element.prototype.supports = function ( methods ) {
-       var i, len,
-               support = 0;
-
-       methods = Array.isArray( methods ) ? methods : [ methods ];
-       for ( i = 0, len = methods.length; i < len; i++ ) {
-               if ( $.isFunction( this[ methods[ i ] ] ) ) {
-                       support++;
-               }
-       }
-
-       return methods.length === support;
-};
-
-/**
- * Update the theme-provided classes.
- *
- * @localdoc This is called in element mixins and widget classes any time state changes.
- *   Updating is debounced, minimizing overhead of changing multiple attributes and
- *   guaranteeing that theme updates do not occur within an element's constructor
- */
-OO.ui.Element.prototype.updateThemeClasses = function () {
-       this.debouncedUpdateThemeClassesHandler();
-};
-
-/**
- * @private
- * @localdoc This method is called directly from the QUnit tests instead of #updateThemeClasses, to
- *   make them synchronous.
- */
-OO.ui.Element.prototype.debouncedUpdateThemeClasses = function () {
-       OO.ui.theme.updateElementClasses( this );
-};
-
-/**
- * Get the HTML tag name.
- *
- * Override this method to base the result on instance information.
- *
- * @return {string} HTML tag name
- */
-OO.ui.Element.prototype.getTagName = function () {
-       return this.constructor.static.tagName;
-};
-
-/**
- * Check if the element is attached to the DOM
- * @return {boolean} The element is attached to the DOM
- */
-OO.ui.Element.prototype.isElementAttached = function () {
-       return $.contains( this.getElementDocument(), this.$element[ 0 ] );
-};
-
-/**
- * Get the DOM document.
- *
- * @return {HTMLDocument} Document object
- */
-OO.ui.Element.prototype.getElementDocument = function () {
-       // Don't cache this in other ways either because subclasses could can change this.$element
-       return OO.ui.Element.static.getDocument( this.$element );
-};
-
-/**
- * Get the DOM window.
- *
- * @return {Window} Window object
- */
-OO.ui.Element.prototype.getElementWindow = function () {
-       return OO.ui.Element.static.getWindow( this.$element );
-};
-
-/**
- * Get closest scrollable container.
- */
-OO.ui.Element.prototype.getClosestScrollableElementContainer = function () {
-       return OO.ui.Element.static.getClosestScrollableContainer( this.$element[ 0 ] );
-};
-
-/**
- * Get group element is in.
- *
- * @return {OO.ui.mixin.GroupElement|null} Group element, null if none
- */
-OO.ui.Element.prototype.getElementGroup = function () {
-       return this.elementGroup;
-};
-
-/**
- * Set group element is in.
- *
- * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
- * @chainable
- */
-OO.ui.Element.prototype.setElementGroup = function ( group ) {
-       this.elementGroup = group;
-       return this;
-};
-
-/**
- * Scroll element into view.
- *
- * @param {Object} [config] Configuration options
- */
-OO.ui.Element.prototype.scrollElementIntoView = function ( config ) {
-       return OO.ui.Element.static.scrollIntoView( this.$element[ 0 ], config );
-};
-
-/**
- * Restore the pre-infusion dynamic state for this widget.
- *
- * This method is called after #$element has been inserted into DOM. The parameter is the return
- * value of #gatherPreInfuseState.
- *
- * @protected
- * @param {Object} state
- */
-OO.ui.Element.prototype.restorePreInfuseState = function () {
-};
-
-/**
- * Wraps an HTML snippet for use with configuration values which default
- * to strings.  This bypasses the default html-escaping done to string
- * values.
- *
- * @class
- *
- * @constructor
- * @param {string} [content] HTML content
- */
-OO.ui.HtmlSnippet = function OoUiHtmlSnippet( content ) {
-       // Properties
-       this.content = content;
-};
-
-/* Setup */
-
-OO.initClass( OO.ui.HtmlSnippet );
-
-/* Methods */
-
-/**
- * Render into HTML.
- *
- * @return {string} Unchanged HTML snippet.
- */
-OO.ui.HtmlSnippet.prototype.toString = function () {
-       return this.content;
-};
-
-/**
- * Layouts are containers for elements and are used to arrange other widgets of arbitrary type in a way
- * that is centrally controlled and can be updated dynamically. Layouts can be, and usually are, combined.
- * See {@link OO.ui.FieldsetLayout FieldsetLayout}, {@link OO.ui.FieldLayout FieldLayout}, {@link OO.ui.FormLayout FormLayout},
- * {@link OO.ui.PanelLayout PanelLayout}, {@link OO.ui.StackLayout StackLayout}, {@link OO.ui.PageLayout PageLayout},
- * {@link OO.ui.HorizontalLayout HorizontalLayout}, and {@link OO.ui.BookletLayout BookletLayout} for more information and examples.
- *
- * @abstract
- * @class
- * @extends OO.ui.Element
- * @mixins OO.EventEmitter
- *
- * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.Layout = function OoUiLayout( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.Layout.parent.call( this, config );
-
-       // Mixin constructors
-       OO.EventEmitter.call( this );
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-layout' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.Layout, OO.ui.Element );
-OO.mixinClass( OO.ui.Layout, OO.EventEmitter );
-
-/**
- * Widgets are compositions of one or more OOjs UI elements that users can both view
- * and interact with. All widgets can be configured and modified via a standard API,
- * and their state can change dynamically according to a model.
- *
- * @abstract
- * @class
- * @extends OO.ui.Element
- * @mixins OO.EventEmitter
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {boolean} [disabled=false] Disable the widget. Disabled widgets cannot be used and their
- *  appearance reflects this state.
- */
-OO.ui.Widget = function OoUiWidget( config ) {
-       // Initialize config
-       config = $.extend( { disabled: false }, config );
-
-       // Parent constructor
-       OO.ui.Widget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.EventEmitter.call( this );
-
-       // Properties
-       this.disabled = null;
-       this.wasDisabled = null;
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-widget' );
-       this.setDisabled( !!config.disabled );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.Widget, OO.ui.Element );
-OO.mixinClass( OO.ui.Widget, OO.EventEmitter );
-
-/* Static Properties */
-
-/**
- * Whether this widget will behave reasonably when wrapped in a HTML `<label>`. If this is true,
- * wrappers such as OO.ui.FieldLayout may use a `<label>` instead of implementing own label click
- * handling.
- *
- * @static
- * @inheritable
- * @property {boolean}
- */
-OO.ui.Widget.static.supportsSimpleLabel = false;
-
-/* Events */
-
-/**
- * @event disable
- *
- * A 'disable' event is emitted when the disabled state of the widget changes
- * (i.e. on disable **and** enable).
- *
- * @param {boolean} disabled Widget is disabled
- */
-
-/**
- * @event toggle
- *
- * A 'toggle' event is emitted when the visibility of the widget changes.
- *
- * @param {boolean} visible Widget is visible
- */
-
-/* Methods */
-
-/**
- * Check if the widget is disabled.
- *
- * @return {boolean} Widget is disabled
- */
-OO.ui.Widget.prototype.isDisabled = function () {
-       return this.disabled;
-};
-
-/**
- * Set the 'disabled' state of the widget.
- *
- * When a widget is disabled, it cannot be used and its appearance is updated to reflect this state.
- *
- * @param {boolean} disabled Disable widget
- * @chainable
- */
-OO.ui.Widget.prototype.setDisabled = function ( disabled ) {
-       var isDisabled;
-
-       this.disabled = !!disabled;
-       isDisabled = this.isDisabled();
-       if ( isDisabled !== this.wasDisabled ) {
-               this.$element.toggleClass( 'oo-ui-widget-disabled', isDisabled );
-               this.$element.toggleClass( 'oo-ui-widget-enabled', !isDisabled );
-               this.$element.attr( 'aria-disabled', isDisabled.toString() );
-               this.emit( 'disable', isDisabled );
-               this.updateThemeClasses();
-       }
-       this.wasDisabled = isDisabled;
-
-       return this;
-};
-
-/**
- * Update the disabled state, in case of changes in parent widget.
- *
- * @chainable
- */
-OO.ui.Widget.prototype.updateDisabled = function () {
-       this.setDisabled( this.disabled );
-       return this;
-};
-
-/**
- * Theme logic.
- *
- * @abstract
- * @class
- *
- * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.Theme = function OoUiTheme( config ) {
-       // Configuration initialization
-       config = config || {};
-};
-
-/* Setup */
-
-OO.initClass( OO.ui.Theme );
-
-/* Methods */
-
-/**
- * Get a list of classes to be applied to a widget.
- *
- * The 'on' and 'off' lists combined MUST contain keys for all classes the theme adds or removes,
- * otherwise state transitions will not work properly.
- *
- * @param {OO.ui.Element} element Element for which to get classes
- * @return {Object.<string,string[]>} Categorized class names with `on` and `off` lists
- */
-OO.ui.Theme.prototype.getElementClasses = function () {
-       return { on: [], off: [] };
-};
-
-/**
- * Update CSS classes provided by the theme.
- *
- * For elements with theme logic hooks, this should be called any time there's a state change.
- *
- * @param {OO.ui.Element} element Element for which to update classes
- * @return {Object.<string,string[]>} Categorized class names with `on` and `off` lists
- */
-OO.ui.Theme.prototype.updateElementClasses = function ( element ) {
-       var $elements = $( [] ),
-               classes = this.getElementClasses( element );
-
-       if ( element.$icon ) {
-               $elements = $elements.add( element.$icon );
-       }
-       if ( element.$indicator ) {
-               $elements = $elements.add( element.$indicator );
-       }
-
-       $elements
-               .removeClass( classes.off.join( ' ' ) )
-               .addClass( classes.on.join( ' ' ) );
-};
-
-/**
- * The TabIndexedElement class is an attribute mixin used to add additional functionality to an
- * element created by another class. The mixin provides a ‘tabIndex’ property, which specifies the
- * order in which users will navigate through the focusable elements via the "tab" key.
- *
- *     @example
- *     // TabIndexedElement is mixed into the ButtonWidget class
- *     // to provide a tabIndex property.
- *     var button1 = new OO.ui.ButtonWidget( {
- *         label: 'fourth',
- *         tabIndex: 4
- *     } );
- *     var button2 = new OO.ui.ButtonWidget( {
- *         label: 'second',
- *         tabIndex: 2
- *     } );
- *     var button3 = new OO.ui.ButtonWidget( {
- *         label: 'third',
- *         tabIndex: 3
- *     } );
- *     var button4 = new OO.ui.ButtonWidget( {
- *         label: 'first',
- *         tabIndex: 1
- *     } );
- *     $( 'body' ).append( button1.$element, button2.$element, button3.$element, button4.$element );
- *
- * @abstract
- * @class
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {jQuery} [$tabIndexed] The element that should use the tabindex functionality. By default,
- *  the functionality is applied to the element created by the class ($element). If a different element is specified, the tabindex
- *  functionality will be applied to it instead.
- * @cfg {number|null} [tabIndex=0] Number that specifies the element’s position in the tab-navigation
- *  order (e.g., 1 for the first focusable element). Use 0 to use the default navigation order; use -1
- *  to remove the element from the tab-navigation flow.
- */
-OO.ui.mixin.TabIndexedElement = function OoUiMixinTabIndexedElement( config ) {
-       // Configuration initialization
-       config = $.extend( { tabIndex: 0 }, config );
-
-       // Properties
-       this.$tabIndexed = null;
-       this.tabIndex = null;
-
-       // Events
-       this.connect( this, { disable: 'onTabIndexedElementDisable' } );
-
-       // Initialization
-       this.setTabIndex( config.tabIndex );
-       this.setTabIndexedElement( config.$tabIndexed || this.$element );
-};
-
-/* Setup */
-
-OO.initClass( OO.ui.mixin.TabIndexedElement );
-
-/* Methods */
-
-/**
- * Set the element that should use the tabindex functionality.
- *
- * This method is used to retarget a tabindex mixin so that its functionality applies
- * to the specified element. If an element is currently using the functionality, the mixin’s
- * effect on that element is removed before the new element is set up.
- *
- * @param {jQuery} $tabIndexed Element that should use the tabindex functionality
- * @chainable
- */
-OO.ui.mixin.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 value of the tabindex.
- *
- * @param {number|null} tabIndex Tabindex value, or `null` for no tabindex
- * @chainable
- */
-OO.ui.mixin.TabIndexedElement.prototype.setTabIndex = function ( tabIndex ) {
-       tabIndex = typeof tabIndex === 'number' ? tabIndex : null;
-
-       if ( this.tabIndex !== tabIndex ) {
-               this.tabIndex = tabIndex;
-               this.updateTabIndex();
-       }
-
-       return this;
-};
-
-/**
- * Update the `tabindex` attribute, in case of changes to tab index or
- * disabled state.
- *
- * @private
- * @chainable
- */
-OO.ui.mixin.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,
-                               // Support: ChromeVox and NVDA
-                               // These do not seem to inherit aria-disabled from parent elements
-                               'aria-disabled': this.isDisabled().toString()
-                       } );
-               } else {
-                       this.$tabIndexed.removeAttr( 'tabindex aria-disabled' );
-               }
-       }
-       return this;
-};
-
-/**
- * Handle disable events.
- *
- * @private
- * @param {boolean} disabled Element is disabled
- */
-OO.ui.mixin.TabIndexedElement.prototype.onTabIndexedElementDisable = function () {
-       this.updateTabIndex();
-};
-
-/**
- * Get the value of the tabindex.
- *
- * @return {number|null} Tabindex value
- */
-OO.ui.mixin.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] The button element created by the class.
- *  If this configuration is omitted, the button element will use a generated `<a>`.
- * @cfg {boolean} [framed=true] Render the button with a frame
- */
-OO.ui.mixin.ButtonElement = function OoUiMixinButtonElement( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Properties
-       this.$button = null;
-       this.framed = 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.setButtonElement( config.$button || $( '<a>' ) );
-};
-
-/* Setup */
-
-OO.initClass( OO.ui.mixin.ButtonElement );
-
-/* Static Properties */
-
-/**
- * Cancel mouse down events.
- *
- * This property is usually set to `true` to prevent the focus from changing when the button is clicked.
- * Classes such as {@link OO.ui.mixin.DraggableElement DraggableElement} and {@link OO.ui.ButtonOptionWidget ButtonOptionWidget}
- * use a value of `false` so that dragging behavior is possible and mousedown events can be handled by a
- * parent widget.
- *
- * @static
- * @inheritable
- * @property {boolean}
- */
-OO.ui.mixin.ButtonElement.static.cancelButtonMouseDownEvents = true;
-
-/* Events */
-
-/**
- * A 'click' event is emitted when the button element is clicked.
- *
- * @event click
- */
-
-/* Methods */
-
-/**
- * Set the button element.
- *
- * This method is used to retarget a button mixin so that its functionality applies to
- * the specified button element instead of the one created by the class. If a button element
- * is already set, the method will remove the mixin’s effect on that element.
- *
- * @param {jQuery} $button Element to use as button
- */
-OO.ui.mixin.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' } )
-               .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.mixin.ButtonElement.prototype.onMouseDown = function ( e ) {
-       if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
-               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 ) {
-               return false;
-       }
-};
-
-/**
- * Handles mouse up events.
- *
- * @protected
- * @param {jQuery.Event} e Mouse up event
- */
-OO.ui.mixin.ButtonElement.prototype.onMouseUp = function ( e ) {
-       if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
-               return;
-       }
-       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.mixin.ButtonElement.prototype.onClick = function ( e ) {
-       if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
-               if ( this.emit( 'click' ) ) {
-                       return false;
-               }
-       }
-};
-
-/**
- * Handles key down events.
- *
- * @protected
- * @param {jQuery.Event} e Key down event
- */
-OO.ui.mixin.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.mixin.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.mixin.ButtonElement.prototype.onKeyPress = function ( e ) {
-       if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
-               if ( this.emit( 'click' ) ) {
-                       return false;
-               }
-       }
-};
-
-/**
- * Check if button has a frame.
- *
- * @return {boolean} Button is framed
- */
-OO.ui.mixin.ButtonElement.prototype.isFramed = function () {
-       return this.framed;
-};
-
-/**
- * Render the button with or without a frame. Omit the `framed` parameter to toggle the button frame on and off.
- *
- * @param {boolean} [framed] Make button framed, omit to toggle
- * @chainable
- */
-OO.ui.mixin.ButtonElement.prototype.toggleFramed = function ( framed ) {
-       framed = framed === undefined ? !this.framed : !!framed;
-       if ( framed !== this.framed ) {
-               this.framed = framed;
-               this.$element
-                       .toggleClass( 'oo-ui-buttonElement-frameless', !framed )
-                       .toggleClass( 'oo-ui-buttonElement-framed', framed );
-               this.updateThemeClasses();
-       }
-
-       return this;
-};
-
-/**
- * Set the button's active state.
- *
- * The active state occurs when a {@link OO.ui.ButtonOptionWidget ButtonOptionWidget} or
- * a {@link OO.ui.ToggleButtonWidget ToggleButtonWidget} is pressed. This method does nothing
- * for other button types.
- *
- * @param {boolean} value Make button active
- * @chainable
- */
-OO.ui.mixin.ButtonElement.prototype.setActive = function ( value ) {
-       this.active = !!value;
-       this.$element.toggleClass( 'oo-ui-buttonElement-active', this.active );
-       return this;
-};
-
-/**
- * Check if the button is active
- *
- * @return {boolean} The button is active
- */
-OO.ui.mixin.ButtonElement.prototype.isActive = function () {
-       return this.active;
-};
-
-/**
- * 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
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {jQuery} [$group] The container element created by the class. If this configuration
- *  is omitted, the group element will use a generated `<div>`.
- */
-OO.ui.mixin.GroupElement = function OoUiMixinGroupElement( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Properties
-       this.$group = null;
-       this.items = [];
-       this.aggregateItemEvents = {};
-
-       // Initialization
-       this.setGroupElement( config.$group || $( '<div>' ) );
-};
-
-/* Methods */
-
-/**
- * Set the group element.
- *
- * If an element is already set, items will be moved to the new element.
- *
- * @param {jQuery} $group Element to use as group
- */
-OO.ui.mixin.GroupElement.prototype.setGroupElement = function ( $group ) {
-       var i, len;
-
-       this.$group = $group;
-       for ( i = 0, len = this.items.length; i < len; i++ ) {
-               this.$group.append( this.items[ i ].$element );
-       }
-};
-
-/**
- * Check if a group contains no items.
- *
- * @return {boolean} Group is empty
- */
-OO.ui.mixin.GroupElement.prototype.isEmpty = function () {
-       return !this.items.length;
-};
-
-/**
- * Get all items in the group.
- *
- * The method returns an array of item references (e.g., [button1, button2, button3]) and is useful
- * when synchronizing groups of items, or whenever the references are required (e.g., when removing items
- * from a group).
- *
- * @return {OO.ui.Element[]} An array of items.
- */
-OO.ui.mixin.GroupElement.prototype.getItems = function () {
-       return this.items.slice( 0 );
-};
-
-/**
- * Get an item by its data.
- *
- * Only the first item with matching data will be returned. To return all matching items,
- * use the #getItemsFromData method.
- *
- * @param {Object} data Item data to search for
- * @return {OO.ui.Element|null} Item with equivalent data, `null` if none exists
- */
-OO.ui.mixin.GroupElement.prototype.getItemFromData = function ( data ) {
-       var i, len, item,
-               hash = OO.getHash( data );
-
-       for ( i = 0, len = this.items.length; i < len; i++ ) {
-               item = this.items[ i ];
-               if ( hash === OO.getHash( item.getData() ) ) {
-                       return item;
-               }
-       }
-
-       return null;
-};
-
-/**
- * Get items by their data.
- *
- * All items with matching data will be returned. To return only the first match, use the #getItemFromData method instead.
- *
- * @param {Object} data Item data to search for
- * @return {OO.ui.Element[]} Items with equivalent data
- */
-OO.ui.mixin.GroupElement.prototype.getItemsFromData = function ( data ) {
-       var i, len, item,
-               hash = OO.getHash( data ),
-               items = [];
-
-       for ( i = 0, len = this.items.length; i < len; i++ ) {
-               item = this.items[ i ];
-               if ( hash === OO.getHash( item.getData() ) ) {
-                       items.push( item );
-               }
-       }
-
-       return items;
-};
-
-/**
- * Aggregate the events emitted by the group.
- *
- * When events are aggregated, the group will listen to all contained items for the event,
- * and then emit the event under a new name. The new event will contain an additional leading
- * parameter containing the item that emitted the original event. Other arguments emitted from
- * the original event are passed through.
- *
- * @param {Object.<string,string|null>} events An object keyed by the name of the event that should be
- *  aggregated  (e.g., ‘click’) and the value of the new name to use (e.g., ‘groupClick’).
- *  A `null` value will remove aggregated events.
-
- * @throws {Error} An error is thrown if aggregation already exists.
- */
-OO.ui.mixin.GroupElement.prototype.aggregate = function ( events ) {
-       var i, len, item, add, remove, itemEvent, groupEvent;
-
-       for ( itemEvent in events ) {
-               groupEvent = events[ itemEvent ];
-
-               // Remove existing aggregated event
-               if ( Object.prototype.hasOwnProperty.call( this.aggregateItemEvents, itemEvent ) ) {
-                       // Don't allow duplicate aggregations
-                       if ( groupEvent ) {
-                               throw new Error( 'Duplicate item event aggregation for ' + itemEvent );
-                       }
-                       // Remove event aggregation from existing items
-                       for ( i = 0, len = this.items.length; i < len; i++ ) {
-                               item = this.items[ i ];
-                               if ( item.connect && item.disconnect ) {
-                                       remove = {};
-                                       remove[ itemEvent ] = [ 'emit', this.aggregateItemEvents[ itemEvent ], item ];
-                                       item.disconnect( this, remove );
-                               }
-                       }
-                       // Prevent future items from aggregating event
-                       delete this.aggregateItemEvents[ itemEvent ];
-               }
-
-               // Add new aggregate event
-               if ( groupEvent ) {
-                       // Make future items aggregate event
-                       this.aggregateItemEvents[ itemEvent ] = groupEvent;
-                       // Add event aggregation to existing items
-                       for ( i = 0, len = this.items.length; i < len; i++ ) {
-                               item = this.items[ i ];
-                               if ( item.connect && item.disconnect ) {
-                                       add = {};
-                                       add[ itemEvent ] = [ 'emit', groupEvent, item ];
-                                       item.connect( this, add );
-                               }
-                       }
-               }
-       }
-};
-
-/**
- * Add items to the group.
- *
- * Items will be added to the end of the group array unless the optional `index` parameter specifies
- * a different insertion point. Adding an existing item will move it to the end of the array or the point specified by the `index`.
- *
- * @param {OO.ui.Element[]} items An array of items to add to the group
- * @param {number} [index] Index of the insertion point
- * @chainable
- */
-OO.ui.mixin.GroupElement.prototype.addItems = function ( items, index ) {
-       var i, len, item, event, events, currentIndex,
-               itemElements = [];
-
-       for ( i = 0, len = items.length; i < len; i++ ) {
-               item = items[ i ];
-
-               // Check if item exists then remove it first, effectively "moving" it
-               currentIndex = this.items.indexOf( item );
-               if ( currentIndex >= 0 ) {
-                       this.removeItems( [ item ] );
-                       // Adjust index to compensate for removal
-                       if ( currentIndex < index ) {
-                               index--;
-                       }
-               }
-               // Add the item
-               if ( item.connect && item.disconnect && !$.isEmptyObject( this.aggregateItemEvents ) ) {
-                       events = {};
-                       for ( event in this.aggregateItemEvents ) {
-                               events[ event ] = [ 'emit', this.aggregateItemEvents[ event ], item ];
-                       }
-                       item.connect( this, events );
-               }
-               item.setElementGroup( this );
-               itemElements.push( item.$element.get( 0 ) );
-       }
-
-       if ( index === undefined || index < 0 || index >= this.items.length ) {
-               this.$group.append( itemElements );
-               this.items.push.apply( this.items, items );
-       } else if ( index === 0 ) {
-               this.$group.prepend( itemElements );
-               this.items.unshift.apply( this.items, items );
-       } else {
-               this.items[ index ].$element.before( itemElements );
-               this.items.splice.apply( this.items, [ index, 0 ].concat( items ) );
-       }
-
-       return this;
-};
-
-/**
- * Remove the specified items from a group.
- *
- * Removed items are detached (not removed) from the DOM so that they may be reused.
- * To remove all items from a group, you may wish to use the #clearItems method instead.
- *
- * @param {OO.ui.Element[]} items An array of items to remove
- * @chainable
- */
-OO.ui.mixin.GroupElement.prototype.removeItems = function ( items ) {
-       var i, len, item, index, remove, itemEvent;
-
-       // Remove specific items
-       for ( i = 0, len = items.length; i < len; i++ ) {
-               item = items[ i ];
-               index = this.items.indexOf( item );
-               if ( index !== -1 ) {
-                       if (
-                               item.connect && item.disconnect &&
-                               !$.isEmptyObject( this.aggregateItemEvents )
-                       ) {
-                               remove = {};
-                               if ( Object.prototype.hasOwnProperty.call( this.aggregateItemEvents, itemEvent ) ) {
-                                       remove[ itemEvent ] = [ 'emit', this.aggregateItemEvents[ itemEvent ], item ];
-                               }
-                               item.disconnect( this, remove );
-                       }
-                       item.setElementGroup( null );
-                       this.items.splice( index, 1 );
-                       item.$element.detach();
-               }
-       }
-
-       return this;
-};
-
-/**
- * Clear all items from the group.
- *
- * Cleared items are detached from the DOM, not removed, so that they may be reused.
- * To remove only a subset of items from a group, use the #removeItems method.
- *
- * @chainable
- */
-OO.ui.mixin.GroupElement.prototype.clearItems = function () {
-       var i, len, item, remove, itemEvent;
-
-       // Remove all items
-       for ( i = 0, len = this.items.length; i < len; 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 ];
-                       }
-                       item.disconnect( this, remove );
-               }
-               item.setElementGroup( null );
-               item.$element.detach();
-       }
-
-       this.items = [];
-       return this;
-};
-
-/**
- * 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.
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons
- *
- * @abstract
- * @class
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {jQuery} [$icon] The icon element created by the class. If this configuration is omitted,
- *  the icon element will use a generated `<span>`. To use a different HTML tag, or to specify that
- *  the icon element be set to an existing icon instead of the one generated by this class, set a
- *  value using a jQuery selection. For example:
- *
- *      // Use a <div> tag instead of a <span>
- *     $icon: $("<div>")
- *     // Use an existing icon element instead of the one generated by the class
- *     $icon: this.$element
- *     // Use an icon element from a child widget
- *     $icon: this.childwidget.$element
- * @cfg {Object|string} [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.
- *
- *  Example of an i18n map:
- *
- *     { default: 'bold-a', en: 'bold-b', de: 'bold-f' }
- *  See the [OOjs UI documentation on MediaWiki] [2] for a list of icons included in the library.
- * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons
- * @cfg {string|Function} [iconTitle] A text string used as the icon title, or a function that returns title
- *  text. The icon title is displayed when users move the mouse over the icon.
- */
-OO.ui.mixin.IconElement = function OoUiMixinIconElement( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Properties
-       this.$icon = null;
-       this.icon = null;
-       this.iconTitle = null;
-
-       // Initialization
-       this.setIcon( config.icon || this.constructor.static.icon );
-       this.setIconTitle( config.iconTitle || this.constructor.static.iconTitle );
-       this.setIconElement( config.$icon || $( '<span>' ) );
-};
-
-/* Setup */
-
-OO.initClass( OO.ui.mixin.IconElement );
-
-/* Static Properties */
-
-/**
- * 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.
- *
- * Example of an i18n map:
- *
- *     { 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}
- */
-OO.ui.mixin.IconElement.static.icon = null;
-
-/**
- * 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}
- */
-OO.ui.mixin.IconElement.static.iconTitle = null;
-
-/* Methods */
-
-/**
- * Set the icon element. This method is used to retarget an icon mixin so that its functionality
- * applies to the specified icon element instead of the one created by the class. If an icon
- * element is already set, the mixin’s effect on that element is removed. Generated CSS classes
- * and mixin methods will no longer affect the element.
- *
- * @param {jQuery} $icon Element to use as icon
- */
-OO.ui.mixin.IconElement.prototype.setIconElement = function ( $icon ) {
-       if ( this.$icon ) {
-               this.$icon
-                       .removeClass( 'oo-ui-iconElement-icon oo-ui-icon-' + this.icon )
-                       .removeAttr( 'title' );
-       }
-
-       this.$icon = $icon
-               .addClass( 'oo-ui-iconElement-icon' )
-               .toggleClass( 'oo-ui-icon-' + this.icon, !!this.icon );
-       if ( this.iconTitle !== null ) {
-               this.$icon.attr( 'title', this.iconTitle );
-       }
-
-       this.updateThemeClasses();
-};
-
-/**
- * Set icon by symbolic name (e.g., ‘remove’ or ‘menu’). Use `null` to remove an icon.
- * The icon parameter can also be set to a map of icon names. See the #icon config setting
- * for an example.
- *
- * @param {Object|string|null} icon A symbolic icon name, a {@link #icon map of icon names} keyed
- *  by language code, or `null` to remove the icon.
- * @chainable
- */
-OO.ui.mixin.IconElement.prototype.setIcon = function ( icon ) {
-       icon = OO.isPlainObject( icon ) ? OO.ui.getLocalValue( icon, null, 'default' ) : icon;
-       icon = typeof icon === 'string' && icon.trim().length ? icon.trim() : null;
-
-       if ( this.icon !== icon ) {
-               if ( this.$icon ) {
-                       if ( this.icon !== null ) {
-                               this.$icon.removeClass( 'oo-ui-icon-' + this.icon );
-                       }
-                       if ( icon !== null ) {
-                               this.$icon.addClass( 'oo-ui-icon-' + icon );
-                       }
-               }
-               this.icon = icon;
-       }
-
-       this.$element.toggleClass( 'oo-ui-iconElement', !!this.icon );
-       this.updateThemeClasses();
-
-       return this;
-};
-
-/**
- * Set the icon title. Use `null` to remove the title.
- *
- * @param {string|Function|null} iconTitle A text string used as the icon title,
- *  a function that returns title text, or `null` for no title.
- * @chainable
- */
-OO.ui.mixin.IconElement.prototype.setIconTitle = function ( iconTitle ) {
-       iconTitle = typeof iconTitle === 'function' ||
-               ( typeof iconTitle === 'string' && iconTitle.length ) ?
-                       OO.ui.resolveMsg( iconTitle ) : null;
-
-       if ( this.iconTitle !== iconTitle ) {
-               this.iconTitle = iconTitle;
-               if ( this.$icon ) {
-                       if ( this.iconTitle !== null ) {
-                               this.$icon.attr( 'title', iconTitle );
-                       } else {
-                               this.$icon.removeAttr( 'title' );
-                       }
-               }
-       }
-
-       return this;
-};
-
-/**
- * Get the symbolic name of the icon.
- *
- * @return {string} Icon name
- */
-OO.ui.mixin.IconElement.prototype.getIcon = function () {
-       return this.icon;
-};
-
-/**
- * Get the icon title. The title text is displayed when a user moves the mouse over the icon.
- *
- * @return {string} Icon title text
- */
-OO.ui.mixin.IconElement.prototype.getIconTitle = function () {
-       return this.iconTitle;
-};
-
-/**
- * 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).
- *
- * 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
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {jQuery} [$indicator] The indicator element created by the class. If this
- *  configuration is omitted, the indicator element will use a generated `<span>`.
- * @cfg {string} [indicator] Symbolic name of the indicator (e.g., ‘alert’ or  ‘down’).
- *  See the [OOjs UI documentation on MediaWiki][2] for a list of indicators included
- *  in the library.
- * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Indicators
- * @cfg {string|Function} [indicatorTitle] A text string used as the indicator title,
- *  or a function that returns title text. The indicator title is displayed when users move
- *  the mouse over the indicator.
- */
-OO.ui.mixin.IndicatorElement = function OoUiMixinIndicatorElement( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Properties
-       this.$indicator = null;
-       this.indicator = null;
-       this.indicatorTitle = null;
-
-       // Initialization
-       this.setIndicator( config.indicator || this.constructor.static.indicator );
-       this.setIndicatorTitle( config.indicatorTitle || this.constructor.static.indicatorTitle );
-       this.setIndicatorElement( config.$indicator || $( '<span>' ) );
-};
-
-/* Setup */
-
-OO.initClass( OO.ui.mixin.IndicatorElement );
-
-/* Static Properties */
-
-/**
- * Symbolic name of the indicator (e.g., ‘alert’ or  ‘down’).
- * The static property will be overridden if the #indicator configuration is used.
- *
- * @static
- * @inheritable
- * @property {string|null}
- */
-OO.ui.mixin.IndicatorElement.static.indicator = null;
-
-/**
- * A text string used as the indicator title, a function that returns title text, or `null`
- * for no title. The static property will be overridden if the #indicatorTitle configuration is used.
- *
- * @static
- * @inheritable
- * @property {string|Function|null}
- */
-OO.ui.mixin.IndicatorElement.static.indicatorTitle = null;
-
-/* Methods */
-
-/**
- * Set the indicator element.
- *
- * If an element is already set, it will be cleaned up before setting up the new element.
- *
- * @param {jQuery} $indicator Element to use as indicator
- */
-OO.ui.mixin.IndicatorElement.prototype.setIndicatorElement = function ( $indicator ) {
-       if ( this.$indicator ) {
-               this.$indicator
-                       .removeClass( 'oo-ui-indicatorElement-indicator oo-ui-indicator-' + this.indicator )
-                       .removeAttr( 'title' );
-       }
-
-       this.$indicator = $indicator
-               .addClass( 'oo-ui-indicatorElement-indicator' )
-               .toggleClass( 'oo-ui-indicator-' + this.indicator, !!this.indicator );
-       if ( this.indicatorTitle !== null ) {
-               this.$indicator.attr( 'title', this.indicatorTitle );
-       }
-
-       this.updateThemeClasses();
-};
-
-/**
- * Set the indicator by its symbolic name: ‘alert’, ‘down’, ‘next’, ‘previous’, ‘required’, ‘up’. Use `null` to remove the indicator.
- *
- * @param {string|null} indicator Symbolic name of indicator, or `null` for no indicator
- * @chainable
- */
-OO.ui.mixin.IndicatorElement.prototype.setIndicator = function ( indicator ) {
-       indicator = typeof indicator === 'string' && indicator.length ? indicator.trim() : null;
-
-       if ( this.indicator !== indicator ) {
-               if ( this.$indicator ) {
-                       if ( this.indicator !== null ) {
-                               this.$indicator.removeClass( 'oo-ui-indicator-' + this.indicator );
-                       }
-                       if ( indicator !== null ) {
-                               this.$indicator.addClass( 'oo-ui-indicator-' + indicator );
-                       }
-               }
-               this.indicator = indicator;
-       }
-
-       this.$element.toggleClass( 'oo-ui-indicatorElement', !!this.indicator );
-       this.updateThemeClasses();
-
-       return this;
-};
-
-/**
- * Set the indicator title.
- *
- * The title is displayed when a user moves the mouse over the indicator.
- *
- * @param {string|Function|null} indicator Indicator title text, a function that returns text, or
- *   `null` for no indicator title
- * @chainable
- */
-OO.ui.mixin.IndicatorElement.prototype.setIndicatorTitle = function ( indicatorTitle ) {
-       indicatorTitle = typeof indicatorTitle === 'function' ||
-               ( typeof indicatorTitle === 'string' && indicatorTitle.length ) ?
-                       OO.ui.resolveMsg( indicatorTitle ) : null;
-
-       if ( this.indicatorTitle !== indicatorTitle ) {
-               this.indicatorTitle = indicatorTitle;
-               if ( this.$indicator ) {
-                       if ( this.indicatorTitle !== null ) {
-                               this.$indicator.attr( 'title', indicatorTitle );
-                       } else {
-                               this.$indicator.removeAttr( 'title' );
-                       }
-               }
-       }
-
-       return this;
-};
-
-/**
- * Get the symbolic name of the indicator (e.g., ‘alert’ or  ‘down’).
- *
- * @return {string} Symbolic name of indicator
- */
-OO.ui.mixin.IndicatorElement.prototype.getIndicator = function () {
-       return this.indicator;
-};
-
-/**
- * Get the indicator title.
- *
- * The title is displayed when a user moves the mouse over the indicator.
- *
- * @return {string} Indicator title text
- */
-OO.ui.mixin.IndicatorElement.prototype.getIndicatorTitle = function () {
-       return this.indicatorTitle;
-};
-
-/**
- * LabelElement is often mixed into other classes to generate a label, which
- * helps identify the function of an interface element.
- * See the [OOjs UI documentation on MediaWiki] [1] for more information.
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Labels
- *
- * @abstract
- * @class
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {jQuery} [$label] The label element created by the class. If this
- *  configuration is omitted, the label element will use a generated `<span>`.
- * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] The label text. The label can be specified
- *  as a plaintext string, a jQuery selection of elements, or a function that will produce a string
- *  in the future. See the [OOjs UI documentation on MediaWiki] [2] for examples.
- *  [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Labels
- * @cfg {boolean} [autoFitLabel=true] Fit the label to the width of the parent element.
- *  The label will be truncated to fit if necessary.
- */
-OO.ui.mixin.LabelElement = function OoUiMixinLabelElement( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Properties
-       this.$label = null;
-       this.label = null;
-       this.autoFitLabel = config.autoFitLabel === undefined || !!config.autoFitLabel;
-
-       // Initialization
-       this.setLabel( config.label || this.constructor.static.label );
-       this.setLabelElement( config.$label || $( '<span>' ) );
-};
-
-/* Setup */
-
-OO.initClass( OO.ui.mixin.LabelElement );
-
-/* Events */
-
-/**
- * @event labelChange
- * @param {string} value
- */
-
-/* Static Properties */
-
-/**
- * The label text. The label can be specified as a plaintext string, a function that will
- * produce a string in the future, or `null` for no label. The static value will
- * be overridden if a label is specified with the #label config option.
- *
- * @static
- * @inheritable
- * @property {string|Function|null}
- */
-OO.ui.mixin.LabelElement.static.label = null;
-
-/* Methods */
-
-/**
- * Set the label element.
- *
- * If an element is already set, it will be cleaned up before setting up the new element.
- *
- * @param {jQuery} $label Element to use as label
- */
-OO.ui.mixin.LabelElement.prototype.setLabelElement = function ( $label ) {
-       if ( this.$label ) {
-               this.$label.removeClass( 'oo-ui-labelElement-label' ).empty();
-       }
-
-       this.$label = $label.addClass( 'oo-ui-labelElement-label' );
-       this.setLabelContent( this.label );
-};
-
-/**
- * Set the label.
- *
- * An empty string will result in the label being hidden. A string containing only whitespace will
- * be converted to a single `&nbsp;`.
- *
- * @param {jQuery|string|OO.ui.HtmlSnippet|Function|null} label Label nodes; text; a function that returns nodes or
- *  text; or null for no label
- * @chainable
- */
-OO.ui.mixin.LabelElement.prototype.setLabel = function ( label ) {
-       label = typeof label === 'function' ? OO.ui.resolveMsg( label ) : label;
-       label = ( ( typeof label === 'string' && label.length ) || label instanceof jQuery || label instanceof OO.ui.HtmlSnippet ) ? 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' );
-       }
-
-       return this;
-};
-
-/**
- * Get the label.
- *
- * @return {jQuery|string|Function|null} Label nodes; text; a function that returns nodes or
- *  text; or null for no label
- */
-OO.ui.mixin.LabelElement.prototype.getLabel = function () {
-       return this.label;
-};
-
-/**
- * Fit the label.
- *
- * @chainable
- */
-OO.ui.mixin.LabelElement.prototype.fitLabel = function () {
-       if ( this.$label && this.$label.autoEllipsis && this.autoFitLabel ) {
-               this.$label.autoEllipsis( { hasSpan: false, tooltip: true } );
-       }
-
-       return this;
-};
-
-/**
- * Set the content of the label.
- *
- * Do not call this method until after the label element has been set by #setLabelElement.
- *
- * @private
- * @param {jQuery|string|Function|null} label Label nodes; text; a function that returns nodes or
- *  text; or null for no label
- */
-OO.ui.mixin.LabelElement.prototype.setLabelContent = function ( label ) {
-       if ( typeof label === 'string' ) {
-               if ( label.match( /^\s*$/ ) ) {
-                       // Convert whitespace only string to a single non-breaking space
-                       this.$label.html( '&nbsp;' );
-               } else {
-                       this.$label.text( label );
-               }
-       } else if ( label instanceof OO.ui.HtmlSnippet ) {
-               this.$label.html( label.toString() );
-       } else if ( label instanceof jQuery ) {
-               this.$label.empty().append( label );
-       } else {
-               this.$label.empty();
-       }
-};
-
-/**
- * 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.
- *
- * The flags affect the appearance of the buttons:
- *
- *     @example
- *     // FlaggedElement is mixed into ButtonWidget to provide styling flags
- *     var button1 = new OO.ui.ButtonWidget( {
- *         label: 'Constructive',
- *         flags: 'constructive'
- *     } );
- *     var button2 = new OO.ui.ButtonWidget( {
- *         label: 'Destructive',
- *         flags: 'destructive'
- *     } );
- *     var button3 = new OO.ui.ButtonWidget( {
- *         label: 'Progressive',
- *         flags: 'progressive'
- *     } );
- *     $( 'body' ).append( button1.$element, button2.$element, button3.$element );
- *
- * {@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] The name or names of the flags (e.g., 'constructive' or 'primary') to apply.
- *  Please see the [OOjs UI documentation on MediaWiki] [2] for more information about available flags.
- *  [2]: https://www.mediawiki.org/wiki/OOjs_UI/Elements/Flagged
- * @cfg {jQuery} [$flagged] The flagged element. By default,
- *  the flagged functionality is applied to the element created by the class ($element).
- *  If a different element is specified, the flagged functionality will be applied to it instead.
- */
-OO.ui.mixin.FlaggedElement = function OoUiMixinFlaggedElement( 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
- * A flag event is emitted when the #clearFlags or #setFlags methods are used. The `changes`
- * parameter contains the name of each modified flag and indicates whether it was
- * added or removed.
- *
- * @param {Object.<string,boolean>} changes Object keyed by flag name. A Boolean `true` indicates
- * that the flag was added, `false` that the flag was removed.
- */
-
-/* Methods */
-
-/**
- * Set the flagged element.
- *
- * This method is used to retarget a flagged mixin so that its functionality applies to the specified element.
- * If an element is already set, the method will remove the mixin’s effect on that element.
- *
- * @param {jQuery} $flagged Element that should be flagged
- */
-OO.ui.mixin.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 the specified flag is set.
- *
- * @param {string} flag Name of flag
- * @return {boolean} The flag is set
- */
-OO.ui.mixin.FlaggedElement.prototype.hasFlag = function ( flag ) {
-       // This may be called before the constructor, thus before this.flags is set
-       return this.flags && ( flag in this.flags );
-};
-
-/**
- * Get the names of all flags set.
- *
- * @return {string[]} Flag names
- */
-OO.ui.mixin.FlaggedElement.prototype.getFlags = function () {
-       // This may be called before the constructor, thus before this.flags is set
-       return Object.keys( this.flags || {} );
-};
-
-/**
- * Clear all flags.
- *
- * @chainable
- * @fires flag
- */
-OO.ui.mixin.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.<string, boolean>} flags A flag name, an array of flag names,
- *  or an object keyed by flag name with a boolean value that indicates whether the flag should
- *  be added (`true`) or removed (`false`).
- * @chainable
- * @fires flag
- */
-OO.ui.mixin.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;
-};
-
-/**
- * TitledElement is mixed into other classes to provide a `title` attribute.
- * Titles are rendered by the browser and are made visible when the user moves
- * the mouse over the element. Titles are not visible on touch devices.
- *
- *     @example
- *     // TitledElement provides a 'title' attribute to the
- *     // ButtonWidget class
- *     var button = new OO.ui.ButtonWidget( {
- *         label: 'Button with Title',
- *         title: 'I am a button'
- *     } );
- *     $( 'body' ).append( button.$element );
- *
- * @abstract
- * @class
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {jQuery} [$titled] The element to which the `title` attribute is applied.
- *  If this config is omitted, the title functionality is applied to $element, the
- *  element created by the class.
- * @cfg {string|Function} [title] The title text or a function that returns text. If
- *  this config is omitted, the value of the {@link #static-title static title} property is used.
- */
-OO.ui.mixin.TitledElement = function OoUiMixinTitledElement( 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 );
-};
-
-/* Setup */
-
-OO.initClass( OO.ui.mixin.TitledElement );
-
-/* Static Properties */
-
-/**
- * The title text, a function that returns text, or `null` for no title. The value of the static property
- * is overridden if the #title config option is used.
- *
- * @static
- * @inheritable
- * @property {string|Function|null}
- */
-OO.ui.mixin.TitledElement.static.title = null;
-
-/* Methods */
-
-/**
- * Set the titled element.
- *
- * This method is used to retarget a titledElement mixin so that its functionality applies to the specified element.
- * If an element is already set, the mixin’s effect on that element is removed before the new element is set up.
- *
- * @param {jQuery} $titled Element that should use the 'titled' functionality
- */
-OO.ui.mixin.TitledElement.prototype.setTitledElement = function ( $titled ) {
-       if ( this.$titled ) {
-               this.$titled.removeAttr( 'title' );
-       }
-
-       this.$titled = $titled;
-       if ( this.title ) {
-               this.$titled.attr( 'title', this.title );
-       }
-};
-
-/**
- * Set title.
- *
- * @param {string|Function|null} title Title text, a function that returns text, or `null` for no title
- * @chainable
- */
-OO.ui.mixin.TitledElement.prototype.setTitle = function ( title ) {
-       title = typeof title === 'function' ? OO.ui.resolveMsg( title ) : title;
-       title = ( typeof title === 'string' && title.length ) ? title : null;
-
-       if ( this.title !== title ) {
-               if ( this.$titled ) {
-                       if ( title !== null ) {
-                               this.$titled.attr( 'title', title );
-                       } else {
-                               this.$titled.removeAttr( 'title' );
-                       }
-               }
-               this.title = title;
-       }
-
-       return this;
-};
-
-/**
- * Get title.
- *
- * @return {string} Title string
- */
-OO.ui.mixin.TitledElement.prototype.getTitle = function () {
-       return this.title;
-};
-
-/**
- * AccessKeyedElement is mixed into other classes to provide an `accesskey` attribute.
- * Accesskeys allow an user to go to a specific element by using
- * a shortcut combination of a browser specific keys + the key
- * set to the field.
- *
- *     @example
- *     // AccessKeyedElement provides an 'accesskey' attribute to the
- *     // ButtonWidget class
- *     var button = new OO.ui.ButtonWidget( {
- *         label: 'Button with Accesskey',
- *         accessKey: 'k'
- *     } );
- *     $( 'body' ).append( button.$element );
- *
- * @abstract
- * @class
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {jQuery} [$accessKeyed] The element to which the `accesskey` attribute is applied.
- *  If this config is omitted, the accesskey functionality is applied to $element, the
- *  element created by the class.
- * @cfg {string|Function} [accessKey] The key or a function that returns the key. If
- *  this config is omitted, no accesskey will be added.
- */
-OO.ui.mixin.AccessKeyedElement = function OoUiMixinAccessKeyedElement( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Properties
-       this.$accessKeyed = null;
-       this.accessKey = null;
-
-       // Initialization
-       this.setAccessKey( config.accessKey || null );
-       this.setAccessKeyedElement( config.$accessKeyed || this.$element );
-};
-
-/* Setup */
-
-OO.initClass( OO.ui.mixin.AccessKeyedElement );
-
-/* Static Properties */
-
-/**
- * The access key, a function that returns a key, or `null` for no accesskey.
- *
- * @static
- * @inheritable
- * @property {string|Function|null}
- */
-OO.ui.mixin.AccessKeyedElement.static.accessKey = null;
-
-/* Methods */
-
-/**
- * Set the accesskeyed element.
- *
- * This method is used to retarget a AccessKeyedElement mixin so that its functionality applies to the specified element.
- * If an element is already set, the mixin's effect on that element is removed before the new element is set up.
- *
- * @param {jQuery} $accessKeyed Element that should use the 'accesskeyes' functionality
- */
-OO.ui.mixin.AccessKeyedElement.prototype.setAccessKeyedElement = function ( $accessKeyed ) {
-       if ( this.$accessKeyed ) {
-               this.$accessKeyed.removeAttr( 'accesskey' );
-       }
-
-       this.$accessKeyed = $accessKeyed;
-       if ( this.accessKey ) {
-               this.$accessKeyed.attr( 'accesskey', this.accessKey );
-       }
-};
-
-/**
- * Set accesskey.
- *
- * @param {string|Function|null} accesskey Key, a function that returns a key, or `null` for no accesskey
- * @chainable
- */
-OO.ui.mixin.AccessKeyedElement.prototype.setAccessKey = function ( accessKey ) {
-       accessKey = typeof accessKey === 'string' ? OO.ui.resolveMsg( accessKey ) : null;
-
-       if ( this.accessKey !== accessKey ) {
-               if ( this.$accessKeyed ) {
-                       if ( accessKey !== null ) {
-                               this.$accessKeyed.attr( 'accesskey', accessKey );
-                       } else {
-                               this.$accessKeyed.removeAttr( 'accesskey' );
-                       }
-               }
-               this.accessKey = accessKey;
-       }
-
-       return this;
-};
-
-/**
- * Get accesskey.
- *
- * @return {string} accessKey string
- */
-OO.ui.mixin.AccessKeyedElement.prototype.getAccessKey = function () {
-       return this.accessKey;
-};
-
-/**
- * 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
- * @mixins OO.ui.mixin.ButtonElement
- * @mixins OO.ui.mixin.IconElement
- * @mixins OO.ui.mixin.IndicatorElement
- * @mixins OO.ui.mixin.LabelElement
- * @mixins OO.ui.mixin.TitledElement
- * @mixins OO.ui.mixin.FlaggedElement
- * @mixins OO.ui.mixin.TabIndexedElement
- * @mixins OO.ui.mixin.AccessKeyedElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @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 || {};
-
-       // Parent constructor
-       OO.ui.ButtonWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.ButtonElement.call( this, config );
-       OO.ui.mixin.IconElement.call( this, config );
-       OO.ui.mixin.IndicatorElement.call( this, config );
-       OO.ui.mixin.LabelElement.call( this, config );
-       OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
-       OO.ui.mixin.FlaggedElement.call( this, config );
-       OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) );
-       OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$button } ) );
-
-       // Properties
-       this.href = null;
-       this.target = null;
-       this.noFollow = false;
-
-       // Events
-       this.connect( this, { disable: 'onDisable' } );
-
-       // Initialization
-       this.$button.append( this.$icon, this.$label, this.$indicator );
-       this.$element
-               .addClass( 'oo-ui-buttonWidget' )
-               .append( this.$button );
-       this.setHref( config.href );
-       this.setTarget( config.target );
-       this.setNoFollow( config.noFollow );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.ButtonWidget, OO.ui.Widget );
-OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.ButtonElement );
-OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IconElement );
-OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.IndicatorElement );
-OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.LabelElement );
-OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TitledElement );
-OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.FlaggedElement );
-OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.TabIndexedElement );
-OO.mixinClass( OO.ui.ButtonWidget, OO.ui.mixin.AccessKeyedElement );
-
-/* Methods */
-
-/**
- * @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' );
-       }
-
-       return OO.ui.mixin.ButtonElement.prototype.onMouseDown.call( this, e );
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.ButtonWidget.prototype.onMouseUp = function ( e ) {
-       if ( !this.isDisabled() ) {
-               // Restore the tab-index after the button is up to restore the button's accessibility
-               this.$button.attr( 'tabindex', this.tabIndex );
-       }
-
-       return OO.ui.mixin.ButtonElement.prototype.onMouseUp.call( this, e );
-};
-
-/**
- * Get hyperlink location.
- *
- * @return {string} Hyperlink location
- */
-OO.ui.ButtonWidget.prototype.getHref = function () {
-       return this.href;
-};
-
-/**
- * Get hyperlink target.
- *
- * @return {string} Hyperlink target
- */
-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.
- *
- * @param {string|null} href Hyperlink location, null to remove
- */
-OO.ui.ButtonWidget.prototype.setHref = function ( href ) {
-       href = typeof href === 'string' ? href : null;
-       if ( href !== null && !OO.ui.isSafeUrl( href ) ) {
-               href = './' + href;
-       }
-
-       if ( href !== this.href ) {
-               this.href = href;
-               this.updateHref();
-       }
-
-       return this;
-};
-
-/**
- * Update the `href` attribute, in case of changes to href or
- * disabled state.
- *
- * @private
- * @chainable
- */
-OO.ui.ButtonWidget.prototype.updateHref = function () {
-       if ( this.href !== null && !this.isDisabled() ) {
-               this.$button.attr( 'href', this.href );
-       } else {
-               this.$button.removeAttr( 'href' );
-       }
-
-       return this;
-};
-
-/**
- * Handle disable events.
- *
- * @private
- * @param {boolean} disabled Element is disabled
- */
-OO.ui.ButtonWidget.prototype.onDisable = function () {
-       this.updateHref();
-};
-
-/**
- * Set hyperlink target.
- *
- * @param {string|null} target Hyperlink target, null to remove
- */
-OO.ui.ButtonWidget.prototype.setTarget = function ( target ) {
-       target = typeof target === 'string' ? target : null;
-
-       if ( target !== this.target ) {
-               this.target = target;
-               if ( target !== null ) {
-                       this.$button.attr( 'target', target );
-               } else {
-                       this.$button.removeAttr( 'target' );
-               }
-       }
-
-       return this;
-};
-
-/**
- * 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;
-};
-
-/**
- * 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: $( '<p>List of categories...</p>' ),
- *             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
- * @mixins OO.ui.mixin.GroupElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {OO.ui.ButtonWidget[]} [items] Buttons to add
- */
-OO.ui.ButtonGroupWidget = function OoUiButtonGroupWidget( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.ButtonGroupWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-buttonGroupWidget' );
-       if ( Array.isArray( config.items ) ) {
-               this.addItems( config.items );
-       }
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.ButtonGroupWidget, OO.ui.Widget );
-OO.mixinClass( OO.ui.ButtonGroupWidget, OO.ui.mixin.GroupElement );
-
-/**
- * IconWidget is a generic widget for {@link OO.ui.mixin.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 );
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Icons
- *
- * @class
- * @extends OO.ui.Widget
- * @mixins OO.ui.mixin.IconElement
- * @mixins OO.ui.mixin.TitledElement
- * @mixins OO.ui.mixin.FlaggedElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.IconWidget = function OoUiIconWidget( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.IconWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.IconElement.call( this, $.extend( {}, config, { $icon: this.$element } ) );
-       OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
-       OO.ui.mixin.FlaggedElement.call( this, $.extend( {}, config, { $flagged: this.$element } ) );
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-iconWidget' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.IconWidget, OO.ui.Widget );
-OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.IconElement );
-OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.TitledElement );
-OO.mixinClass( OO.ui.IconWidget, OO.ui.mixin.FlaggedElement );
-
-/* Static Properties */
-
-OO.ui.IconWidget.static.tagName = 'span';
-
-/**
- * IndicatorWidgets create indicators, which are small graphics that are generally used to draw
- * attention to the status of an item or to clarify the function of a control. For a list of
- * indicators included in the library, please see the [OOjs UI documentation on MediaWiki][1].
- *
- *     @example
- *     // Example of an indicator widget
- *     var indicator1 = new OO.ui.IndicatorWidget( {
- *         indicator: 'alert'
- *     } );
- *
- *     // Create a fieldset layout to add a label
- *     var fieldset = new OO.ui.FieldsetLayout();
- *     fieldset.addItems( [
- *         new OO.ui.FieldLayout( indicator1, { label: 'An alert indicator:' } )
- *     ] );
- *     $( 'body' ).append( fieldset.$element );
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Icons,_Indicators,_and_Labels#Indicators
- *
- * @class
- * @extends OO.ui.Widget
- * @mixins OO.ui.mixin.IndicatorElement
- * @mixins OO.ui.mixin.TitledElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.IndicatorWidget = function OoUiIndicatorWidget( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.IndicatorWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$element } ) );
-       OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$element } ) );
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-indicatorWidget' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.IndicatorWidget, OO.ui.Widget );
-OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.IndicatorElement );
-OO.mixinClass( OO.ui.IndicatorWidget, OO.ui.mixin.TitledElement );
-
-/* Static Properties */
-
-OO.ui.IndicatorWidget.static.tagName = 'span';
-
-/**
- * LabelWidgets help identify the function of interface elements. Each LabelWidget can
- * be configured with a `label` option that is set to a string, a label node, or a function:
- *
- * - String: a plaintext string
- * - jQuery selection: a jQuery selection, used for anything other than a plaintext label, e.g., a
- *   label that includes a link or special styling, such as a gray color or additional graphical elements.
- * - Function: a function that will produce a string in the future. Functions are used
- *   in cases where the value of the label is not currently defined.
- *
- * In addition, the LabelWidget can be associated with an {@link OO.ui.InputWidget input widget}, which
- * will come into focus when the label is clicked.
- *
- *     @example
- *     // Examples of LabelWidgets
- *     var label1 = new OO.ui.LabelWidget( {
- *         label: 'plaintext label'
- *     } );
- *     var label2 = new OO.ui.LabelWidget( {
- *         label: $( '<a href="default.html">jQuery label</a>' )
- *     } );
- *     // Create a fieldset layout with fields for each example
- *     var fieldset = new OO.ui.FieldsetLayout();
- *     fieldset.addItems( [
- *         new OO.ui.FieldLayout( label1 ),
- *         new OO.ui.FieldLayout( label2 )
- *     ] );
- *     $( 'body' ).append( fieldset.$element );
- *
- * @class
- * @extends OO.ui.Widget
- * @mixins OO.ui.mixin.LabelElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {OO.ui.InputWidget} [input] {@link OO.ui.InputWidget Input widget} that uses the label.
- *  Clicking the label will focus the specified input field.
- */
-OO.ui.LabelWidget = function OoUiLabelWidget( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.LabelWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { $label: this.$element } ) );
-       OO.ui.mixin.TitledElement.call( this, config );
-
-       // Properties
-       this.input = config.input;
-
-       // Events
-       if ( this.input instanceof OO.ui.InputWidget ) {
-               this.$element.on( 'click', this.onClick.bind( this ) );
-       }
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-labelWidget' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.LabelWidget, OO.ui.Widget );
-OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.LabelElement );
-OO.mixinClass( OO.ui.LabelWidget, OO.ui.mixin.TitledElement );
-
-/* Static Properties */
-
-OO.ui.LabelWidget.static.tagName = 'span';
-
-/* Methods */
-
-/**
- * Handles label mouse click events.
- *
- * @private
- * @param {jQuery.Event} e Mouse click event
- */
-OO.ui.LabelWidget.prototype.onClick = function () {
-       this.input.simulateLabelClick();
-       return false;
-};
-
-/**
- * PendingElement is a mixin that is used to create elements that notify users that something is happening
- * and that they should wait before proceeding. The pending state is visually represented with a pending
- * texture that appears in the head of a pending {@link OO.ui.ProcessDialog process dialog} or in the input
- * field of a {@link OO.ui.TextInputWidget text input widget}.
- *
- * Currently, {@link OO.ui.ActionWidget Action widgets}, which mix in this class, can also be marked as pending, but only when
- * used in {@link OO.ui.MessageDialog message dialogs}. The behavior is not currently supported for action widgets used
- * in process dialogs.
- *
- *     @example
- *     function MessageDialog( config ) {
- *         MessageDialog.parent.call( this, config );
- *     }
- *     OO.inheritClass( MessageDialog, OO.ui.MessageDialog );
- *
- *     MessageDialog.static.actions = [
- *         { action: 'save', label: 'Done', flags: 'primary' },
- *         { label: 'Cancel', flags: 'safe' }
- *     ];
- *
- *     MessageDialog.prototype.initialize = function () {
- *         MessageDialog.parent.prototype.initialize.apply( this, arguments );
- *         this.content = new OO.ui.PanelLayout( { $: this.$, padded: true } );
- *         this.content.$element.append( '<p>Click the \'Done\' action widget to see its pending state. Note that action widgets can be marked pending in message dialogs but not process dialogs.</p>' );
- *         this.$body.append( this.content.$element );
- *     };
- *     MessageDialog.prototype.getBodyHeight = function () {
- *         return 100;
- *     }
- *     MessageDialog.prototype.getActionProcess = function ( action ) {
- *         var dialog = this;
- *         if ( action === 'save' ) {
- *             dialog.getActions().get({actions: 'save'})[0].pushPending();
- *             return new OO.ui.Process()
- *             .next( 1000 )
- *             .next( function () {
- *                 dialog.getActions().get({actions: 'save'})[0].popPending();
- *             } );
- *         }
- *         return MessageDialog.parent.prototype.getActionProcess.call( this, action );
- *     };
- *
- *     var windowManager = new OO.ui.WindowManager();
- *     $( 'body' ).append( windowManager.$element );
- *
- *     var dialog = new MessageDialog();
- *     windowManager.addWindows( [ dialog ] );
- *     windowManager.openWindow( dialog );
- *
- * @abstract
- * @class
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {jQuery} [$pending] Element to mark as pending, defaults to this.$element
- */
-OO.ui.mixin.PendingElement = function OoUiMixinPendingElement( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Properties
-       this.pending = 0;
-       this.$pending = null;
-
-       // Initialisation
-       this.setPendingElement( config.$pending || this.$element );
-};
-
-/* Setup */
-
-OO.initClass( OO.ui.mixin.PendingElement );
-
-/* Methods */
-
-/**
- * Set the pending element (and clean up any existing one).
- *
- * @param {jQuery} $pending The element to set to pending.
- */
-OO.ui.mixin.PendingElement.prototype.setPendingElement = function ( $pending ) {
-       if ( this.$pending ) {
-               this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
-       }
-
-       this.$pending = $pending;
-       if ( this.pending > 0 ) {
-               this.$pending.addClass( 'oo-ui-pendingElement-pending' );
-       }
-};
-
-/**
- * Check if an element is pending.
- *
- * @return {boolean} Element is pending
- */
-OO.ui.mixin.PendingElement.prototype.isPending = function () {
-       return !!this.pending;
-};
-
-/**
- * Increase the pending counter. The pending state will remain active until the counter is zero
- * (i.e., the number of calls to #pushPending and #popPending is the same).
- *
- * @chainable
- */
-OO.ui.mixin.PendingElement.prototype.pushPending = function () {
-       if ( this.pending === 0 ) {
-               this.$pending.addClass( 'oo-ui-pendingElement-pending' );
-               this.updateThemeClasses();
-       }
-       this.pending++;
-
-       return this;
-};
-
-/**
- * Decrease the pending counter. The pending state will remain active until the counter is zero
- * (i.e., the number of calls to #pushPending and #popPending is the same).
- *
- * @chainable
- */
-OO.ui.mixin.PendingElement.prototype.popPending = function () {
-       if ( this.pending === 1 ) {
-               this.$pending.removeClass( 'oo-ui-pendingElement-pending' );
-               this.updateThemeClasses();
-       }
-       this.pending = Math.max( 0, this.pending - 1 );
-
-       return this;
-};
-
-/**
- * Element that can be automatically clipped to visible boundaries.
- *
- * Whenever the element's natural height changes, you have to call
- * {@link OO.ui.mixin.ClippableElement#clip} to make sure it's still
- * clipping correctly.
- *
- * The dimensions of #$clippableContainer will be compared to the boundaries of the
- * nearest scrollable container. If #$clippableContainer is too tall and/or too wide,
- * then #$clippable will be given a fixed reduced height and/or width and will be made
- * scrollable. By default, #$clippable and #$clippableContainer are the same element,
- * but you can build a static footer by setting #$clippableContainer to an element that contains
- * #$clippable and the footer.
- *
- * @abstract
- * @class
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {jQuery} [$clippable] Node to clip, assigned to #$clippable, omit to use #$element
- * @cfg {jQuery} [$clippableContainer] Node to keep visible, assigned to #$clippableContainer,
- *   omit to use #$clippable
- */
-OO.ui.mixin.ClippableElement = function OoUiMixinClippableElement( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Properties
-       this.$clippable = null;
-       this.$clippableContainer = null;
-       this.clipping = false;
-       this.clippedHorizontally = false;
-       this.clippedVertically = false;
-       this.$clippableScrollableContainer = null;
-       this.$clippableScroller = null;
-       this.$clippableWindow = null;
-       this.idealWidth = null;
-       this.idealHeight = null;
-       this.onClippableScrollHandler = this.clip.bind( this );
-       this.onClippableWindowResizeHandler = this.clip.bind( this );
-
-       // Initialization
-       if ( config.$clippableContainer ) {
-               this.setClippableContainer( config.$clippableContainer );
-       }
-       this.setClippableElement( config.$clippable || this.$element );
-};
-
-/* Methods */
-
-/**
- * Set clippable element.
- *
- * If an element is already set, it will be cleaned up before setting up the new element.
- *
- * @param {jQuery} $clippable Element to make clippable
- */
-OO.ui.mixin.ClippableElement.prototype.setClippableElement = function ( $clippable ) {
-       if ( this.$clippable ) {
-               this.$clippable.removeClass( 'oo-ui-clippableElement-clippable' );
-               this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
-               OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
-       }
-
-       this.$clippable = $clippable.addClass( 'oo-ui-clippableElement-clippable' );
-       this.clip();
-};
-
-/**
- * Set clippable container.
- *
- * This is the container that will be measured when deciding whether to clip. When clipping,
- * #$clippable will be resized in order to keep the clippable container fully visible.
- *
- * If the clippable container is unset, #$clippable will be used.
- *
- * @param {jQuery|null} $clippableContainer Container to keep visible, or null to unset
- */
-OO.ui.mixin.ClippableElement.prototype.setClippableContainer = function ( $clippableContainer ) {
-       this.$clippableContainer = $clippableContainer;
-       if ( this.$clippable ) {
-               this.clip();
-       }
-};
-
-/**
- * Toggle clipping.
- *
- * Do not turn clipping on until after the element is attached to the DOM and visible.
- *
- * @param {boolean} [clipping] Enable clipping, omit to toggle
- * @chainable
- */
-OO.ui.mixin.ClippableElement.prototype.toggleClipping = function ( clipping ) {
-       clipping = clipping === undefined ? !this.clipping : !!clipping;
-
-       if ( this.clipping !== clipping ) {
-               this.clipping = clipping;
-               if ( clipping ) {
-                       this.$clippableScrollableContainer = $( 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.$clippableScrollableContainer.is( 'html, body' ) ?
-                               $( OO.ui.Element.static.getWindow( this.$clippableScrollableContainer ) ) :
-                               this.$clippableScrollableContainer;
-                       this.$clippableScroller.on( 'scroll', this.onClippableScrollHandler );
-                       this.$clippableWindow = $( this.getElementWindow() )
-                               .on( 'resize', this.onClippableWindowResizeHandler );
-                       // Initial clip after visible
-                       this.clip();
-               } else {
-                       this.$clippable.css( { width: '', height: '', overflowX: '', overflowY: '' } );
-                       OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
-
-                       this.$clippableScrollableContainer = null;
-                       this.$clippableScroller.off( 'scroll', this.onClippableScrollHandler );
-                       this.$clippableScroller = null;
-                       this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
-                       this.$clippableWindow = null;
-               }
-       }
-
-       return this;
-};
-
-/**
- * Check if the element will be clipped to fit the visible area of the nearest scrollable container.
- *
- * @return {boolean} Element will be clipped to the visible area
- */
-OO.ui.mixin.ClippableElement.prototype.isClipping = function () {
-       return this.clipping;
-};
-
-/**
- * Check if the bottom or right of the element is being clipped by the nearest scrollable container.
- *
- * @return {boolean} Part of the element is being clipped
- */
-OO.ui.mixin.ClippableElement.prototype.isClipped = function () {
-       return this.clippedHorizontally || this.clippedVertically;
-};
-
-/**
- * Check if the right of the element is being clipped by the nearest scrollable container.
- *
- * @return {boolean} Part of the element is being clipped
- */
-OO.ui.mixin.ClippableElement.prototype.isClippedHorizontally = function () {
-       return this.clippedHorizontally;
-};
-
-/**
- * Check if the bottom of the element is being clipped by the nearest scrollable container.
- *
- * @return {boolean} Part of the element is being clipped
- */
-OO.ui.mixin.ClippableElement.prototype.isClippedVertically = function () {
-       return this.clippedVertically;
-};
-
-/**
- * Set the ideal size. These are the dimensions the element will have when it's not being clipped.
- *
- * @param {number|string} [width] Width as a number of pixels or CSS string with unit suffix
- * @param {number|string} [height] Height as a number of pixels or CSS string with unit suffix
- */
-OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height ) {
-       this.idealWidth = width;
-       this.idealHeight = height;
-
-       if ( !this.clipping ) {
-               // Update dimensions
-               this.$clippable.css( { width: width, height: height } );
-       }
-       // While clipping, idealWidth and idealHeight are not considered
-};
-
-/**
- * Clip element to visible boundaries and allow scrolling when needed. Call this method when
- * the element's natural height changes.
- *
- * Element will be clipped the bottom or right of the element is within 10px of the edge of, or
- * overlapped by, the visible area of the nearest scrollable container.
- *
- * @chainable
- */
-OO.ui.mixin.ClippableElement.prototype.clip = function () {
-       var $container, extraHeight, extraWidth, ccOffset,
-               $scrollableContainer, scOffset, scHeight, scWidth,
-               ccWidth, scrollerIsWindow, scrollTop, scrollLeft,
-               desiredWidth, desiredHeight, allotedWidth, allotedHeight,
-               naturalWidth, naturalHeight, clipWidth, clipHeight,
-               buffer = 7; // Chosen by fair dice roll
-
-       if ( !this.clipping ) {
-               // this.$clippableScrollableContainer and this.$clippableWindow are null, so the below will fail
-               return this;
-       }
-
-       $container = this.$clippableContainer || this.$clippable;
-       extraHeight = $container.outerHeight() - this.$clippable.outerHeight();
-       extraWidth = $container.outerWidth() - this.$clippable.outerWidth();
-       ccOffset = $container.offset();
-       $scrollableContainer = this.$clippableScrollableContainer.is( 'html, body' ) ?
-               this.$clippableWindow : this.$clippableScrollableContainer;
-       scOffset = $scrollableContainer.offset() || { top: 0, left: 0 };
-       scHeight = $scrollableContainer.innerHeight() - buffer;
-       scWidth = $scrollableContainer.innerWidth() - buffer;
-       ccWidth = $container.outerWidth() + buffer;
-       scrollerIsWindow = this.$clippableScroller[ 0 ] === this.$clippableWindow[ 0 ];
-       scrollTop = scrollerIsWindow ? this.$clippableScroller.scrollTop() : 0;
-       scrollLeft = scrollerIsWindow ? this.$clippableScroller.scrollLeft() : 0;
-       desiredWidth = ccOffset.left < 0 ?
-               ccWidth + ccOffset.left :
-               ( scOffset.left + scrollLeft + scWidth ) - ccOffset.left;
-       desiredHeight = ( scOffset.top + scrollTop + scHeight ) - ccOffset.top;
-       allotedWidth = Math.ceil( desiredWidth - extraWidth );
-       allotedHeight = Math.ceil( desiredHeight - extraHeight );
-       naturalWidth = this.$clippable.prop( 'scrollWidth' );
-       naturalHeight = this.$clippable.prop( 'scrollHeight' );
-       clipWidth = allotedWidth < naturalWidth;
-       clipHeight = allotedHeight < naturalHeight;
-
-       if ( clipWidth ) {
-               this.$clippable.css( { overflowX: 'scroll', width: Math.max( 0, allotedWidth ) } );
-       } else {
-               this.$clippable.css( { width: this.idealWidth ? this.idealWidth - extraWidth : '', overflowX: '' } );
-       }
-       if ( clipHeight ) {
-               this.$clippable.css( { overflowY: 'scroll', height: Math.max( 0, allotedHeight ) } );
-       } else {
-               this.$clippable.css( { height: this.idealHeight ? this.idealHeight - extraHeight : '', overflowY: '' } );
-       }
-
-       // If we stopped clipping in at least one of the dimensions
-       if ( ( this.clippedHorizontally && !clipWidth ) || ( this.clippedVertically && !clipHeight ) ) {
-               OO.ui.Element.static.reconsiderScrollbars( this.$clippable[ 0 ] );
-       }
-
-       this.clippedHorizontally = clipWidth;
-       this.clippedVertically = clipHeight;
-
-       return this;
-};
-
-/**
- * PopupWidget is a container for content. The popup is overlaid and positioned absolutely.
- * By default, each popup has an anchor that points toward its origin.
- * Please see the [OOjs UI documentation on Mediawiki] [1] for more information and examples.
- *
- *     @example
- *     // A popup widget.
- *     var popup = new OO.ui.PopupWidget( {
- *         $content: $( '<p>Hi there!</p>' ),
- *         padded: true,
- *         width: 300
- *     } );
- *
- *     $( 'body' ).append( popup.$element );
- *     // To display the popup, toggle the visibility to 'true'.
- *     popup.toggle( true );
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups
- *
- * @class
- * @extends OO.ui.Widget
- * @mixins OO.ui.mixin.LabelElement
- * @mixins OO.ui.mixin.ClippableElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {number} [width=320] Width of popup in pixels
- * @cfg {number} [height] Height of popup in pixels. Omit to use the automatic height.
- * @cfg {boolean} [anchor=true] Show anchor pointing to origin of popup
- * @cfg {string} [align='center'] Alignment of the popup: `center`, `force-left`, `force-right`, `backwards` or `forwards`.
- *  If the popup is forced-left the popup body is leaning towards the left. For force-right alignment, the body of the
- *  popup is leaning towards the right of the screen.
- *  Using 'backwards' is a logical direction which will result in the popup leaning towards the beginning of the sentence
- *  in the given language, which means it will flip to the correct positioning in right-to-left languages.
- *  Using 'forward' will also result in a logical alignment where the body of the popup leans towards the end of the
- *  sentence in the given language.
- * @cfg {jQuery} [$container] Constrain the popup to the boundaries of the specified container.
- *  See the [OOjs UI docs on MediaWiki][3] for an example.
- *  [3]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups#containerExample
- * @cfg {number} [containerPadding=10] Padding between the popup and its container, specified as a number of pixels.
- * @cfg {jQuery} [$content] Content to append to the popup's body
- * @cfg {jQuery} [$footer] Content to append to the popup's footer
- * @cfg {boolean} [autoClose=false] Automatically close the popup when it loses focus.
- * @cfg {jQuery} [$autoCloseIgnore] Elements that will not close the popup when clicked.
- *  This config option is only relevant if #autoClose is set to `true`. See the [OOjs UI docs on MediaWiki][2]
- *  for an example.
- *  [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Popups#autocloseExample
- * @cfg {boolean} [head] Show a popup header that contains a #label (if specified) and close
- *  button.
- * @cfg {boolean} [padded] Add padding to the popup's body
- */
-OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.PopupWidget.parent.call( this, config );
-
-       // Properties (must be set before ClippableElement constructor call)
-       this.$body = $( '<div>' );
-       this.$popup = $( '<div>' );
-
-       // Mixin constructors
-       OO.ui.mixin.LabelElement.call( this, config );
-       OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, {
-               $clippable: this.$body,
-               $clippableContainer: this.$popup
-       } ) );
-
-       // Properties
-       this.$head = $( '<div>' );
-       this.$footer = $( '<div>' );
-       this.$anchor = $( '<div>' );
-       // If undefined, will be computed lazily in updateDimensions()
-       this.$container = config.$container;
-       this.containerPadding = config.containerPadding !== undefined ? config.containerPadding : 10;
-       this.autoClose = !!config.autoClose;
-       this.$autoCloseIgnore = config.$autoCloseIgnore;
-       this.transitionTimeout = null;
-       this.anchor = null;
-       this.width = config.width !== undefined ? config.width : 320;
-       this.height = config.height !== undefined ? config.height : null;
-       this.setAlignment( config.align );
-       this.closeButton = new OO.ui.ButtonWidget( { framed: false, icon: 'close' } );
-       this.onMouseDownHandler = this.onMouseDown.bind( this );
-       this.onDocumentKeyDownHandler = this.onDocumentKeyDown.bind( this );
-
-       // Events
-       this.closeButton.connect( this, { click: 'onCloseButtonClick' } );
-
-       // Initialization
-       this.toggleAnchor( config.anchor === undefined || config.anchor );
-       this.$body.addClass( 'oo-ui-popupWidget-body' );
-       this.$anchor.addClass( 'oo-ui-popupWidget-anchor' );
-       this.$head
-               .addClass( 'oo-ui-popupWidget-head' )
-               .append( this.$label, this.closeButton.$element );
-       this.$footer.addClass( 'oo-ui-popupWidget-footer' );
-       if ( !config.head ) {
-               this.$head.addClass( 'oo-ui-element-hidden' );
-       }
-       if ( !config.$footer ) {
-               this.$footer.addClass( 'oo-ui-element-hidden' );
-       }
-       this.$popup
-               .addClass( 'oo-ui-popupWidget-popup' )
-               .append( this.$head, this.$body, this.$footer );
-       this.$element
-               .addClass( 'oo-ui-popupWidget' )
-               .append( this.$popup, this.$anchor );
-       // Move content, which was added to #$element by OO.ui.Widget, to the body
-       if ( config.$content instanceof jQuery ) {
-               this.$body.append( config.$content );
-       }
-       if ( config.$footer instanceof jQuery ) {
-               this.$footer.append( config.$footer );
-       }
-       if ( config.padded ) {
-               this.$body.addClass( 'oo-ui-popupWidget-body-padded' );
-       }
-
-       // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
-       // that reference properties not initialized at that time of parent class construction
-       // TODO: Find a better way to handle post-constructor setup
-       this.visible = false;
-       this.$element.addClass( 'oo-ui-element-hidden' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.PopupWidget, OO.ui.Widget );
-OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.LabelElement );
-OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.ClippableElement );
-
-/* Methods */
-
-/**
- * Handles mouse down events.
- *
- * @private
- * @param {MouseEvent} e Mouse down event
- */
-OO.ui.PopupWidget.prototype.onMouseDown = function ( e ) {
-       if (
-               this.isVisible() &&
-               !$.contains( this.$element[ 0 ], e.target ) &&
-               ( !this.$autoCloseIgnore || !this.$autoCloseIgnore.has( e.target ).length )
-       ) {
-               this.toggle( false );
-       }
-};
-
-/**
- * Bind mouse down listener.
- *
- * @private
- */
-OO.ui.PopupWidget.prototype.bindMouseDownListener = function () {
-       // Capture clicks outside popup
-       this.getElementWindow().addEventListener( 'mousedown', this.onMouseDownHandler, true );
-};
-
-/**
- * Handles close button click events.
- *
- * @private
- */
-OO.ui.PopupWidget.prototype.onCloseButtonClick = function () {
-       if ( this.isVisible() ) {
-               this.toggle( false );
-       }
-};
-
-/**
- * Unbind mouse down listener.
- *
- * @private
- */
-OO.ui.PopupWidget.prototype.unbindMouseDownListener = function () {
-       this.getElementWindow().removeEventListener( 'mousedown', this.onMouseDownHandler, true );
-};
-
-/**
- * Handles key down events.
- *
- * @private
- * @param {KeyboardEvent} e Key down event
- */
-OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) {
-       if (
-               e.which === OO.ui.Keys.ESCAPE &&
-               this.isVisible()
-       ) {
-               this.toggle( false );
-               e.preventDefault();
-               e.stopPropagation();
-       }
-};
-
-/**
- * Bind key down listener.
- *
- * @private
- */
-OO.ui.PopupWidget.prototype.bindKeyDownListener = function () {
-       this.getElementWindow().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
-};
-
-/**
- * Unbind key down listener.
- *
- * @private
- */
-OO.ui.PopupWidget.prototype.unbindKeyDownListener = function () {
-       this.getElementWindow().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
-};
-
-/**
- * Show, hide, or toggle the visibility of the anchor.
- *
- * @param {boolean} [show] Show anchor, omit to toggle
- */
-OO.ui.PopupWidget.prototype.toggleAnchor = function ( show ) {
-       show = show === undefined ? !this.anchored : !!show;
-
-       if ( this.anchored !== show ) {
-               if ( show ) {
-                       this.$element.addClass( 'oo-ui-popupWidget-anchored' );
-               } else {
-                       this.$element.removeClass( 'oo-ui-popupWidget-anchored' );
-               }
-               this.anchored = show;
-       }
-};
-
-/**
- * Check if the anchor is visible.
- *
- * @return {boolean} Anchor is visible
- */
-OO.ui.PopupWidget.prototype.hasAnchor = function () {
-       return this.anchor;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.PopupWidget.prototype.toggle = function ( show ) {
-       var change;
-       show = show === undefined ? !this.isVisible() : !!show;
-
-       change = show !== this.isVisible();
-
-       // Parent method
-       OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
-
-       if ( change ) {
-               if ( show ) {
-                       if ( this.autoClose ) {
-                               this.bindMouseDownListener();
-                               this.bindKeyDownListener();
-                       }
-                       this.updateDimensions();
-                       this.toggleClipping( true );
-               } else {
-                       this.toggleClipping( false );
-                       if ( this.autoClose ) {
-                               this.unbindMouseDownListener();
-                               this.unbindKeyDownListener();
-                       }
-               }
-       }
-
-       return this;
-};
-
-/**
- * Set the size of the popup.
- *
- * Changing the size may also change the popup's position depending on the alignment.
- *
- * @param {number} width Width in pixels
- * @param {number} height Height in pixels
- * @param {boolean} [transition=false] Use a smooth transition
- * @chainable
- */
-OO.ui.PopupWidget.prototype.setSize = function ( width, height, transition ) {
-       this.width = width;
-       this.height = height !== undefined ? height : null;
-       if ( this.isVisible() ) {
-               this.updateDimensions( transition );
-       }
-};
-
-/**
- * Update the size and position.
- *
- * Only use this to keep the popup properly anchored. Use #setSize to change the size, and this will
- * be called automatically.
- *
- * @param {boolean} [transition=false] Use a smooth transition
- * @chainable
- */
-OO.ui.PopupWidget.prototype.updateDimensions = function ( transition ) {
-       var popupOffset, originOffset, containerLeft, containerWidth, containerRight,
-               popupLeft, popupRight, overlapLeft, overlapRight, anchorWidth,
-               align = this.align,
-               widget = this;
-
-       if ( !this.$container ) {
-               // Lazy-initialize $container if not specified in constructor
-               this.$container = $( this.getClosestScrollableElementContainer() );
-       }
-
-       // Set height and width before measuring things, since it might cause our measurements
-       // to change (e.g. due to scrollbars appearing or disappearing)
-       this.$popup.css( {
-               width: this.width,
-               height: this.height !== null ? this.height : 'auto'
-       } );
-
-       // If we are in RTL, we need to flip the alignment, unless it is center
-       if ( align === 'forwards' || align === 'backwards' ) {
-               if ( this.$container.css( 'direction' ) === 'rtl' ) {
-                       align = ( { forwards: 'force-left', backwards: 'force-right' } )[ this.align ];
-               } else {
-                       align = ( { forwards: 'force-right', backwards: 'force-left' } )[ this.align ];
-               }
-
-       }
-
-       // Compute initial popupOffset based on alignment
-       popupOffset = this.width * ( { 'force-left': -1, center: -0.5, 'force-right': 0 } )[ align ];
-
-       // Figure out if this will cause the popup to go beyond the edge of the container
-       originOffset = this.$element.offset().left;
-       containerLeft = this.$container.offset().left;
-       containerWidth = this.$container.innerWidth();
-       containerRight = containerLeft + containerWidth;
-       popupLeft = popupOffset - this.containerPadding;
-       popupRight = popupOffset + this.containerPadding + this.width + this.containerPadding;
-       overlapLeft = ( originOffset + popupLeft ) - containerLeft;
-       overlapRight = containerRight - ( originOffset + popupRight );
-
-       // Adjust offset to make the popup not go beyond the edge, if needed
-       if ( overlapRight < 0 ) {
-               popupOffset += overlapRight;
-       } else if ( overlapLeft < 0 ) {
-               popupOffset -= overlapLeft;
-       }
-
-       // Adjust offset to avoid anchor being rendered too close to the edge
-       // $anchor.width() doesn't work with the pure CSS anchor (returns 0)
-       // TODO: Find a measurement that works for CSS anchors and image anchors
-       anchorWidth = this.$anchor[ 0 ].scrollWidth * 2;
-       if ( popupOffset + this.width < anchorWidth ) {
-               popupOffset = anchorWidth - this.width;
-       } else if ( -popupOffset < anchorWidth ) {
-               popupOffset = -anchorWidth;
-       }
-
-       // Prevent transition from being interrupted
-       clearTimeout( this.transitionTimeout );
-       if ( transition ) {
-               // Enable transition
-               this.$element.addClass( 'oo-ui-popupWidget-transitioning' );
-       }
-
-       // Position body relative to anchor
-       this.$popup.css( 'margin-left', popupOffset );
-
-       if ( transition ) {
-               // Prevent transitioning after transition is complete
-               this.transitionTimeout = setTimeout( function () {
-                       widget.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
-               }, 200 );
-       } else {
-               // Prevent transitioning immediately
-               this.$element.removeClass( 'oo-ui-popupWidget-transitioning' );
-       }
-
-       // Reevaluate clipping state since we've relocated and resized the popup
-       this.clip();
-
-       return this;
-};
-
-/**
- * Set popup alignment
- * @param {string} align Alignment of the popup, `center`, `force-left`, `force-right`,
- *  `backwards` or `forwards`.
- */
-OO.ui.PopupWidget.prototype.setAlignment = function ( align ) {
-       // Validate alignment and transform deprecated values
-       if ( [ 'left', 'right', 'force-left', 'force-right', 'backwards', 'forwards', 'center' ].indexOf( align ) > -1 ) {
-               this.align = { left: 'force-right', right: 'force-left' }[ align ] || align;
-       } else {
-               this.align = 'center';
-       }
-};
-
-/**
- * Get popup alignment
- * @return {string} align Alignment of the popup, `center`, `force-left`, `force-right`,
- *  `backwards` or `forwards`.
- */
-OO.ui.PopupWidget.prototype.getAlignment = function () {
-       return this.align;
-};
-
-/**
- * PopupElement is mixed into other classes to generate a {@link OO.ui.PopupWidget popup widget}.
- * A popup is a container for content. It is overlaid and positioned absolutely. By default, each
- * popup has an anchor, which is an arrow-like protrusion that points toward the popup’s origin.
- * See {@link OO.ui.PopupWidget PopupWidget} for an example.
- *
- * @abstract
- * @class
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {Object} [popup] Configuration to pass to popup
- * @cfg {boolean} [popup.autoClose=true] Popup auto-closes when it loses focus
- */
-OO.ui.mixin.PopupElement = function OoUiMixinPopupElement( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Properties
-       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.mixin.PopupElement.prototype.getPopup = function () {
-       return this.popup;
-};
-
-/**
- * PopupButtonWidgets toggle the visibility of a contained {@link OO.ui.PopupWidget PopupWidget},
- * which is used to display additional information or options.
- *
- *     @example
- *     // Example of a popup button.
- *     var popupButton = new OO.ui.PopupButtonWidget( {
- *         label: 'Popup button with options',
- *         icon: 'menu',
- *         popup: {
- *             $content: $( '<p>Additional options here.</p>' ),
- *             padded: true,
- *             align: 'force-left'
- *         }
- *     } );
- *     // Append the button to the DOM.
- *     $( 'body' ).append( popupButton.$element );
- *
- * @class
- * @extends OO.ui.ButtonWidget
- * @mixins OO.ui.mixin.PopupElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
-       // Parent constructor
-       OO.ui.PopupButtonWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.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 );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.PopupButtonWidget, OO.ui.ButtonWidget );
-OO.mixinClass( OO.ui.PopupButtonWidget, OO.ui.mixin.PopupElement );
-
-/* Methods */
-
-/**
- * Handle the button action being triggered.
- *
- * @private
- */
-OO.ui.PopupButtonWidget.prototype.onAction = function () {
-       this.popup.toggle();
-};
-
-/**
- * Mixin for OO.ui.Widget subclasses to provide OO.ui.mixin.GroupElement.
- *
- * Use together with OO.ui.mixin.ItemWidget to make disabled state inheritable.
- *
- * @private
- * @abstract
- * @class
- * @extends OO.ui.mixin.GroupElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.mixin.GroupWidget = function OoUiMixinGroupWidget( config ) {
-       // Parent constructor
-       OO.ui.mixin.GroupWidget.parent.call( this, config );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.mixin.GroupWidget, OO.ui.mixin.GroupElement );
-
-/* Methods */
-
-/**
- * Set the disabled state of the widget.
- *
- * This will also update the disabled state of child widgets.
- *
- * @param {boolean} disabled Disable widget
- * @chainable
- */
-OO.ui.mixin.GroupWidget.prototype.setDisabled = function ( disabled ) {
-       var i, len;
-
-       // Parent method
-       // Note: Calling #setDisabled this way assumes this is mixed into an OO.ui.Widget
-       OO.ui.Widget.prototype.setDisabled.call( this, disabled );
-
-       // During construction, #setDisabled is called before the OO.ui.mixin.GroupElement constructor
-       if ( this.items ) {
-               for ( i = 0, len = this.items.length; i < len; i++ ) {
-                       this.items[ i ].updateDisabled();
-               }
-       }
-
-       return this;
-};
-
-/**
- * Mixin for widgets used as items in widgets that mix in OO.ui.mixin.GroupWidget.
- *
- * Item widgets have a reference to a OO.ui.mixin.GroupWidget while they are attached to the group. This
- * allows bidirectional communication.
- *
- * Use together with OO.ui.mixin.GroupWidget to make disabled state inheritable.
- *
- * @private
- * @abstract
- * @class
- *
- * @constructor
- */
-OO.ui.mixin.ItemWidget = function OoUiMixinItemWidget() {
-       //
-};
-
-/* Methods */
-
-/**
- * Check if widget is disabled.
- *
- * Checks parent if present, making disabled state inheritable.
- *
- * @return {boolean} Widget is disabled
- */
-OO.ui.mixin.ItemWidget.prototype.isDisabled = function () {
-       return this.disabled ||
-               ( this.elementGroup instanceof OO.ui.Widget && this.elementGroup.isDisabled() );
-};
-
-/**
- * Set group element is in.
- *
- * @param {OO.ui.mixin.GroupElement|null} group Group element, null if none
- * @chainable
- */
-OO.ui.mixin.ItemWidget.prototype.setElementGroup = function ( group ) {
-       // Parent method
-       // Note: Calling #setElementGroup this way assumes this is mixed into an OO.ui.Element
-       OO.ui.Element.prototype.setElementGroup.call( this, group );
-
-       // Initialize item disabled states
-       this.updateDisabled();
-
-       return this;
-};
-
-/**
- * OptionWidgets are special elements that can be selected and configured with data. The
- * data is often unique for each option, but it does not have to be. OptionWidgets are used
- * with OO.ui.SelectWidget to create a selection of mutually exclusive options. For more information
- * and examples, please see the [OOjs UI documentation on MediaWiki][1].
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
- *
- * @class
- * @extends OO.ui.Widget
- * @mixins OO.ui.mixin.LabelElement
- * @mixins OO.ui.mixin.FlaggedElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.OptionWidget = function OoUiOptionWidget( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.OptionWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.ItemWidget.call( this );
-       OO.ui.mixin.LabelElement.call( this, config );
-       OO.ui.mixin.FlaggedElement.call( this, config );
-
-       // Properties
-       this.selected = false;
-       this.highlighted = false;
-       this.pressed = false;
-
-       // Initialization
-       this.$element
-               .data( 'oo-ui-optionWidget', this )
-               .attr( 'role', 'option' )
-               .attr( 'aria-selected', 'false' )
-               .addClass( 'oo-ui-optionWidget' )
-               .append( this.$label );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.OptionWidget, OO.ui.Widget );
-OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.ItemWidget );
-OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.LabelElement );
-OO.mixinClass( OO.ui.OptionWidget, OO.ui.mixin.FlaggedElement );
-
-/* Static Properties */
-
-OO.ui.OptionWidget.static.selectable = true;
-
-OO.ui.OptionWidget.static.highlightable = true;
-
-OO.ui.OptionWidget.static.pressable = true;
-
-OO.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
-
-/* Methods */
-
-/**
- * Check if the option can be selected.
- *
- * @return {boolean} Item is selectable
- */
-OO.ui.OptionWidget.prototype.isSelectable = function () {
-       return this.constructor.static.selectable && !this.isDisabled() && this.isVisible();
-};
-
-/**
- * Check if the option can be highlighted. A highlight indicates that the option
- * may be selected when a user presses enter or clicks. Disabled items cannot
- * be highlighted.
- *
- * @return {boolean} Item is highlightable
- */
-OO.ui.OptionWidget.prototype.isHighlightable = function () {
-       return this.constructor.static.highlightable && !this.isDisabled() && this.isVisible();
-};
-
-/**
- * Check if the option can be pressed. The pressed state occurs when a user mouses
- * down on an item, but has not yet let go of the mouse.
- *
- * @return {boolean} Item is pressable
- */
-OO.ui.OptionWidget.prototype.isPressable = function () {
-       return this.constructor.static.pressable && !this.isDisabled() && this.isVisible();
-};
-
-/**
- * Check if the option is selected.
- *
- * @return {boolean} Item is selected
- */
-OO.ui.OptionWidget.prototype.isSelected = function () {
-       return this.selected;
-};
-
-/**
- * Check if the option is highlighted. A highlight indicates that the
- * item may be selected when a user presses enter or clicks.
- *
- * @return {boolean} Item is highlighted
- */
-OO.ui.OptionWidget.prototype.isHighlighted = function () {
-       return this.highlighted;
-};
-
-/**
- * Check if the option is pressed. The pressed state occurs when a user mouses
- * down on an item, but has not yet let go of the mouse. The item may appear
- * selected, but it will not be selected until the user releases the mouse.
- *
- * @return {boolean} Item is pressed
- */
-OO.ui.OptionWidget.prototype.isPressed = function () {
-       return this.pressed;
-};
-
-/**
- * Set the option’s selected state. In general, all modifications to the selection
- * should be handled by the SelectWidget’s {@link OO.ui.SelectWidget#selectItem selectItem( [item] )}
- * method instead of this method.
- *
- * @param {boolean} [state=false] Select option
- * @chainable
- */
-OO.ui.OptionWidget.prototype.setSelected = function ( state ) {
-       if ( this.constructor.static.selectable ) {
-               this.selected = !!state;
-               this.$element
-                       .toggleClass( 'oo-ui-optionWidget-selected', state )
-                       .attr( 'aria-selected', state.toString() );
-               if ( state && this.constructor.static.scrollIntoViewOnSelect ) {
-                       this.scrollElementIntoView();
-               }
-               this.updateThemeClasses();
-       }
-       return this;
-};
-
-/**
- * Set the option’s highlighted state. In general, all programmatic
- * modifications to the highlight should be handled by the
- * SelectWidget’s {@link OO.ui.SelectWidget#highlightItem highlightItem( [item] )}
- * method instead of this method.
- *
- * @param {boolean} [state=false] Highlight option
- * @chainable
- */
-OO.ui.OptionWidget.prototype.setHighlighted = function ( state ) {
-       if ( this.constructor.static.highlightable ) {
-               this.highlighted = !!state;
-               this.$element.toggleClass( 'oo-ui-optionWidget-highlighted', state );
-               this.updateThemeClasses();
-       }
-       return this;
-};
-
-/**
- * Set the option’s pressed state. In general, all
- * programmatic modifications to the pressed state should be handled by the
- * SelectWidget’s {@link OO.ui.SelectWidget#pressItem pressItem( [item] )}
- * method instead of this method.
- *
- * @param {boolean} [state=false] Press option
- * @chainable
- */
-OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
-       if ( this.constructor.static.pressable ) {
-               this.pressed = !!state;
-               this.$element.toggleClass( 'oo-ui-optionWidget-pressed', state );
-               this.updateThemeClasses();
-       }
-       return this;
-};
-
-/**
- * A SelectWidget is of a generic selection of options. The OOjs UI library contains several types of
- * select widgets, including {@link OO.ui.ButtonSelectWidget button selects},
- * {@link OO.ui.RadioSelectWidget radio selects}, and {@link OO.ui.MenuSelectWidget
- * menu selects}.
- *
- * This class should be used together with OO.ui.OptionWidget or OO.ui.DecoratedOptionWidget. For more
- * information, please see the [OOjs UI documentation on MediaWiki][1].
- *
- *     @example
- *     // Example of a select widget with three options
- *     var select = new OO.ui.SelectWidget( {
- *         items: [
- *             new OO.ui.OptionWidget( {
- *                 data: 'a',
- *                 label: 'Option One',
- *             } ),
- *             new OO.ui.OptionWidget( {
- *                 data: 'b',
- *                 label: 'Option Two',
- *             } ),
- *             new OO.ui.OptionWidget( {
- *                 data: 'c',
- *                 label: 'Option Three',
- *             } )
- *         ]
- *     } );
- *     $( 'body' ).append( select.$element );
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
- *
- * @abstract
- * @class
- * @extends OO.ui.Widget
- * @mixins OO.ui.mixin.GroupWidget
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {OO.ui.OptionWidget[]} [items] An array of options to add to the select.
- *  Options are created with {@link OO.ui.OptionWidget OptionWidget} classes. See
- *  the [OOjs UI documentation on MediaWiki] [2] for examples.
- *  [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
- */
-OO.ui.SelectWidget = function OoUiSelectWidget( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.SelectWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.GroupWidget.call( this, $.extend( {}, config, { $group: this.$element } ) );
-
-       // Properties
-       this.pressed = false;
-       this.selecting = null;
-       this.onMouseUpHandler = this.onMouseUp.bind( this );
-       this.onMouseMoveHandler = this.onMouseMove.bind( this );
-       this.onKeyDownHandler = this.onKeyDown.bind( this );
-       this.onKeyPressHandler = this.onKeyPress.bind( this );
-       this.keyPressBuffer = '';
-       this.keyPressBufferTimer = null;
-
-       // Events
-       this.connect( this, {
-               toggle: 'onToggle'
-       } );
-       this.$element.on( {
-               mousedown: this.onMouseDown.bind( this ),
-               mouseover: this.onMouseOver.bind( this ),
-               mouseleave: this.onMouseLeave.bind( this )
-       } );
-
-       // Initialization
-       this.$element
-               .addClass( 'oo-ui-selectWidget oo-ui-selectWidget-depressed' )
-               .attr( 'role', 'listbox' );
-       if ( Array.isArray( config.items ) ) {
-               this.addItems( config.items );
-       }
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.SelectWidget, OO.ui.Widget );
-
-// Need to mixin base class as well
-OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupElement );
-OO.mixinClass( OO.ui.SelectWidget, OO.ui.mixin.GroupWidget );
-
-/* Static */
-OO.ui.SelectWidget.static.passAllFilter = function () {
-       return true;
-};
-
-/* Events */
-
-/**
- * @event highlight
- *
- * A `highlight` event is emitted when the highlight is changed with the #highlightItem method.
- *
- * @param {OO.ui.OptionWidget|null} item Highlighted item
- */
-
-/**
- * @event press
- *
- * A `press` event is emitted when the #pressItem method is used to programmatically modify the
- * pressed state of an option.
- *
- * @param {OO.ui.OptionWidget|null} item Pressed item
- */
-
-/**
- * @event select
- *
- * A `select` event is emitted when the selection is modified programmatically with the #selectItem method.
- *
- * @param {OO.ui.OptionWidget|null} item Selected item
- */
-
-/**
- * @event choose
- * A `choose` event is emitted when an item is chosen with the #chooseItem method.
- * @param {OO.ui.OptionWidget} item Chosen item
- */
-
-/**
- * @event add
- *
- * An `add` event is emitted when options are added to the select with the #addItems method.
- *
- * @param {OO.ui.OptionWidget[]} items Added items
- * @param {number} index Index of insertion point
- */
-
-/**
- * @event remove
- *
- * A `remove` event is emitted when options are removed from the select with the #clearItems
- * or #removeItems methods.
- *
- * @param {OO.ui.OptionWidget[]} items Removed items
- */
-
-/* Methods */
-
-/**
- * Handle mouse down events.
- *
- * @private
- * @param {jQuery.Event} e Mouse down event
- */
-OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
-       var item;
-
-       if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
-               this.togglePressed( true );
-               item = this.getTargetItem( e );
-               if ( item && item.isSelectable() ) {
-                       this.pressItem( item );
-                       this.selecting = item;
-                       this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true );
-                       this.getElementDocument().addEventListener( 'mousemove', this.onMouseMoveHandler, true );
-               }
-       }
-       return false;
-};
-
-/**
- * Handle mouse up events.
- *
- * @private
- * @param {jQuery.Event} e Mouse up event
- */
-OO.ui.SelectWidget.prototype.onMouseUp = function ( e ) {
-       var item;
-
-       this.togglePressed( false );
-       if ( !this.selecting ) {
-               item = this.getTargetItem( e );
-               if ( item && item.isSelectable() ) {
-                       this.selecting = item;
-               }
-       }
-       if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT && this.selecting ) {
-               this.pressItem( null );
-               this.chooseItem( this.selecting );
-               this.selecting = null;
-       }
-
-       this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true );
-       this.getElementDocument().removeEventListener( 'mousemove', this.onMouseMoveHandler, true );
-
-       return false;
-};
-
-/**
- * Handle mouse move events.
- *
- * @private
- * @param {jQuery.Event} e Mouse move event
- */
-OO.ui.SelectWidget.prototype.onMouseMove = function ( e ) {
-       var item;
-
-       if ( !this.isDisabled() && this.pressed ) {
-               item = this.getTargetItem( e );
-               if ( item && item !== this.selecting && item.isSelectable() ) {
-                       this.pressItem( item );
-                       this.selecting = item;
-               }
-       }
-       return false;
-};
-
-/**
- * Handle mouse over events.
- *
- * @private
- * @param {jQuery.Event} e Mouse over event
- */
-OO.ui.SelectWidget.prototype.onMouseOver = function ( e ) {
-       var item;
-
-       if ( !this.isDisabled() ) {
-               item = this.getTargetItem( e );
-               this.highlightItem( item && item.isHighlightable() ? item : null );
-       }
-       return false;
-};
-
-/**
- * Handle mouse leave events.
- *
- * @private
- * @param {jQuery.Event} e Mouse over event
- */
-OO.ui.SelectWidget.prototype.onMouseLeave = function () {
-       if ( !this.isDisabled() ) {
-               this.highlightItem( null );
-       }
-       return false;
-};
-
-/**
- * Handle key down events.
- *
- * @protected
- * @param {jQuery.Event} e Key down event
- */
-OO.ui.SelectWidget.prototype.onKeyDown = function ( e ) {
-       var nextItem,
-               handled = false,
-               currentItem = this.getHighlightedItem() || this.getSelectedItem();
-
-       if ( !this.isDisabled() && this.isVisible() ) {
-               switch ( e.keyCode ) {
-                       case OO.ui.Keys.ENTER:
-                               if ( currentItem && currentItem.constructor.static.highlightable ) {
-                                       // Was only highlighted, now let's select it. No-op if already selected.
-                                       this.chooseItem( currentItem );
-                                       handled = true;
-                               }
-                               break;
-                       case OO.ui.Keys.UP:
-                       case OO.ui.Keys.LEFT:
-                               this.clearKeyPressBuffer();
-                               nextItem = this.getRelativeSelectableItem( currentItem, -1 );
-                               handled = true;
-                               break;
-                       case OO.ui.Keys.DOWN:
-                       case OO.ui.Keys.RIGHT:
-                               this.clearKeyPressBuffer();
-                               nextItem = this.getRelativeSelectableItem( currentItem, 1 );
-                               handled = true;
-                               break;
-                       case OO.ui.Keys.ESCAPE:
-                       case OO.ui.Keys.TAB:
-                               if ( currentItem && currentItem.constructor.static.highlightable ) {
-                                       currentItem.setHighlighted( false );
-                               }
-                               this.unbindKeyDownListener();
-                               this.unbindKeyPressListener();
-                               // Don't prevent tabbing away / defocusing
-                               handled = false;
-                               break;
-               }
-
-               if ( nextItem ) {
-                       if ( nextItem.constructor.static.highlightable ) {
-                               this.highlightItem( nextItem );
-                       } else {
-                               this.chooseItem( nextItem );
-                       }
-                       nextItem.scrollElementIntoView();
-               }
-
-               if ( handled ) {
-                       // Can't just return false, because e is not always a jQuery event
-                       e.preventDefault();
-                       e.stopPropagation();
-               }
-       }
-};
-
-/**
- * Bind key down listener.
- *
- * @protected
- */
-OO.ui.SelectWidget.prototype.bindKeyDownListener = function () {
-       this.getElementWindow().addEventListener( 'keydown', this.onKeyDownHandler, true );
-};
-
-/**
- * Unbind key down listener.
- *
- * @protected
- */
-OO.ui.SelectWidget.prototype.unbindKeyDownListener = function () {
-       this.getElementWindow().removeEventListener( 'keydown', this.onKeyDownHandler, true );
-};
-
-/**
- * Clear the key-press buffer
- *
- * @protected
- */
-OO.ui.SelectWidget.prototype.clearKeyPressBuffer = function () {
-       if ( this.keyPressBufferTimer ) {
-               clearTimeout( this.keyPressBufferTimer );
-               this.keyPressBufferTimer = null;
-       }
-       this.keyPressBuffer = '';
-};
-
-/**
- * Handle key press events.
- *
- * @protected
- * @param {jQuery.Event} e Key press event
- */
-OO.ui.SelectWidget.prototype.onKeyPress = function ( e ) {
-       var c, filter, item;
-
-       if ( !e.charCode ) {
-               if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.keyPressBuffer !== '' ) {
-                       this.keyPressBuffer = this.keyPressBuffer.substr( 0, this.keyPressBuffer.length - 1 );
-                       return false;
-               }
-               return;
-       }
-       if ( String.fromCodePoint ) {
-               c = String.fromCodePoint( e.charCode );
-       } else {
-               c = String.fromCharCode( e.charCode );
-       }
-
-       if ( this.keyPressBufferTimer ) {
-               clearTimeout( this.keyPressBufferTimer );
-       }
-       this.keyPressBufferTimer = setTimeout( this.clearKeyPressBuffer.bind( this ), 1500 );
-
-       item = this.getHighlightedItem() || this.getSelectedItem();
-
-       if ( this.keyPressBuffer === c ) {
-               // Common (if weird) special case: typing "xxxx" will cycle through all
-               // the items beginning with "x".
-               if ( item ) {
-                       item = this.getRelativeSelectableItem( item, 1 );
-               }
-       } else {
-               this.keyPressBuffer += c;
-       }
-
-       filter = this.getItemMatcher( this.keyPressBuffer, false );
-       if ( !item || !filter( item ) ) {
-               item = this.getRelativeSelectableItem( item, 1, filter );
-       }
-       if ( item ) {
-               if ( item.constructor.static.highlightable ) {
-                       this.highlightItem( item );
-               } else {
-                       this.chooseItem( item );
-               }
-               item.scrollElementIntoView();
-       }
-
-       return false;
-};
-
-/**
- * Get a matcher for the specific string
- *
- * @protected
- * @param {string} s String to match against items
- * @param {boolean} [exact=false] Only accept exact matches
- * @return {Function} function ( OO.ui.OptionItem ) => boolean
- */
-OO.ui.SelectWidget.prototype.getItemMatcher = function ( s, exact ) {
-       var re;
-
-       if ( s.normalize ) {
-               s = s.normalize();
-       }
-       s = exact ? s.trim() : s.replace( /^\s+/, '' );
-       re = '^\\s*' + s.replace( /([\\{}()|.?*+\-\^$\[\]])/g, '\\$1' ).replace( /\s+/g, '\\s+' );
-       if ( exact ) {
-               re += '\\s*$';
-       }
-       re = new RegExp( re, 'i' );
-       return function ( item ) {
-               var l = item.getLabel();
-               if ( typeof l !== 'string' ) {
-                       l = item.$label.text();
-               }
-               if ( l.normalize ) {
-                       l = l.normalize();
-               }
-               return re.test( l );
-       };
-};
-
-/**
- * Bind key press listener.
- *
- * @protected
- */
-OO.ui.SelectWidget.prototype.bindKeyPressListener = function () {
-       this.getElementWindow().addEventListener( 'keypress', this.onKeyPressHandler, true );
-};
-
-/**
- * Unbind key down listener.
- *
- * If you override this, be sure to call this.clearKeyPressBuffer() from your
- * implementation.
- *
- * @protected
- */
-OO.ui.SelectWidget.prototype.unbindKeyPressListener = function () {
-       this.getElementWindow().removeEventListener( 'keypress', this.onKeyPressHandler, true );
-       this.clearKeyPressBuffer();
-};
-
-/**
- * Visibility change handler
- *
- * @protected
- * @param {boolean} visible
- */
-OO.ui.SelectWidget.prototype.onToggle = function ( visible ) {
-       if ( !visible ) {
-               this.clearKeyPressBuffer();
-       }
-};
-
-/**
- * Get the closest item to a jQuery.Event.
- *
- * @private
- * @param {jQuery.Event} e
- * @return {OO.ui.OptionWidget|null} Outline item widget, `null` if none was found
- */
-OO.ui.SelectWidget.prototype.getTargetItem = function ( e ) {
-       return $( e.target ).closest( '.oo-ui-optionWidget' ).data( 'oo-ui-optionWidget' ) || null;
-};
-
-/**
- * Get selected item.
- *
- * @return {OO.ui.OptionWidget|null} Selected item, `null` if no item is selected
- */
-OO.ui.SelectWidget.prototype.getSelectedItem = function () {
-       var i, len;
-
-       for ( i = 0, len = this.items.length; i < len; i++ ) {
-               if ( this.items[ i ].isSelected() ) {
-                       return this.items[ i ];
-               }
-       }
-       return null;
-};
-
-/**
- * Get highlighted item.
- *
- * @return {OO.ui.OptionWidget|null} Highlighted item, `null` if no item is highlighted
- */
-OO.ui.SelectWidget.prototype.getHighlightedItem = function () {
-       var i, len;
-
-       for ( i = 0, len = this.items.length; i < len; i++ ) {
-               if ( this.items[ i ].isHighlighted() ) {
-                       return this.items[ i ];
-               }
-       }
-       return null;
-};
-
-/**
- * Toggle pressed state.
- *
- * Press is a state that occurs when a user mouses down on an item, but
- * has not yet let go of the mouse. The item may appear selected, but it will not be selected
- * until the user releases the mouse.
- *
- * @param {boolean} pressed An option is being pressed
- */
-OO.ui.SelectWidget.prototype.togglePressed = function ( pressed ) {
-       if ( pressed === undefined ) {
-               pressed = !this.pressed;
-       }
-       if ( pressed !== this.pressed ) {
-               this.$element
-                       .toggleClass( 'oo-ui-selectWidget-pressed', pressed )
-                       .toggleClass( 'oo-ui-selectWidget-depressed', !pressed );
-               this.pressed = pressed;
-       }
-};
-
-/**
- * Highlight an option. If the `item` param is omitted, no options will be highlighted
- * and any existing highlight will be removed. The highlight is mutually exclusive.
- *
- * @param {OO.ui.OptionWidget} [item] Item to highlight, omit for no highlight
- * @fires highlight
- * @chainable
- */
-OO.ui.SelectWidget.prototype.highlightItem = function ( item ) {
-       var i, len, highlighted,
-               changed = false;
-
-       for ( i = 0, len = this.items.length; i < len; i++ ) {
-               highlighted = this.items[ i ] === item;
-               if ( this.items[ i ].isHighlighted() !== highlighted ) {
-                       this.items[ i ].setHighlighted( highlighted );
-                       changed = true;
-               }
-       }
-       if ( changed ) {
-               this.emit( 'highlight', item );
-       }
-
-       return this;
-};
-
-/**
- * Fetch an item by its label.
- *
- * @param {string} label Label of the item to select.
- * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
- * @return {OO.ui.Element|null} Item with equivalent label, `null` if none exists
- */
-OO.ui.SelectWidget.prototype.getItemFromLabel = function ( label, prefix ) {
-       var i, item, found,
-               len = this.items.length,
-               filter = this.getItemMatcher( label, true );
-
-       for ( i = 0; i < len; i++ ) {
-               item = this.items[ i ];
-               if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
-                       return item;
-               }
-       }
-
-       if ( prefix ) {
-               found = null;
-               filter = this.getItemMatcher( label, false );
-               for ( i = 0; i < len; i++ ) {
-                       item = this.items[ i ];
-                       if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
-                               if ( found ) {
-                                       return null;
-                               }
-                               found = item;
-                       }
-               }
-               if ( found ) {
-                       return found;
-               }
-       }
-
-       return null;
-};
-
-/**
- * Programmatically select an option by its label. If the item does not exist,
- * all options will be deselected.
- *
- * @param {string} [label] Label of the item to select.
- * @param {boolean} [prefix=false] Allow a prefix match, if only a single item matches
- * @fires select
- * @chainable
- */
-OO.ui.SelectWidget.prototype.selectItemByLabel = function ( label, prefix ) {
-       var itemFromLabel = this.getItemFromLabel( label, !!prefix );
-       if ( label === undefined || !itemFromLabel ) {
-               return this.selectItem();
-       }
-       return this.selectItem( itemFromLabel );
-};
-
-/**
- * Programmatically select an option by its data. If the `data` parameter is omitted,
- * or if the item does not exist, all options will be deselected.
- *
- * @param {Object|string} [data] Value of the item to select, omit to deselect all
- * @fires select
- * @chainable
- */
-OO.ui.SelectWidget.prototype.selectItemByData = function ( data ) {
-       var itemFromData = this.getItemFromData( data );
-       if ( data === undefined || !itemFromData ) {
-               return this.selectItem();
-       }
-       return this.selectItem( itemFromData );
-};
-
-/**
- * Programmatically select an option by its reference. If the `item` parameter is omitted,
- * all options will be deselected.
- *
- * @param {OO.ui.OptionWidget} [item] Item to select, omit to deselect all
- * @fires select
- * @chainable
- */
-OO.ui.SelectWidget.prototype.selectItem = function ( item ) {
-       var i, len, selected,
-               changed = false;
-
-       for ( i = 0, len = this.items.length; i < len; i++ ) {
-               selected = this.items[ i ] === item;
-               if ( this.items[ i ].isSelected() !== selected ) {
-                       this.items[ i ].setSelected( selected );
-                       changed = true;
-               }
-       }
-       if ( changed ) {
-               this.emit( 'select', item );
-       }
-
-       return this;
-};
-
-/**
- * Press an item.
- *
- * Press is a state that occurs when a user mouses down on an item, but has not
- * yet let go of the mouse. The item may appear selected, but it will not be selected until the user
- * releases the mouse.
- *
- * @param {OO.ui.OptionWidget} [item] Item to press, omit to depress all
- * @fires press
- * @chainable
- */
-OO.ui.SelectWidget.prototype.pressItem = function ( item ) {
-       var i, len, pressed,
-               changed = false;
-
-       for ( i = 0, len = this.items.length; i < len; i++ ) {
-               pressed = this.items[ i ] === item;
-               if ( this.items[ i ].isPressed() !== pressed ) {
-                       this.items[ i ].setPressed( pressed );
-                       changed = true;
-               }
-       }
-       if ( changed ) {
-               this.emit( 'press', item );
-       }
-
-       return this;
-};
-
-/**
- * Choose an item.
- *
- * Note that ‘choose’ should never be modified programmatically. A user can choose
- * an option with the keyboard or mouse and it becomes selected. To select an item programmatically,
- * use the #selectItem method.
- *
- * This method is identical to #selectItem, but may vary in subclasses that take additional action
- * when users choose an item with the keyboard or mouse.
- *
- * @param {OO.ui.OptionWidget} item Item to choose
- * @fires choose
- * @chainable
- */
-OO.ui.SelectWidget.prototype.chooseItem = function ( item ) {
-       if ( item ) {
-               this.selectItem( item );
-               this.emit( 'choose', item );
-       }
-
-       return this;
-};
-
-/**
- * Get an option by its position relative to the specified item (or to the start of the option array,
- * if item is `null`). The direction in which to search through the option array is specified with a
- * number: -1 for reverse (the default) or 1 for forward. The method will return an option, or
- * `null` if there are no options in the array.
- *
- * @param {OO.ui.OptionWidget|null} item Item to describe the start position, or `null` to start at the beginning of the array.
- * @param {number} direction Direction to move in: -1 to move backward, 1 to move forward
- * @param {Function} filter Only consider items for which this function returns
- *  true. Function takes an OO.ui.OptionWidget and returns a boolean.
- * @return {OO.ui.OptionWidget|null} Item at position, `null` if there are no items in the select
- */
-OO.ui.SelectWidget.prototype.getRelativeSelectableItem = function ( item, direction, filter ) {
-       var currentIndex, nextIndex, i,
-               increase = direction > 0 ? 1 : -1,
-               len = this.items.length;
-
-       if ( !$.isFunction( filter ) ) {
-               filter = OO.ui.SelectWidget.static.passAllFilter;
-       }
-
-       if ( item instanceof OO.ui.OptionWidget ) {
-               currentIndex = this.items.indexOf( item );
-               nextIndex = ( currentIndex + increase + len ) % len;
-       } else {
-               // If no item is selected and moving forward, start at the beginning.
-               // If moving backward, start at the end.
-               nextIndex = direction > 0 ? 0 : len - 1;
-       }
-
-       for ( i = 0; i < len; i++ ) {
-               item = this.items[ nextIndex ];
-               if ( item instanceof OO.ui.OptionWidget && item.isSelectable() && filter( item ) ) {
-                       return item;
-               }
-               nextIndex = ( nextIndex + increase + len ) % len;
-       }
-       return null;
-};
-
-/**
- * Get the next selectable item or `null` if there are no selectable items.
- * Disabled options and menu-section markers and breaks are not selectable.
- *
- * @return {OO.ui.OptionWidget|null} Item, `null` if there aren't any selectable items
- */
-OO.ui.SelectWidget.prototype.getFirstSelectableItem = function () {
-       var i, len, item;
-
-       for ( i = 0, len = this.items.length; i < len; i++ ) {
-               item = this.items[ i ];
-               if ( item instanceof OO.ui.OptionWidget && item.isSelectable() ) {
-                       return item;
-               }
-       }
-
-       return null;
-};
-
-/**
- * Add an array of options to the select. Optionally, an index number can be used to
- * specify an insertion point.
- *
- * @param {OO.ui.OptionWidget[]} items Items to add
- * @param {number} [index] Index to insert items after
- * @fires add
- * @chainable
- */
-OO.ui.SelectWidget.prototype.addItems = function ( items, index ) {
-       // Mixin method
-       OO.ui.mixin.GroupWidget.prototype.addItems.call( this, items, index );
-
-       // Always provide an index, even if it was omitted
-       this.emit( 'add', items, index === undefined ? this.items.length - items.length - 1 : index );
-
-       return this;
-};
-
-/**
- * Remove the specified array of options from the select. Options will be detached
- * from the DOM, not removed, so they can be reused later. To remove all options from
- * the select, you may wish to use the #clearItems method instead.
- *
- * @param {OO.ui.OptionWidget[]} items Items to remove
- * @fires remove
- * @chainable
- */
-OO.ui.SelectWidget.prototype.removeItems = function ( items ) {
-       var i, len, item;
-
-       // Deselect items being removed
-       for ( i = 0, len = items.length; i < len; i++ ) {
-               item = items[ i ];
-               if ( item.isSelected() ) {
-                       this.selectItem( null );
-               }
-       }
-
-       // Mixin method
-       OO.ui.mixin.GroupWidget.prototype.removeItems.call( this, items );
-
-       this.emit( 'remove', items );
-
-       return this;
-};
-
-/**
- * Clear all options from the select. Options will be detached from the DOM, not removed,
- * so that they can be reused later. To remove a subset of options from the select, use
- * the #removeItems method.
- *
- * @fires remove
- * @chainable
- */
-OO.ui.SelectWidget.prototype.clearItems = function () {
-       var items = this.items.slice();
-
-       // Mixin method
-       OO.ui.mixin.GroupWidget.prototype.clearItems.call( this );
-
-       // Clear selection
-       this.selectItem( null );
-
-       this.emit( 'remove', items );
-
-       return this;
-};
-
-/**
- * DecoratedOptionWidgets are {@link OO.ui.OptionWidget options} that can be configured
- * with an {@link OO.ui.mixin.IconElement icon} and/or {@link OO.ui.mixin.IndicatorElement indicator}.
- * This class is used with OO.ui.SelectWidget to create a selection of mutually exclusive
- * options. For more information about options and selects, please see the
- * [OOjs UI documentation on MediaWiki][1].
- *
- *     @example
- *     // Decorated options in a select widget
- *     var select = new OO.ui.SelectWidget( {
- *         items: [
- *             new OO.ui.DecoratedOptionWidget( {
- *                 data: 'a',
- *                 label: 'Option with icon',
- *                 icon: 'help'
- *             } ),
- *             new OO.ui.DecoratedOptionWidget( {
- *                 data: 'b',
- *                 label: 'Option with indicator',
- *                 indicator: 'next'
- *             } )
- *         ]
- *     } );
- *     $( 'body' ).append( select.$element );
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
- *
- * @class
- * @extends OO.ui.OptionWidget
- * @mixins OO.ui.mixin.IconElement
- * @mixins OO.ui.mixin.IndicatorElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.DecoratedOptionWidget = function OoUiDecoratedOptionWidget( config ) {
-       // Parent constructor
-       OO.ui.DecoratedOptionWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.IconElement.call( this, config );
-       OO.ui.mixin.IndicatorElement.call( this, config );
-
-       // Initialization
-       this.$element
-               .addClass( 'oo-ui-decoratedOptionWidget' )
-               .prepend( this.$icon )
-               .append( this.$indicator );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.DecoratedOptionWidget, OO.ui.OptionWidget );
-OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IconElement );
-OO.mixinClass( OO.ui.DecoratedOptionWidget, OO.ui.mixin.IndicatorElement );
-
-/**
- * MenuOptionWidget is an option widget that looks like a menu item. The class is used with
- * OO.ui.MenuSelectWidget to create a menu of mutually exclusive options. Please see
- * the [OOjs UI documentation on MediaWiki] [1] for more information.
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
- *
- * @class
- * @extends OO.ui.DecoratedOptionWidget
- *
- * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.MenuOptionWidget = function OoUiMenuOptionWidget( config ) {
-       // Configuration initialization
-       config = $.extend( { icon: 'check' }, config );
-
-       // Parent constructor
-       OO.ui.MenuOptionWidget.parent.call( this, config );
-
-       // Initialization
-       this.$element
-               .attr( 'role', 'menuitem' )
-               .addClass( 'oo-ui-menuOptionWidget' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.MenuOptionWidget, OO.ui.DecoratedOptionWidget );
-
-/* Static Properties */
-
-OO.ui.MenuOptionWidget.static.scrollIntoViewOnSelect = true;
-
-/**
- * MenuSectionOptionWidgets are used inside {@link OO.ui.MenuSelectWidget menu select widgets} to group one or more related
- * {@link OO.ui.MenuOptionWidget menu options}. MenuSectionOptionWidgets cannot be highlighted or selected.
- *
- *     @example
- *     var myDropdown = new OO.ui.DropdownWidget( {
- *         menu: {
- *             items: [
- *                 new OO.ui.MenuSectionOptionWidget( {
- *                     label: 'Dogs'
- *                 } ),
- *                 new OO.ui.MenuOptionWidget( {
- *                     data: 'corgi',
- *                     label: 'Welsh Corgi'
- *                 } ),
- *                 new OO.ui.MenuOptionWidget( {
- *                     data: 'poodle',
- *                     label: 'Standard Poodle'
- *                 } ),
- *                 new OO.ui.MenuSectionOptionWidget( {
- *                     label: 'Cats'
- *                 } ),
- *                 new OO.ui.MenuOptionWidget( {
- *                     data: 'lion',
- *                     label: 'Lion'
- *                 } )
- *             ]
- *         }
- *     } );
- *     $( 'body' ).append( myDropdown.$element );
- *
- * @class
- * @extends OO.ui.DecoratedOptionWidget
- *
- * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.MenuSectionOptionWidget = function OoUiMenuSectionOptionWidget( config ) {
-       // Parent constructor
-       OO.ui.MenuSectionOptionWidget.parent.call( this, config );
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-menuSectionOptionWidget' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.MenuSectionOptionWidget, OO.ui.DecoratedOptionWidget );
-
-/* Static Properties */
-
-OO.ui.MenuSectionOptionWidget.static.selectable = false;
-
-OO.ui.MenuSectionOptionWidget.static.highlightable = false;
-
-/**
- * MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and
- * is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget.
- * See {@link OO.ui.DropdownWidget DropdownWidget}, {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget},
- * and {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus.
- * MenuSelectWidgets themselves are not instantiated directly, rather subclassed
- * and customized to be opened, closed, and displayed as needed.
- *
- * By default, menus are clipped to the visible viewport and are not visible when a user presses the
- * mouse outside the menu.
- *
- * Menus also have support for keyboard interaction:
- *
- * - Enter/Return key: choose and select a menu option
- * - Up-arrow key: highlight the previous menu option
- * - Down-arrow key: highlight the next menu option
- * - Esc key: hide the menu
- *
- * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
- *
- * @class
- * @extends OO.ui.SelectWidget
- * @mixins OO.ui.mixin.ClippableElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {OO.ui.TextInputWidget} [input] Text input used to implement option highlighting for menu items that match
- *  the text the user types. This config is used by {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget}
- *  and {@link OO.ui.mixin.LookupElement LookupElement}
- * @cfg {jQuery} [$input] Text input used to implement option highlighting for menu items that match
- *  the text the user types. This config is used by {@link OO.ui.CapsuleMultiSelectWidget CapsuleMultiSelectWidget}
- * @cfg {OO.ui.Widget} [widget] Widget associated with the menu's active state. If the user clicks the mouse
- *  anywhere on the page outside of this widget, the menu is hidden. For example, if there is a button
- *  that toggles the menu's visibility on click, the menu will be hidden then re-shown when the user clicks
- *  that button, unless the button (or its parent widget) is passed in here.
- * @cfg {boolean} [autoHide=true] Hide the menu when the mouse is pressed outside the menu.
- * @cfg {boolean} [filterFromInput=false] Filter the displayed options from the input
- */
-OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.MenuSelectWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) );
-
-       // Properties
-       this.newItems = null;
-       this.autoHide = config.autoHide === undefined || !!config.autoHide;
-       this.filterFromInput = !!config.filterFromInput;
-       this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
-       this.$widget = config.widget ? config.widget.$element : null;
-       this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
-       this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
-
-       // Initialization
-       this.$element
-               .addClass( 'oo-ui-menuSelectWidget' )
-               .attr( 'role', 'menu' );
-
-       // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
-       // that reference properties not initialized at that time of parent class construction
-       // TODO: Find a better way to handle post-constructor setup
-       this.visible = false;
-       this.$element.addClass( 'oo-ui-element-hidden' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.MenuSelectWidget, OO.ui.SelectWidget );
-OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement );
-
-/* Methods */
-
-/**
- * Handles document mouse down events.
- *
- * @protected
- * @param {jQuery.Event} e Key down event
- */
-OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
-       if (
-               !OO.ui.contains( this.$element[ 0 ], e.target, true ) &&
-               ( !this.$widget || !OO.ui.contains( this.$widget[ 0 ], e.target, true ) )
-       ) {
-               this.toggle( false );
-       }
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.MenuSelectWidget.prototype.onKeyDown = function ( e ) {
-       var currentItem = this.getHighlightedItem() || this.getSelectedItem();
-
-       if ( !this.isDisabled() && this.isVisible() ) {
-               switch ( e.keyCode ) {
-                       case OO.ui.Keys.LEFT:
-                       case OO.ui.Keys.RIGHT:
-                               // Do nothing if a text field is associated, arrow keys will be handled natively
-                               if ( !this.$input ) {
-                                       OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
-                               }
-                               break;
-                       case OO.ui.Keys.ESCAPE:
-                       case OO.ui.Keys.TAB:
-                               if ( currentItem ) {
-                                       currentItem.setHighlighted( false );
-                               }
-                               this.toggle( false );
-                               // Don't prevent tabbing away, prevent defocusing
-                               if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
-                                       e.preventDefault();
-                                       e.stopPropagation();
-                               }
-                               break;
-                       default:
-                               OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
-                               return;
-               }
-       }
-};
-
-/**
- * Update menu item visibility after input changes.
- * @protected
- */
-OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
-       var i, item,
-               len = this.items.length,
-               showAll = !this.isVisible(),
-               filter = showAll ? null : this.getItemMatcher( this.$input.val() );
-
-       for ( i = 0; i < len; i++ ) {
-               item = this.items[ i ];
-               if ( item instanceof OO.ui.OptionWidget ) {
-                       item.toggle( showAll || filter( item ) );
-               }
-       }
-
-       // Reevaluate clipping
-       this.clip();
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.MenuSelectWidget.prototype.bindKeyDownListener = function () {
-       if ( this.$input ) {
-               this.$input.on( 'keydown', this.onKeyDownHandler );
-       } else {
-               OO.ui.MenuSelectWidget.parent.prototype.bindKeyDownListener.call( this );
-       }
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.MenuSelectWidget.prototype.unbindKeyDownListener = function () {
-       if ( this.$input ) {
-               this.$input.off( 'keydown', this.onKeyDownHandler );
-       } else {
-               OO.ui.MenuSelectWidget.parent.prototype.unbindKeyDownListener.call( this );
-       }
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.MenuSelectWidget.prototype.bindKeyPressListener = function () {
-       if ( this.$input ) {
-               if ( this.filterFromInput ) {
-                       this.$input.on( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
-               }
-       } else {
-               OO.ui.MenuSelectWidget.parent.prototype.bindKeyPressListener.call( this );
-       }
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.MenuSelectWidget.prototype.unbindKeyPressListener = function () {
-       if ( this.$input ) {
-               if ( this.filterFromInput ) {
-                       this.$input.off( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
-                       this.updateItemVisibility();
-               }
-       } else {
-               OO.ui.MenuSelectWidget.parent.prototype.unbindKeyPressListener.call( this );
-       }
-};
-
-/**
- * Choose an item.
- *
- * When a user chooses an item, the menu is closed.
- *
- * Note that ‘choose’ should never be modified programmatically. A user can choose an option with the keyboard
- * or mouse and it becomes selected. To select an item programmatically, use the #selectItem method.
- * @param {OO.ui.OptionWidget} item Item to choose
- * @chainable
- */
-OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
-       OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
-       this.toggle( false );
-       return this;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
-       var i, len, item;
-
-       // Parent method
-       OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index );
-
-       // Auto-initialize
-       if ( !this.newItems ) {
-               this.newItems = [];
-       }
-
-       for ( i = 0, len = items.length; i < len; i++ ) {
-               item = items[ i ];
-               if ( this.isVisible() ) {
-                       // Defer fitting label until item has been attached
-                       item.fitLabel();
-               } else {
-                       this.newItems.push( item );
-               }
-       }
-
-       // Reevaluate clipping
-       this.clip();
-
-       return this;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) {
-       // Parent method
-       OO.ui.MenuSelectWidget.parent.prototype.removeItems.call( this, items );
-
-       // Reevaluate clipping
-       this.clip();
-
-       return this;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.MenuSelectWidget.prototype.clearItems = function () {
-       // Parent method
-       OO.ui.MenuSelectWidget.parent.prototype.clearItems.call( this );
-
-       // Reevaluate clipping
-       this.clip();
-
-       return this;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
-       var i, len, change;
-
-       visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
-       change = visible !== this.isVisible();
-
-       // Parent method
-       OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
-
-       if ( change ) {
-               if ( visible ) {
-                       this.bindKeyDownListener();
-                       this.bindKeyPressListener();
-
-                       if ( this.newItems && this.newItems.length ) {
-                               for ( i = 0, len = this.newItems.length; i < len; i++ ) {
-                                       this.newItems[ i ].fitLabel();
-                               }
-                               this.newItems = null;
-                       }
-                       this.toggleClipping( true );
-
-                       // Auto-hide
-                       if ( this.autoHide ) {
-                               this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
-                       }
-               } else {
-                       this.unbindKeyDownListener();
-                       this.unbindKeyPressListener();
-                       this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
-                       this.toggleClipping( false );
-               }
-       }
-
-       return this;
-};
-
-/**
- * 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.
- *
- * If you want to use this within a HTML form, such as a OO.ui.FormLayout, use
- * OO.ui.DropdownInputWidget instead.
- *
- *     @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'
- *                 } )
- *             ]
- *         }
- *     } );
- *
- *     $( 'body' ).append( dropDown.$element );
- *
- *     dropDown.getMenu().selectItemByData( 'b' );
- *
- *     dropDown.getMenu().getSelectedItem().getData(); // returns 'b'
- *
- * 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
- * @mixins OO.ui.mixin.IconElement
- * @mixins OO.ui.mixin.IndicatorElement
- * @mixins OO.ui.mixin.LabelElement
- * @mixins OO.ui.mixin.TitledElement
- * @mixins OO.ui.mixin.TabIndexedElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {Object} [menu] Configuration options to pass to {@link OO.ui.FloatingMenuSelectWidget menu select widget}
- * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
- *  the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
- *  containing `<div>` and has a larger area. By default, the menu uses relative positioning.
- */
-OO.ui.DropdownWidget = function OoUiDropdownWidget( config ) {
-       // Configuration initialization
-       config = $.extend( { indicator: 'down' }, config );
-
-       // Parent constructor
-       OO.ui.DropdownWidget.parent.call( this, config );
-
-       // Properties (must be set before TabIndexedElement constructor call)
-       this.$handle = this.$( '<span>' );
-       this.$overlay = config.$overlay || this.$element;
-
-       // Mixin constructors
-       OO.ui.mixin.IconElement.call( this, config );
-       OO.ui.mixin.IndicatorElement.call( this, config );
-       OO.ui.mixin.LabelElement.call( this, config );
-       OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
-       OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) );
-
-       // Properties
-       this.menu = new OO.ui.FloatingMenuSelectWidget( $.extend( {
-               widget: this,
-               $container: this.$element
-       }, config.menu ) );
-
-       // Events
-       this.$handle.on( {
-               click: this.onClick.bind( this ),
-               keypress: this.onKeyPress.bind( this )
-       } );
-       this.menu.connect( this, { select: 'onMenuSelect' } );
-
-       // Initialization
-       this.$handle
-               .addClass( 'oo-ui-dropdownWidget-handle' )
-               .append( this.$icon, this.$label, this.$indicator );
-       this.$element
-               .addClass( 'oo-ui-dropdownWidget' )
-               .append( this.$handle );
-       this.$overlay.append( this.menu.$element );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.DropdownWidget, OO.ui.Widget );
-OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IconElement );
-OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.IndicatorElement );
-OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.LabelElement );
-OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TitledElement );
-OO.mixinClass( OO.ui.DropdownWidget, OO.ui.mixin.TabIndexedElement );
-
-/* Methods */
-
-/**
- * Get the menu.
- *
- * @return {OO.ui.MenuSelectWidget} Menu of widget
- */
-OO.ui.DropdownWidget.prototype.getMenu = function () {
-       return this.menu;
-};
-
-/**
- * Handles menu select events.
- *
- * @private
- * @param {OO.ui.MenuOptionWidget} item Selected menu item
- */
-OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
-       var selectedLabel;
-
-       if ( !item ) {
-               this.setLabel( null );
-               return;
-       }
-
-       selectedLabel = item.getLabel();
-
-       // If the label is a DOM element, clone it, because setLabel will append() it
-       if ( selectedLabel instanceof jQuery ) {
-               selectedLabel = selectedLabel.clone();
-       }
-
-       this.setLabel( selectedLabel );
-};
-
-/**
- * Handle mouse click events.
- *
- * @private
- * @param {jQuery.Event} e Mouse click event
- */
-OO.ui.DropdownWidget.prototype.onClick = function ( e ) {
-       if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
-               this.menu.toggle();
-       }
-       return false;
-};
-
-/**
- * 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 && !this.menu.isVisible() ) || e.which === OO.ui.Keys.ENTER )
-       ) {
-               this.menu.toggle();
-               return false;
-       }
-};
-
-/**
- * RadioOptionWidget is an option widget that looks like a radio button.
- * The class is used with OO.ui.RadioSelectWidget to create a selection of radio options.
- * Please see the [OOjs UI documentation on MediaWiki] [1] for more information.
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Button_selects_and_option
- *
- * @class
- * @extends OO.ui.OptionWidget
- *
- * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.RadioOptionWidget = function OoUiRadioOptionWidget( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Properties (must be done before parent constructor which calls #setDisabled)
-       this.radio = new OO.ui.RadioInputWidget( { value: config.data, tabIndex: -1 } );
-
-       // Parent constructor
-       OO.ui.RadioOptionWidget.parent.call( this, config );
-
-       // Events
-       this.radio.$input.on( 'focus', this.onInputFocus.bind( this ) );
-
-       // Initialization
-       // Remove implicit role, we're handling it ourselves
-       this.radio.$input.attr( 'role', 'presentation' );
-       this.$element
-               .addClass( 'oo-ui-radioOptionWidget' )
-               .attr( 'role', 'radio' )
-               .attr( 'aria-checked', 'false' )
-               .removeAttr( 'aria-selected' )
-               .prepend( this.radio.$element );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.RadioOptionWidget, OO.ui.OptionWidget );
-
-/* Static Properties */
-
-OO.ui.RadioOptionWidget.static.highlightable = false;
-
-OO.ui.RadioOptionWidget.static.scrollIntoViewOnSelect = true;
-
-OO.ui.RadioOptionWidget.static.pressable = false;
-
-OO.ui.RadioOptionWidget.static.tagName = 'label';
-
-/* Methods */
-
-/**
- * @param {jQuery.Event} e Focus event
- * @private
- */
-OO.ui.RadioOptionWidget.prototype.onInputFocus = function () {
-       this.radio.$input.blur();
-       this.$element.parent().focus();
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.RadioOptionWidget.prototype.setSelected = function ( state ) {
-       OO.ui.RadioOptionWidget.parent.prototype.setSelected.call( this, state );
-
-       this.radio.setSelected( state );
-       this.$element
-               .attr( 'aria-checked', state.toString() )
-               .removeAttr( 'aria-selected' );
-
-       return this;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.RadioOptionWidget.prototype.setDisabled = function ( disabled ) {
-       OO.ui.RadioOptionWidget.parent.prototype.setDisabled.call( this, disabled );
-
-       this.radio.setDisabled( this.isDisabled() );
-
-       return this;
-};
-
-/**
- * RadioSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains radio
- * options and is used together with OO.ui.RadioOptionWidget. The RadioSelectWidget provides
- * an interface for adding, removing and selecting options.
- * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
- *
- * If you want to use this within a HTML form, such as a OO.ui.FormLayout, use
- * OO.ui.RadioSelectInputWidget instead.
- *
- *     @example
- *     // A RadioSelectWidget with RadioOptions.
- *     var option1 = new OO.ui.RadioOptionWidget( {
- *         data: 'a',
- *         label: 'Selected radio option'
- *     } );
- *
- *     var option2 = new OO.ui.RadioOptionWidget( {
- *         data: 'b',
- *         label: 'Unselected radio option'
- *     } );
- *
- *     var radioSelect=new OO.ui.RadioSelectWidget( {
- *         items: [ option1, option2 ]
- *      } );
- *
- *     // Select 'option 1' using the RadioSelectWidget's selectItem() method.
- *     radioSelect.selectItem( option1 );
- *
- *     $( 'body' ).append( radioSelect.$element );
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
-
- *
- * @class
- * @extends OO.ui.SelectWidget
- * @mixins OO.ui.mixin.TabIndexedElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.RadioSelectWidget = function OoUiRadioSelectWidget( config ) {
-       // Parent constructor
-       OO.ui.RadioSelectWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.TabIndexedElement.call( this, config );
-
-       // Events
-       this.$element.on( {
-               focus: this.bindKeyDownListener.bind( this ),
-               blur: this.unbindKeyDownListener.bind( this )
-       } );
-
-       // Initialization
-       this.$element
-               .addClass( 'oo-ui-radioSelectWidget' )
-               .attr( 'role', 'radiogroup' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.RadioSelectWidget, OO.ui.SelectWidget );
-OO.mixinClass( OO.ui.RadioSelectWidget, OO.ui.mixin.TabIndexedElement );
-
-/**
- * Element that will stick under a specified container, even when it is inserted elsewhere in the
- * document (for example, in a OO.ui.Window's $overlay).
- *
- * The elements's position is automatically calculated and maintained when window is resized or the
- * page is scrolled. If you reposition the container manually, you have to call #position to make
- * sure the element is still placed correctly.
- *
- * As positioning is only possible when both the element and the container are attached to the DOM
- * and visible, it's only done after you call #togglePositioning. You might want to do this inside
- * the #toggle method to display a floating popup, for example.
- *
- * @abstract
- * @class
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {jQuery} [$floatable] Node to position, assigned to #$floatable, omit to use #$element
- * @cfg {jQuery} [$floatableContainer] Node to position below
- */
-OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Properties
-       this.$floatable = null;
-       this.$floatableContainer = null;
-       this.$floatableWindow = null;
-       this.$floatableClosestScrollable = null;
-       this.onFloatableScrollHandler = this.position.bind( this );
-       this.onFloatableWindowResizeHandler = this.position.bind( this );
-
-       // Initialization
-       this.setFloatableContainer( config.$floatableContainer );
-       this.setFloatableElement( config.$floatable || this.$element );
-};
-
-/* Methods */
-
-/**
- * Set floatable element.
- *
- * If an element is already set, it will be cleaned up before setting up the new element.
- *
- * @param {jQuery} $floatable Element to make floatable
- */
-OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
-       if ( this.$floatable ) {
-               this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
-               this.$floatable.css( { left: '', top: '' } );
-       }
-
-       this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
-       this.position();
-};
-
-/**
- * Set floatable container.
- *
- * The element will be always positioned under the specified container.
- *
- * @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
- */
-OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
-       this.$floatableContainer = $floatableContainer;
-       if ( this.$floatable ) {
-               this.position();
-       }
-};
-
-/**
- * Toggle positioning.
- *
- * Do not turn positioning on until after the element is attached to the DOM and visible.
- *
- * @param {boolean} [positioning] Enable positioning, omit to toggle
- * @chainable
- */
-OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
-       var closestScrollableOfContainer, closestScrollableOfFloatable;
-
-       positioning = positioning === undefined ? !this.positioning : !!positioning;
-
-       if ( this.positioning !== positioning ) {
-               this.positioning = positioning;
-
-               closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer( this.$floatableContainer[ 0 ] );
-               closestScrollableOfFloatable = OO.ui.Element.static.getClosestScrollableContainer( this.$floatable[ 0 ] );
-               if ( closestScrollableOfContainer !== closestScrollableOfFloatable ) {
-                       // If the scrollable is the root, we have to listen to scroll events
-                       // on the window because of browser inconsistencies (or do we? someone should verify this)
-                       if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
-                               closestScrollableOfContainer = OO.ui.Element.static.getWindow( closestScrollableOfContainer );
-                       }
-               }
-
-               if ( positioning ) {
-                       this.$floatableWindow = $( this.getElementWindow() );
-                       this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
-
-                       if ( closestScrollableOfContainer !== closestScrollableOfFloatable ) {
-                               this.$floatableClosestScrollable = $( closestScrollableOfContainer );
-                               this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
-                       }
-
-                       // Initial position after visible
-                       this.position();
-               } else {
-                       if ( this.$floatableWindow ) {
-                               this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
-                               this.$floatableWindow = null;
-                       }
-
-                       if ( this.$floatableClosestScrollable ) {
-                               this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
-                               this.$floatableClosestScrollable = null;
-                       }
-
-                       this.$floatable.css( { left: '', top: '' } );
-               }
-       }
-
-       return this;
-};
-
-/**
- * Position the floatable below its container.
- *
- * This should only be done when both of them are attached to the DOM and visible.
- *
- * @chainable
- */
-OO.ui.mixin.FloatableElement.prototype.position = function () {
-       var pos;
-
-       if ( !this.positioning ) {
-               return this;
-       }
-
-       pos = OO.ui.Element.static.getRelativePosition( this.$floatableContainer, this.$floatable.offsetParent() );
-
-       // Position under container
-       pos.top += this.$floatableContainer.height();
-       this.$floatable.css( pos );
-
-       // We updated the position, so re-evaluate the clipping state.
-       // (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
-       // will not notice the need to update itself.)
-       // TODO: This is terrible, we shouldn't need to know about ClippableElement at all here. Why does
-       // it not listen to the right events in the right places?
-       if ( this.clip ) {
-               this.clip();
-       }
-
-       return this;
-};
-
-/**
- * FloatingMenuSelectWidget is a menu that will stick under a specified
- * container, even when it is inserted elsewhere in the document (for example,
- * in a OO.ui.Window's $overlay). This is sometimes necessary to prevent the
- * menu from being clipped too aggresively.
- *
- * The menu's position is automatically calculated and maintained when the menu
- * is toggled or the window is resized.
- *
- * See OO.ui.ComboBoxInputWidget for an example of a widget that uses this class.
- *
- * @class
- * @extends OO.ui.MenuSelectWidget
- * @mixins OO.ui.mixin.FloatableElement
- *
- * @constructor
- * @param {OO.ui.Widget} [inputWidget] Widget to provide the menu for.
- *   Deprecated, omit this parameter and specify `$container` instead.
- * @param {Object} [config] Configuration options
- * @cfg {jQuery} [$container=inputWidget.$element] Element to render menu under
- */
-OO.ui.FloatingMenuSelectWidget = function OoUiFloatingMenuSelectWidget( inputWidget, config ) {
-       // Allow 'inputWidget' parameter and config for backwards compatibility
-       if ( OO.isPlainObject( inputWidget ) && config === undefined ) {
-               config = inputWidget;
-               inputWidget = config.inputWidget;
-       }
-
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.FloatingMenuSelectWidget.parent.call( this, config );
-
-       // Properties (must be set before mixin constructors)
-       this.inputWidget = inputWidget; // For backwards compatibility
-       this.$container = config.$container || this.inputWidget.$element;
-
-       // Mixins constructors
-       OO.ui.mixin.FloatableElement.call( this, $.extend( {}, config, { $floatableContainer: this.$container } ) );
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-floatingMenuSelectWidget' );
-       // For backwards compatibility
-       this.$element.addClass( 'oo-ui-textInputMenuSelectWidget' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.FloatingMenuSelectWidget, OO.ui.MenuSelectWidget );
-OO.mixinClass( OO.ui.FloatingMenuSelectWidget, OO.ui.mixin.FloatableElement );
-
-// For backwards compatibility
-OO.ui.TextInputMenuSelectWidget = OO.ui.FloatingMenuSelectWidget;
-
-/* Methods */
-
-/**
- * @inheritdoc
- */
-OO.ui.FloatingMenuSelectWidget.prototype.toggle = function ( visible ) {
-       var change;
-       visible = visible === undefined ? !this.isVisible() : !!visible;
-       change = visible !== this.isVisible();
-
-       if ( change && visible ) {
-               // Make sure the width is set before the parent method runs.
-               this.setIdealSize( this.$container.width() );
-       }
-
-       // Parent method
-       // This will call this.clip(), which is nonsensical since we're not positioned yet...
-       OO.ui.FloatingMenuSelectWidget.parent.prototype.toggle.call( this, visible );
-
-       if ( change ) {
-               this.togglePositioning( this.isVisible() );
-       }
-
-       return this;
-};
-
-/**
- * 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.mixin.FlaggedElement
- * @mixins OO.ui.mixin.TabIndexedElement
- * @mixins OO.ui.mixin.TitledElement
- * @mixins OO.ui.mixin.AccessKeyedElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {string} [name=''] The value of the input’s HTML `name` attribute.
- * @cfg {string} [value=''] The value of the input.
- * @cfg {string} [dir] The directionality of the input (ltr/rtl).
- * @cfg {Function} [inputFilter] The name of an input filter function. Input filters modify the value of an input
- *  before it is accepted.
- */
-OO.ui.InputWidget = function OoUiInputWidget( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.InputWidget.parent.call( this, config );
-
-       // Properties
-       this.$input = this.getInputElement( config );
-       this.value = '';
-       this.inputFilter = config.inputFilter;
-
-       // Mixin constructors
-       OO.ui.mixin.FlaggedElement.call( this, config );
-       OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
-       OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
-       OO.ui.mixin.AccessKeyedElement.call( this, $.extend( {}, config, { $accessKeyed: this.$input } ) );
-
-       // Events
-       this.$input.on( 'keydown mouseup cut paste change input select', this.onEdit.bind( this ) );
-
-       // Initialization
-       this.$input
-               .addClass( 'oo-ui-inputWidget-input' )
-               .attr( 'name', config.name )
-               .prop( 'disabled', this.isDisabled() );
-       this.$element
-               .addClass( 'oo-ui-inputWidget' )
-               .append( this.$input );
-       this.setValue( config.value );
-       this.setAccessKey( config.accessKey );
-       if ( config.dir ) {
-               this.setDir( config.dir );
-       }
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.InputWidget, OO.ui.Widget );
-OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.FlaggedElement );
-OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TabIndexedElement );
-OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.TitledElement );
-OO.mixinClass( OO.ui.InputWidget, OO.ui.mixin.AccessKeyedElement );
-
-/* Static Properties */
-
-OO.ui.InputWidget.static.supportsSimpleLabel = true;
-
-/* Static Methods */
-
-/**
- * @inheritdoc
- */
-OO.ui.InputWidget.static.reusePreInfuseDOM = function ( node, config ) {
-       config = OO.ui.InputWidget.parent.static.reusePreInfuseDOM( node, config );
-       // Reusing $input lets browsers preserve inputted values across page reloads (T114134)
-       config.$input = $( node ).find( '.oo-ui-inputWidget-input' );
-       return config;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.InputWidget.static.gatherPreInfuseState = function ( node, config ) {
-       var state = OO.ui.InputWidget.parent.static.gatherPreInfuseState( node, config );
-       state.value = config.$input.val();
-       // Might be better in TabIndexedElement, but it's awkward to do there because mixins are awkward
-       state.focus = config.$input.is( ':focus' );
-       return state;
-};
-
-/* Events */
-
-/**
- * @event change
- *
- * A change event is emitted when the value of the input changes.
- *
- * @param {string} value
- */
-
-/* Methods */
-
-/**
- * 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).
- *
- * @protected
- * @param {Object} config Configuration options
- * @return {jQuery} Input element
- */
-OO.ui.InputWidget.prototype.getInputElement = function ( config ) {
-       // See #reusePreInfuseDOM about config.$input
-       return config.$input || $( '<input>' );
-};
-
-/**
- * Handle potentially value-changing events.
- *
- * @private
- * @param {jQuery.Event} e Key down, mouse up, cut, paste, change, input, or select event
- */
-OO.ui.InputWidget.prototype.onEdit = function () {
-       var widget = this;
-       if ( !this.isDisabled() ) {
-               // Allow the stack to clear so the value will be updated
-               setTimeout( function () {
-                       widget.setValue( widget.$input.val() );
-               } );
-       }
-};
-
-/**
- * Get the value of the input.
- *
- * @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;
-};
-
-/**
- * Set the directionality of the input, either RTL (right-to-left) or LTR (left-to-right).
- *
- * @deprecated since v0.13.1, use #setDir directly
- * @param {boolean} isRTL Directionality is right-to-left
- * @chainable
- */
-OO.ui.InputWidget.prototype.setRTL = function ( isRTL ) {
-       this.setDir( isRTL ? 'rtl' : 'ltr' );
-       return this;
-};
-
-/**
- * Set the directionality of the input.
- *
- * @param {string} dir Text directionality: 'ltr', 'rtl' or 'auto'
- * @chainable
- */
-OO.ui.InputWidget.prototype.setDir = function ( dir ) {
-       this.$input.prop( 'dir', dir );
-       return this;
-};
-
-/**
- * Set the value of the input.
- *
- * @param {string} value New value
- * @fires change
- * @chainable
- */
-OO.ui.InputWidget.prototype.setValue = function ( value ) {
-       value = this.cleanUpValue( value );
-       // Update the DOM if it has changed. Note that with cleanUpValue, it
-       // is possible for the DOM value to change without this.value changing.
-       if ( this.$input.val() !== value ) {
-               this.$input.val( value );
-       }
-       if ( this.value !== value ) {
-               this.value = value;
-               this.emit( 'change', this.value );
-       }
-       return this;
-};
-
-/**
- * Set the input's access key.
- * FIXME: This is the same code as in OO.ui.mixin.ButtonElement, maybe find a better place for it?
- *
- * @param {string} accessKey Input's access key, use empty string to remove
- * @chainable
- */
-OO.ui.InputWidget.prototype.setAccessKey = function ( accessKey ) {
-       accessKey = typeof accessKey === 'string' && accessKey.length ? accessKey : null;
-
-       if ( this.accessKey !== accessKey ) {
-               if ( this.$input ) {
-                       if ( accessKey !== null ) {
-                               this.$input.attr( 'accesskey', accessKey );
-                       } else {
-                               this.$input.removeAttr( 'accesskey' );
-                       }
-               }
-               this.accessKey = accessKey;
-       }
-
-       return this;
-};
-
-/**
- * Clean up incoming value.
- *
- * Ensures value is a string, and converts undefined and null to empty string.
- *
- * @private
- * @param {string} value Original value
- * @return {string} Cleaned up value
- */
-OO.ui.InputWidget.prototype.cleanUpValue = function ( value ) {
-       if ( value === undefined || value === null ) {
-               return '';
-       } else if ( this.inputFilter ) {
-               return this.inputFilter( String( value ) );
-       } else {
-               return String( value );
-       }
-};
-
-/**
- * Simulate the behavior of clicking on a label bound to this input. This method is only called by
- * {@link OO.ui.LabelWidget LabelWidget} and {@link OO.ui.FieldLayout FieldLayout}. It should not be
- * called directly.
- */
-OO.ui.InputWidget.prototype.simulateLabelClick = function () {
-       if ( !this.isDisabled() ) {
-               if ( this.$input.is( ':checkbox, :radio' ) ) {
-                       this.$input.click();
-               }
-               if ( this.$input.is( ':input' ) ) {
-                       this.$input[ 0 ].focus();
-               }
-       }
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.InputWidget.prototype.setDisabled = function ( state ) {
-       OO.ui.InputWidget.parent.prototype.setDisabled.call( this, state );
-       if ( this.$input ) {
-               this.$input.prop( 'disabled', this.isDisabled() );
-       }
-       return this;
-};
-
-/**
- * Focus the input.
- *
- * @chainable
- */
-OO.ui.InputWidget.prototype.focus = function () {
-       this.$input[ 0 ].focus();
-       return this;
-};
-
-/**
- * Blur the input.
- *
- * @chainable
- */
-OO.ui.InputWidget.prototype.blur = function () {
-       this.$input[ 0 ].blur();
-       return this;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.InputWidget.prototype.restorePreInfuseState = function ( state ) {
-       OO.ui.InputWidget.parent.prototype.restorePreInfuseState.call( this, state );
-       if ( state.value !== undefined && state.value !== this.getValue() ) {
-               this.setValue( state.value );
-       }
-       if ( state.focus ) {
-               this.focus();
-       }
-};
-
-/**
- * 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 `<button/>` (the default) or an HTML `<input/>` tags. See the
- * [OOjs UI documentation on MediaWiki] [1] for more information.
- *
- *     @example
- *     // A ButtonInputWidget rendered as an HTML button, the default.
- *     var button = new OO.ui.ButtonInputWidget( {
- *         label: 'Input button',
- *         icon: 'check',
- *         value: 'check'
- *     } );
- *     $( 'body' ).append( button.$element );
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs#Button_inputs
- *
- * @class
- * @extends OO.ui.InputWidget
- * @mixins OO.ui.mixin.ButtonElement
- * @mixins OO.ui.mixin.IconElement
- * @mixins OO.ui.mixin.IndicatorElement
- * @mixins OO.ui.mixin.LabelElement
- * @mixins OO.ui.mixin.TitledElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {string} [type='button'] The value of the HTML `'type'` attribute: 'button', 'submit' or 'reset'.
- * @cfg {boolean} [useInputTag=false] Use an `<input/>` tag instead of a `<button/>` tag, the default.
- *  Widgets configured to be an `<input/>` do not support {@link #icon icons} and {@link #indicator indicators},
- *  non-plaintext {@link #label labels}, or {@link #value values}. In general, useInputTag should only
- *  be set to `true` when there’s need to support IE6 in a form with multiple buttons.
- */
-OO.ui.ButtonInputWidget = function OoUiButtonInputWidget( config ) {
-       // Configuration initialization
-       config = $.extend( { type: 'button', useInputTag: false }, config );
-
-       // Properties (must be set before parent constructor, which calls #setValue)
-       this.useInputTag = config.useInputTag;
-
-       // Parent constructor
-       OO.ui.ButtonInputWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.ButtonElement.call( this, $.extend( {}, config, { $button: this.$input } ) );
-       OO.ui.mixin.IconElement.call( this, config );
-       OO.ui.mixin.IndicatorElement.call( this, config );
-       OO.ui.mixin.LabelElement.call( this, config );
-       OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$input } ) );
-
-       // Initialization
-       if ( !config.useInputTag ) {
-               this.$input.append( this.$icon, this.$label, this.$indicator );
-       }
-       this.$element.addClass( 'oo-ui-buttonInputWidget' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.ButtonInputWidget, OO.ui.InputWidget );
-OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.ButtonElement );
-OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IconElement );
-OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.IndicatorElement );
-OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.LabelElement );
-OO.mixinClass( OO.ui.ButtonInputWidget, OO.ui.mixin.TitledElement );
-
-/* Static Properties */
-
-/**
- * Disable generating `<label>` elements for buttons. One would very rarely need additional label
- * for a button, and it's already a big clickable target, and it causes unexpected rendering.
- */
-OO.ui.ButtonInputWidget.static.supportsSimpleLabel = false;
-
-/* Methods */
-
-/**
- * @inheritdoc
- * @protected
- */
-OO.ui.ButtonInputWidget.prototype.getInputElement = function ( config ) {
-       var type;
-       // See InputWidget#reusePreInfuseDOM about config.$input
-       if ( config.$input ) {
-               return config.$input.empty();
-       }
-       type = [ 'button', 'submit', 'reset' ].indexOf( config.type ) !== -1 ? config.type : 'button';
-       return $( '<' + ( config.useInputTag ? 'input' : 'button' ) + ' type="' + type + '">' );
-};
-
-/**
- * Set label value.
- *
- * If #useInputTag is `true`, the label is set as the `value` of the `<input/>` tag.
- *
- * @param {jQuery|string|Function|null} label Label nodes, text, a function that returns nodes or
- *  text, or `null` for no label
- * @chainable
- */
-OO.ui.ButtonInputWidget.prototype.setLabel = function ( label ) {
-       OO.ui.mixin.LabelElement.prototype.setLabel.call( this, label );
-
-       if ( this.useInputTag ) {
-               if ( typeof label === 'function' ) {
-                       label = OO.ui.resolveMsg( label );
-               }
-               if ( label instanceof jQuery ) {
-                       label = label.text();
-               }
-               if ( !label ) {
-                       label = '';
-               }
-               this.$input.val( label );
-       }
-
-       return this;
-};
-
-/**
- * Set the value of the input.
- *
- * This method is disabled for button inputs configured as {@link #useInputTag <input/> tags}, as
- * they do not support {@link #value values}.
- *
- * @param {string} value New value
- * @chainable
- */
-OO.ui.ButtonInputWidget.prototype.setValue = function ( value ) {
-       if ( !this.useInputTag ) {
-               OO.ui.ButtonInputWidget.parent.prototype.setValue.call( this, value );
-       }
-       return this;
-};
-
-/**
- * CheckboxInputWidgets, like HTML checkboxes, can be selected and/or configured with a value.
- * Note that these {@link OO.ui.InputWidget input widgets} are best laid out
- * in {@link OO.ui.FieldLayout field layouts} that use the {@link OO.ui.FieldLayout#align inline}
- * alignment. For more information, please see the [OOjs UI documentation on MediaWiki][1].
- *
- * This widget can be used inside a HTML form, such as a OO.ui.FormLayout.
- *
- *     @example
- *     // An example of selected, unselected, and disabled checkbox inputs
- *     var checkbox1=new OO.ui.CheckboxInputWidget( {
- *          value: 'a',
- *          selected: true
- *     } );
- *     var checkbox2=new OO.ui.CheckboxInputWidget( {
- *         value: 'b'
- *     } );
- *     var checkbox3=new OO.ui.CheckboxInputWidget( {
- *         value:'c',
- *         disabled: true
- *     } );
- *     // Create a fieldset layout with fields for each checkbox.
- *     var fieldset = new OO.ui.FieldsetLayout( {
- *         label: 'Checkboxes'
- *     } );
- *     fieldset.addItems( [
- *         new OO.ui.FieldLayout( checkbox1, { label: 'Selected checkbox', align: 'inline' } ),
- *         new OO.ui.FieldLayout( checkbox2, { label: 'Unselected checkbox', align: 'inline' } ),
- *         new OO.ui.FieldLayout( checkbox3, { label: 'Disabled checkbox', align: 'inline' } ),
- *     ] );
- *     $( 'body' ).append( fieldset.$element );
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
- *
- * @class
- * @extends OO.ui.InputWidget
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {boolean} [selected=false] Select the checkbox initially. By default, the checkbox is not selected.
- */
-OO.ui.CheckboxInputWidget = function OoUiCheckboxInputWidget( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.CheckboxInputWidget.parent.call( this, config );
-
-       // Initialization
-       this.$element
-               .addClass( 'oo-ui-checkboxInputWidget' )
-               // Required for pretty styling in MediaWiki theme
-               .append( $( '<span>' ) );
-       this.setSelected( config.selected !== undefined ? config.selected : false );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.CheckboxInputWidget, OO.ui.InputWidget );
-
-/* Static Methods */
-
-/**
- * @inheritdoc
- */
-OO.ui.CheckboxInputWidget.static.gatherPreInfuseState = function ( node, config ) {
-       var state = OO.ui.CheckboxInputWidget.parent.static.gatherPreInfuseState( node, config );
-       state.checked = config.$input.prop( 'checked' );
-       return state;
-};
-
-/* Methods */
-
-/**
- * @inheritdoc
- * @protected
- */
-OO.ui.CheckboxInputWidget.prototype.getInputElement = function () {
-       return $( '<input type="checkbox" />' );
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.CheckboxInputWidget.prototype.onEdit = function () {
-       var widget = this;
-       if ( !this.isDisabled() ) {
-               // Allow the stack to clear so the value will be updated
-               setTimeout( function () {
-                       widget.setSelected( widget.$input.prop( 'checked' ) );
-               } );
-       }
-};
-
-/**
- * Set selection state of this checkbox.
- *
- * @param {boolean} state `true` for selected
- * @chainable
- */
-OO.ui.CheckboxInputWidget.prototype.setSelected = function ( state ) {
-       state = !!state;
-       if ( this.selected !== state ) {
-               this.selected = state;
-               this.$input.prop( 'checked', this.selected );
-               this.emit( 'change', this.selected );
-       }
-       return this;
-};
-
-/**
- * Check if this checkbox is selected.
- *
- * @return {boolean} Checkbox is selected
- */
-OO.ui.CheckboxInputWidget.prototype.isSelected = 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 selected = this.$input.prop( 'checked' );
-       if ( this.selected !== selected ) {
-               this.setSelected( selected );
-       }
-       return this.selected;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.CheckboxInputWidget.prototype.restorePreInfuseState = function ( state ) {
-       OO.ui.CheckboxInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
-       if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
-               this.setSelected( state.checked );
-       }
-};
-
-/**
- * DropdownInputWidget is a {@link OO.ui.DropdownWidget DropdownWidget} intended to be used
- * within a HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
- * of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for
- * more information about input widgets.
- *
- * A DropdownInputWidget always has a value (one of the options is always selected), unless there
- * are no options. If no `value` configuration option is provided, the first option is selected.
- * If you need a state representing no value (no option being selected), use a DropdownWidget.
- *
- * This and OO.ui.RadioSelectInputWidget support the same configuration options.
- *
- *     @example
- *     // Example: A DropdownInputWidget with three options
- *     var dropdownInput = new OO.ui.DropdownInputWidget( {
- *         options: [
- *             { data: 'a', label: 'First' },
- *             { data: 'b', label: 'Second'},
- *             { data: 'c', label: 'Third' }
- *         ]
- *     } );
- *     $( 'body' ).append( dropdownInput.$element );
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
- *
- * @class
- * @extends OO.ui.InputWidget
- * @mixins OO.ui.mixin.TitledElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
- * @cfg {Object} [dropdown] Configuration options for {@link OO.ui.DropdownWidget DropdownWidget}
- */
-OO.ui.DropdownInputWidget = function OoUiDropdownInputWidget( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Properties (must be done before parent constructor which calls #setDisabled)
-       this.dropdownWidget = new OO.ui.DropdownWidget( config.dropdown );
-
-       // Parent constructor
-       OO.ui.DropdownInputWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.TitledElement.call( this, config );
-
-       // Events
-       this.dropdownWidget.getMenu().connect( this, { select: 'onMenuSelect' } );
-
-       // Initialization
-       this.setOptions( config.options || [] );
-       this.$element
-               .addClass( 'oo-ui-dropdownInputWidget' )
-               .append( this.dropdownWidget.$element );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.DropdownInputWidget, OO.ui.InputWidget );
-OO.mixinClass( OO.ui.DropdownInputWidget, OO.ui.mixin.TitledElement );
-
-/* Methods */
-
-/**
- * @inheritdoc
- * @protected
- */
-OO.ui.DropdownInputWidget.prototype.getInputElement = function ( config ) {
-       // See InputWidget#reusePreInfuseDOM about config.$input
-       if ( config.$input ) {
-               return config.$input.addClass( 'oo-ui-element-hidden' );
-       }
-       return $( '<input type="hidden">' );
-};
-
-/**
- * Handles menu select events.
- *
- * @private
- * @param {OO.ui.MenuOptionWidget} item Selected menu item
- */
-OO.ui.DropdownInputWidget.prototype.onMenuSelect = function ( item ) {
-       this.setValue( item.getData() );
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.DropdownInputWidget.prototype.setValue = function ( value ) {
-       value = this.cleanUpValue( value );
-       this.dropdownWidget.getMenu().selectItemByData( value );
-       OO.ui.DropdownInputWidget.parent.prototype.setValue.call( this, value );
-       return this;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.DropdownInputWidget.prototype.setDisabled = function ( state ) {
-       this.dropdownWidget.setDisabled( state );
-       OO.ui.DropdownInputWidget.parent.prototype.setDisabled.call( this, state );
-       return this;
-};
-
-/**
- * Set the options available for this input.
- *
- * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
- * @chainable
- */
-OO.ui.DropdownInputWidget.prototype.setOptions = function ( options ) {
-       var
-               value = this.getValue(),
-               widget = this;
-
-       // Rebuild the dropdown menu
-       this.dropdownWidget.getMenu()
-               .clearItems()
-               .addItems( options.map( function ( opt ) {
-                       var optValue = widget.cleanUpValue( opt.data );
-                       return new OO.ui.MenuOptionWidget( {
-                               data: optValue,
-                               label: opt.label !== undefined ? opt.label : optValue
-                       } );
-               } ) );
-
-       // Restore the previous value, or reset to something sensible
-       if ( this.dropdownWidget.getMenu().getItemFromData( value ) ) {
-               // Previous value is still available, ensure consistency with the dropdown
-               this.setValue( value );
-       } else {
-               // No longer valid, reset
-               if ( options.length ) {
-                       this.setValue( options[ 0 ].data );
-               }
-       }
-
-       return this;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.DropdownInputWidget.prototype.focus = function () {
-       this.dropdownWidget.getMenu().toggle( true );
-       return this;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.DropdownInputWidget.prototype.blur = function () {
-       this.dropdownWidget.getMenu().toggle( false );
-       return this;
-};
-
-/**
- * RadioInputWidget creates a single radio button. Because radio buttons are usually used as a set,
- * in most cases you will want to use a {@link OO.ui.RadioSelectWidget radio select}
- * with {@link OO.ui.RadioOptionWidget radio options} instead of this class. For more information,
- * please see the [OOjs UI documentation on MediaWiki][1].
- *
- * This widget can be used inside a HTML form, such as a OO.ui.FormLayout.
- *
- *     @example
- *     // An example of selected, unselected, and disabled radio inputs
- *     var radio1 = new OO.ui.RadioInputWidget( {
- *         value: 'a',
- *         selected: true
- *     } );
- *     var radio2 = new OO.ui.RadioInputWidget( {
- *         value: 'b'
- *     } );
- *     var radio3 = new OO.ui.RadioInputWidget( {
- *         value: 'c',
- *         disabled: true
- *     } );
- *     // Create a fieldset layout with fields for each radio button.
- *     var fieldset = new OO.ui.FieldsetLayout( {
- *         label: 'Radio inputs'
- *     } );
- *     fieldset.addItems( [
- *         new OO.ui.FieldLayout( radio1, { label: 'Selected', align: 'inline' } ),
- *         new OO.ui.FieldLayout( radio2, { label: 'Unselected', align: 'inline' } ),
- *         new OO.ui.FieldLayout( radio3, { label: 'Disabled', align: 'inline' } ),
- *     ] );
- *     $( 'body' ).append( fieldset.$element );
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
- *
- * @class
- * @extends OO.ui.InputWidget
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {boolean} [selected=false] Select the radio button initially. By default, the radio button is not selected.
- */
-OO.ui.RadioInputWidget = function OoUiRadioInputWidget( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.RadioInputWidget.parent.call( this, config );
-
-       // Initialization
-       this.$element
-               .addClass( 'oo-ui-radioInputWidget' )
-               // Required for pretty styling in MediaWiki theme
-               .append( $( '<span>' ) );
-       this.setSelected( config.selected !== undefined ? config.selected : false );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.RadioInputWidget, OO.ui.InputWidget );
-
-/* Static Methods */
-
-/**
- * @inheritdoc
- */
-OO.ui.RadioInputWidget.static.gatherPreInfuseState = function ( node, config ) {
-       var state = OO.ui.RadioInputWidget.parent.static.gatherPreInfuseState( node, config );
-       state.checked = config.$input.prop( 'checked' );
-       return state;
-};
-
-/* Methods */
-
-/**
- * @inheritdoc
- * @protected
- */
-OO.ui.RadioInputWidget.prototype.getInputElement = function () {
-       return $( '<input type="radio" />' );
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.RadioInputWidget.prototype.onEdit = function () {
-       // RadioInputWidget doesn't track its state.
-};
-
-/**
- * Set selection state of this radio button.
- *
- * @param {boolean} state `true` for selected
- * @chainable
- */
-OO.ui.RadioInputWidget.prototype.setSelected = function ( state ) {
-       // RadioInputWidget doesn't track its state.
-       this.$input.prop( 'checked', state );
-       return this;
-};
-
-/**
- * Check if this radio button is selected.
- *
- * @return {boolean} Radio is selected
- */
-OO.ui.RadioInputWidget.prototype.isSelected = function () {
-       return this.$input.prop( 'checked' );
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.RadioInputWidget.prototype.restorePreInfuseState = function ( state ) {
-       OO.ui.RadioInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
-       if ( state.checked !== undefined && state.checked !== this.isSelected() ) {
-               this.setSelected( state.checked );
-       }
-};
-
-/**
- * RadioSelectInputWidget is a {@link OO.ui.RadioSelectWidget RadioSelectWidget} intended to be used
- * within a HTML form, such as a OO.ui.FormLayout. The selected value is synchronized with the value
- * of a hidden HTML `input` tag. Please see the [OOjs UI documentation on MediaWiki][1] for
- * more information about input widgets.
- *
- * This and OO.ui.DropdownInputWidget support the same configuration options.
- *
- *     @example
- *     // Example: A RadioSelectInputWidget with three options
- *     var radioSelectInput = new OO.ui.RadioSelectInputWidget( {
- *         options: [
- *             { data: 'a', label: 'First' },
- *             { data: 'b', label: 'Second'},
- *             { data: 'c', label: 'Third' }
- *         ]
- *     } );
- *     $( 'body' ).append( radioSelectInput.$element );
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
- *
- * @class
- * @extends OO.ui.InputWidget
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
- */
-OO.ui.RadioSelectInputWidget = function OoUiRadioSelectInputWidget( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Properties (must be done before parent constructor which calls #setDisabled)
-       this.radioSelectWidget = new OO.ui.RadioSelectWidget();
-
-       // Parent constructor
-       OO.ui.RadioSelectInputWidget.parent.call( this, config );
-
-       // Events
-       this.radioSelectWidget.connect( this, { select: 'onMenuSelect' } );
-
-       // Initialization
-       this.setOptions( config.options || [] );
-       this.$element
-               .addClass( 'oo-ui-radioSelectInputWidget' )
-               .append( this.radioSelectWidget.$element );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.RadioSelectInputWidget, OO.ui.InputWidget );
-
-/* Static Properties */
-
-OO.ui.RadioSelectInputWidget.static.supportsSimpleLabel = false;
-
-/* Static Methods */
-
-/**
- * @inheritdoc
- */
-OO.ui.RadioSelectInputWidget.static.gatherPreInfuseState = function ( node, config ) {
-       var state = OO.ui.RadioSelectInputWidget.parent.static.gatherPreInfuseState( node, config );
-       state.value = $( node ).find( '.oo-ui-radioInputWidget .oo-ui-inputWidget-input:checked' ).val();
-       return state;
-};
-
-/* Methods */
-
-/**
- * @inheritdoc
- * @protected
- */
-OO.ui.RadioSelectInputWidget.prototype.getInputElement = function () {
-       return $( '<input type="hidden">' );
-};
-
-/**
- * Handles menu select events.
- *
- * @private
- * @param {OO.ui.RadioOptionWidget} item Selected menu item
- */
-OO.ui.RadioSelectInputWidget.prototype.onMenuSelect = function ( item ) {
-       this.setValue( item.getData() );
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.RadioSelectInputWidget.prototype.setValue = function ( value ) {
-       value = this.cleanUpValue( value );
-       this.radioSelectWidget.selectItemByData( value );
-       OO.ui.RadioSelectInputWidget.parent.prototype.setValue.call( this, value );
-       return this;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.RadioSelectInputWidget.prototype.setDisabled = function ( state ) {
-       this.radioSelectWidget.setDisabled( state );
-       OO.ui.RadioSelectInputWidget.parent.prototype.setDisabled.call( this, state );
-       return this;
-};
-
-/**
- * Set the options available for this input.
- *
- * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
- * @chainable
- */
-OO.ui.RadioSelectInputWidget.prototype.setOptions = function ( options ) {
-       var
-               value = this.getValue(),
-               widget = this;
-
-       // Rebuild the radioSelect menu
-       this.radioSelectWidget
-               .clearItems()
-               .addItems( options.map( function ( opt ) {
-                       var optValue = widget.cleanUpValue( opt.data );
-                       return new OO.ui.RadioOptionWidget( {
-                               data: optValue,
-                               label: opt.label !== undefined ? opt.label : optValue
-                       } );
-               } ) );
-
-       // Restore the previous value, or reset to something sensible
-       if ( this.radioSelectWidget.getItemFromData( value ) ) {
-               // Previous value is still available, ensure consistency with the radioSelect
-               this.setValue( value );
-       } else {
-               // No longer valid, reset
-               if ( options.length ) {
-                       this.setValue( options[ 0 ].data );
-               }
-       }
-
-       return this;
-};
-
-/**
- * TextInputWidgets, like HTML text inputs, can be configured with options that customize the
- * size of the field as well as its presentation. In addition, these widgets can be configured
- * with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators}, an optional
- * validation-pattern (used to determine if an input value is valid or not) and an input filter,
- * which modifies incoming values rather than validating them.
- * Please see the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
- *
- * This widget can be used inside a HTML form, such as a OO.ui.FormLayout.
- *
- *     @example
- *     // Example of a text input widget
- *     var textInput = new OO.ui.TextInputWidget( {
- *         value: 'Text input'
- *     } )
- *     $( 'body' ).append( textInput.$element );
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
- *
- * @class
- * @extends OO.ui.InputWidget
- * @mixins OO.ui.mixin.IconElement
- * @mixins OO.ui.mixin.IndicatorElement
- * @mixins OO.ui.mixin.PendingElement
- * @mixins OO.ui.mixin.LabelElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {string} [type='text'] The value of the HTML `type` attribute: 'text', 'password', 'search',
- *  'email' or 'url'. Ignored if `multiline` is true.
- *
- *  Some values of `type` result in additional behaviors:
- *
- *  - `search`: implies `icon: 'search'` and `indicator: 'clear'`; when clicked, the indicator
- *    empties the text field
- * @cfg {string} [placeholder] Placeholder text
- * @cfg {boolean} [autofocus=false] Use an HTML `autofocus` attribute to
- *  instruct the browser to focus this widget.
- * @cfg {boolean} [readOnly=false] Prevent changes to the value of the text input.
- * @cfg {number} [maxLength] Maximum number of characters allowed in the input.
- * @cfg {boolean} [multiline=false] Allow multiple lines of text
- * @cfg {number} [rows] If multiline, number of visible lines in textarea. If used with `autosize`,
- *  specifies minimum number of rows to display.
- * @cfg {boolean} [autosize=false] Automatically resize the text input to fit its content.
- *  Use the #maxRows config to specify a maximum number of displayed rows.
- * @cfg {boolean} [maxRows] Maximum number of rows to display when #autosize is set to true.
- *  Defaults to the maximum of `10` and `2 * rows`, or `10` if `rows` isn't provided.
- * @cfg {string} [labelPosition='after'] The position of the inline label relative to that of
- *  the value or placeholder text: `'before'` or `'after'`
- * @cfg {boolean} [required=false] Mark the field as required. Implies `indicator: 'required'`.
- * @cfg {boolean} [autocomplete=true] Should the browser support autocomplete for this field
- * @cfg {RegExp|Function|string} [validate] Validation pattern: when string, a symbolic name of a
- *  pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer'
- *  (the value must contain only numbers); when RegExp, a regular expression that must match the
- *  value for it to be considered valid; when Function, a function receiving the value as parameter
- *  that must return true, or promise resolving to true, for it to be considered valid.
- */
-OO.ui.TextInputWidget = function OoUiTextInputWidget( config ) {
-       // Configuration initialization
-       config = $.extend( {
-               type: 'text',
-               labelPosition: 'after'
-       }, config );
-       if ( config.type === 'search' ) {
-               if ( config.icon === undefined ) {
-                       config.icon = 'search';
-               }
-               // indicator: 'clear' is set dynamically later, depending on value
-       }
-       if ( config.required ) {
-               if ( config.indicator === undefined ) {
-                       config.indicator = 'required';
-               }
-       }
-
-       // Parent constructor
-       OO.ui.TextInputWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.IconElement.call( this, config );
-       OO.ui.mixin.IndicatorElement.call( this, config );
-       OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$input } ) );
-       OO.ui.mixin.LabelElement.call( this, config );
-
-       // Properties
-       this.type = this.getSaneType( config );
-       this.readOnly = false;
-       this.multiline = !!config.multiline;
-       this.autosize = !!config.autosize;
-       this.minRows = config.rows !== undefined ? config.rows : '';
-       this.maxRows = config.maxRows || Math.max( 2 * ( this.minRows || 0 ), 10 );
-       this.validate = null;
-       this.styleHeight = null;
-       this.scrollWidth = null;
-
-       // Clone for resizing
-       if ( this.autosize ) {
-               this.$clone = this.$input
-                       .clone()
-                       .insertAfter( this.$input )
-                       .attr( 'aria-hidden', 'true' )
-                       .addClass( 'oo-ui-element-hidden' );
-       }
-
-       this.setValidation( config.validate );
-       this.setLabelPosition( config.labelPosition );
-
-       // Events
-       this.$input.on( {
-               keypress: this.onKeyPress.bind( this ),
-               blur: this.onBlur.bind( this )
-       } );
-       this.$input.one( {
-               focus: this.onElementAttach.bind( this )
-       } );
-       this.$icon.on( 'mousedown', this.onIconMouseDown.bind( this ) );
-       this.$indicator.on( 'mousedown', this.onIndicatorMouseDown.bind( this ) );
-       this.on( 'labelChange', this.updatePosition.bind( this ) );
-       this.connect( this, {
-               change: 'onChange',
-               disable: 'onDisable'
-       } );
-
-       // Initialization
-       this.$element
-               .addClass( 'oo-ui-textInputWidget oo-ui-textInputWidget-type-' + this.type )
-               .append( this.$icon, this.$indicator );
-       this.setReadOnly( !!config.readOnly );
-       this.updateSearchIndicator();
-       if ( config.placeholder ) {
-               this.$input.attr( 'placeholder', config.placeholder );
-       }
-       if ( config.maxLength !== undefined ) {
-               this.$input.attr( 'maxlength', config.maxLength );
-       }
-       if ( config.autofocus ) {
-               this.$input.attr( 'autofocus', 'autofocus' );
-       }
-       if ( config.required ) {
-               this.$input.attr( 'required', 'required' );
-               this.$input.attr( 'aria-required', 'true' );
-       }
-       if ( config.autocomplete === false ) {
-               this.$input.attr( 'autocomplete', 'off' );
-               // Turning off autocompletion also disables "form caching" when the user navigates to a
-               // different page and then clicks "Back". Re-enable it when leaving. Borrowed from jQuery UI.
-               $( window ).on( {
-                       beforeunload: function () {
-                               this.$input.removeAttr( 'autocomplete' );
-                       }.bind( this ),
-                       pageshow: function () {
-                               // Browsers don't seem to actually fire this event on "Back", they instead just reload the
-                               // whole page... it shouldn't hurt, though.
-                               this.$input.attr( 'autocomplete', 'off' );
-                       }.bind( this )
-               } );
-       }
-       if ( this.multiline && config.rows ) {
-               this.$input.attr( 'rows', config.rows );
-       }
-       if ( this.label || config.autosize ) {
-               this.installParentChangeDetector();
-       }
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.TextInputWidget, OO.ui.InputWidget );
-OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IconElement );
-OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.IndicatorElement );
-OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.PendingElement );
-OO.mixinClass( OO.ui.TextInputWidget, OO.ui.mixin.LabelElement );
-
-/* Static Properties */
-
-OO.ui.TextInputWidget.static.validationPatterns = {
-       'non-empty': /.+/,
-       integer: /^\d+$/
-};
-
-/* Static Methods */
-
-/**
- * @inheritdoc
- */
-OO.ui.TextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
-       var state = OO.ui.TextInputWidget.parent.static.gatherPreInfuseState( node, config );
-       if ( config.multiline ) {
-               state.scrollTop = config.$input.scrollTop();
-       }
-       return state;
-};
-
-/* Events */
-
-/**
- * An `enter` event is emitted when the user presses 'enter' inside the text box.
- *
- * Not emitted if the input is multiline.
- *
- * @event enter
- */
-
-/**
- * A `resize` event is emitted when autosize is set and the widget resizes
- *
- * @event resize
- */
-
-/* Methods */
-
-/**
- * Handle icon mouse down events.
- *
- * @private
- * @param {jQuery.Event} e Mouse down event
- * @fires icon
- */
-OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
-       if ( e.which === OO.ui.MouseButtons.LEFT ) {
-               this.$input[ 0 ].focus();
-               return false;
-       }
-};
-
-/**
- * Handle indicator mouse down events.
- *
- * @private
- * @param {jQuery.Event} e Mouse down event
- * @fires indicator
- */
-OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
-       if ( e.which === OO.ui.MouseButtons.LEFT ) {
-               if ( this.type === 'search' ) {
-                       // Clear the text field
-                       this.setValue( '' );
-               }
-               this.$input[ 0 ].focus();
-               return false;
-       }
-};
-
-/**
- * Handle key press events.
- *
- * @private
- * @param {jQuery.Event} e Key press event
- * @fires enter If enter key is pressed and input is not multiline
- */
-OO.ui.TextInputWidget.prototype.onKeyPress = function ( e ) {
-       if ( e.which === OO.ui.Keys.ENTER && !this.multiline ) {
-               this.emit( 'enter', e );
-       }
-};
-
-/**
- * Handle blur events.
- *
- * @private
- * @param {jQuery.Event} e Blur event
- */
-OO.ui.TextInputWidget.prototype.onBlur = function () {
-       this.setValidityFlag();
-};
-
-/**
- * Handle element attach events.
- *
- * @private
- * @param {jQuery.Event} e Element attach event
- */
-OO.ui.TextInputWidget.prototype.onElementAttach = function () {
-       // Any previously calculated size is now probably invalid if we reattached elsewhere
-       this.valCache = null;
-       this.adjustSize();
-       this.positionLabel();
-};
-
-/**
- * Handle change events.
- *
- * @param {string} value
- * @private
- */
-OO.ui.TextInputWidget.prototype.onChange = function () {
-       this.updateSearchIndicator();
-       this.setValidityFlag();
-       this.adjustSize();
-};
-
-/**
- * Handle disable events.
- *
- * @param {boolean} disabled Element is disabled
- * @private
- */
-OO.ui.TextInputWidget.prototype.onDisable = function () {
-       this.updateSearchIndicator();
-};
-
-/**
- * Check if the input is {@link #readOnly read-only}.
- *
- * @return {boolean}
- */
-OO.ui.TextInputWidget.prototype.isReadOnly = function () {
-       return this.readOnly;
-};
-
-/**
- * Set the {@link #readOnly read-only} state of the input.
- *
- * @param {boolean} state Make input read-only
- * @chainable
- */
-OO.ui.TextInputWidget.prototype.setReadOnly = function ( state ) {
-       this.readOnly = !!state;
-       this.$input.prop( 'readOnly', this.readOnly );
-       this.updateSearchIndicator();
-       return this;
-};
-
-/**
- * Support function for making #onElementAttach work across browsers.
- *
- * This whole function could be replaced with one line of code using the DOMNodeInsertedIntoDocument
- * event, but it's not supported by Firefox and allegedly deprecated, so we only use it as fallback.
- *
- * Due to MutationObserver performance woes, #onElementAttach is only somewhat reliably called the
- * first time that the element gets attached to the documented.
- */
-OO.ui.TextInputWidget.prototype.installParentChangeDetector = function () {
-       var mutationObserver, onRemove, topmostNode, fakeParentNode,
-               MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver,
-               widget = this;
-
-       if ( MutationObserver ) {
-               // The new way. If only it wasn't so ugly.
-
-               if ( this.$element.closest( 'html' ).length ) {
-                       // Widget is attached already, do nothing. This breaks the functionality of this function when
-                       // the widget is detached and reattached. Alas, doing this correctly with MutationObserver
-                       // would require observation of the whole document, which would hurt performance of other,
-                       // more important code.
-                       return;
-               }
-
-               // Find topmost node in the tree
-               topmostNode = this.$element[ 0 ];
-               while ( topmostNode.parentNode ) {
-                       topmostNode = topmostNode.parentNode;
-               }
-
-               // We have no way to detect the $element being attached somewhere without observing the entire
-               // DOM with subtree modifications, which would hurt performance. So we cheat: we hook to the
-               // parent node of $element, and instead detect when $element is removed from it (and thus
-               // probably attached somewhere else). If there is no parent, we create a "fake" one. If it
-               // doesn't get attached, we end up back here and create the parent.
-
-               mutationObserver = new MutationObserver( function ( mutations ) {
-                       var i, j, removedNodes;
-                       for ( i = 0; i < mutations.length; i++ ) {
-                               removedNodes = mutations[ i ].removedNodes;
-                               for ( j = 0; j < removedNodes.length; j++ ) {
-                                       if ( removedNodes[ j ] === topmostNode ) {
-                                               setTimeout( onRemove, 0 );
-                                               return;
-                                       }
-                               }
-                       }
-               } );
-
-               onRemove = function () {
-                       // If the node was attached somewhere else, report it
-                       if ( widget.$element.closest( 'html' ).length ) {
-                               widget.onElementAttach();
-                       }
-                       mutationObserver.disconnect();
-                       widget.installParentChangeDetector();
-               };
-
-               // Create a fake parent and observe it
-               fakeParentNode = $( '<div>' ).append( topmostNode )[ 0 ];
-               mutationObserver.observe( fakeParentNode, { childList: true } );
-       } else {
-               // Using the DOMNodeInsertedIntoDocument event is much nicer and less magical, and works for
-               // detachment and reattachment, but it's not supported by Firefox and allegedly deprecated.
-               this.$element.on( 'DOMNodeInsertedIntoDocument', this.onElementAttach.bind( this ) );
-       }
-};
-
-/**
- * Automatically adjust the size of the text input.
- *
- * This only affects #multiline inputs that are {@link #autosize autosized}.
- *
- * @chainable
- * @fires resize
- */
-OO.ui.TextInputWidget.prototype.adjustSize = function () {
-       var scrollHeight, innerHeight, outerHeight, maxInnerHeight, measurementError,
-               idealHeight, newHeight, scrollWidth, property;
-
-       if ( this.multiline && this.$input.val() !== this.valCache ) {
-               if ( this.autosize ) {
-                       this.$clone
-                               .val( this.$input.val() )
-                               .attr( 'rows', this.minRows )
-                               // Set inline height property to 0 to measure scroll height
-                               .css( 'height', 0 );
-
-                       this.$clone.removeClass( 'oo-ui-element-hidden' );
-
-                       this.valCache = this.$input.val();
-
-                       scrollHeight = this.$clone[ 0 ].scrollHeight;
-
-                       // Remove inline height property to measure natural heights
-                       this.$clone.css( 'height', '' );
-                       innerHeight = this.$clone.innerHeight();
-                       outerHeight = this.$clone.outerHeight();
-
-                       // Measure max rows height
-                       this.$clone
-                               .attr( 'rows', this.maxRows )
-                               .css( 'height', 'auto' )
-                               .val( '' );
-                       maxInnerHeight = this.$clone.innerHeight();
-
-                       // Difference between reported innerHeight and scrollHeight with no scrollbars present
-                       // Equals 1 on Blink-based browsers and 0 everywhere else
-                       measurementError = maxInnerHeight - this.$clone[ 0 ].scrollHeight;
-                       idealHeight = Math.min( maxInnerHeight, scrollHeight + measurementError );
-
-                       this.$clone.addClass( 'oo-ui-element-hidden' );
-
-                       // Only apply inline height when expansion beyond natural height is needed
-                       // Use the difference between the inner and outer height as a buffer
-                       newHeight = idealHeight > innerHeight ? idealHeight + ( outerHeight - innerHeight ) : '';
-                       if ( newHeight !== this.styleHeight ) {
-                               this.$input.css( 'height', newHeight );
-                               this.styleHeight = newHeight;
-                               this.emit( 'resize' );
-                       }
-               }
-               scrollWidth = this.$input[ 0 ].offsetWidth - this.$input[ 0 ].clientWidth;
-               if ( scrollWidth !== this.scrollWidth ) {
-                       property = this.$element.css( 'direction' ) === 'rtl' ? 'left' : 'right';
-                       // Reset
-                       this.$label.css( { right: '', left: '' } );
-                       this.$indicator.css( { right: '', left: '' } );
-
-                       if ( scrollWidth ) {
-                               this.$indicator.css( property, scrollWidth );
-                               if ( this.labelPosition === 'after' ) {
-                                       this.$label.css( property, scrollWidth );
-                               }
-                       }
-
-                       this.scrollWidth = scrollWidth;
-                       this.positionLabel();
-               }
-       }
-       return this;
-};
-
-/**
- * @inheritdoc
- * @protected
- */
-OO.ui.TextInputWidget.prototype.getInputElement = function ( config ) {
-       return config.multiline ?
-               $( '<textarea>' ) :
-               $( '<input type="' + this.getSaneType( config ) + '" />' );
-};
-
-/**
- * Get sanitized value for 'type' for given config.
- *
- * @param {Object} config Configuration options
- * @return {string|null}
- * @private
- */
-OO.ui.TextInputWidget.prototype.getSaneType = function ( config ) {
-       var type = [ 'text', 'password', 'search', 'email', 'url' ].indexOf( config.type ) !== -1 ?
-               config.type :
-               'text';
-       return config.multiline ? 'multiline' : type;
-};
-
-/**
- * Check if the input supports multiple lines.
- *
- * @return {boolean}
- */
-OO.ui.TextInputWidget.prototype.isMultiline = function () {
-       return !!this.multiline;
-};
-
-/**
- * Check if the input automatically adjusts its size.
- *
- * @return {boolean}
- */
-OO.ui.TextInputWidget.prototype.isAutosizing = function () {
-       return !!this.autosize;
-};
-
-/**
- * Focus the input and select a specified range within the text.
- *
- * @param {number} from Select from offset
- * @param {number} [to] Select to offset, defaults to from
- * @chainable
- */
-OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
-       var isBackwards, start, end,
-               input = this.$input[ 0 ];
-
-       to = to || from;
-
-       isBackwards = to < from;
-       start = isBackwards ? to : from;
-       end = isBackwards ? from : to;
-
-       this.focus();
-
-       input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
-       return this;
-};
-
-/**
- * Get an object describing the current selection range in a directional manner
- *
- * @return {Object} Object containing 'from' and 'to' offsets
- */
-OO.ui.TextInputWidget.prototype.getRange = function () {
-       var input = this.$input[ 0 ],
-               start = input.selectionStart,
-               end = input.selectionEnd,
-               isBackwards = input.selectionDirection === 'backward';
-
-       return {
-               from: isBackwards ? end : start,
-               to: isBackwards ? start : end
-       };
-};
-
-/**
- * Get the length of the text input value.
- *
- * This could differ from the length of #getValue if the
- * value gets filtered
- *
- * @return {number} Input length
- */
-OO.ui.TextInputWidget.prototype.getInputLength = function () {
-       return this.$input[ 0 ].value.length;
-};
-
-/**
- * Focus the input and select the entire text.
- *
- * @chainable
- */
-OO.ui.TextInputWidget.prototype.select = function () {
-       return this.selectRange( 0, this.getInputLength() );
-};
-
-/**
- * Focus the input and move the cursor to the start.
- *
- * @chainable
- */
-OO.ui.TextInputWidget.prototype.moveCursorToStart = function () {
-       return this.selectRange( 0 );
-};
-
-/**
- * Focus the input and move the cursor to the end.
- *
- * @chainable
- */
-OO.ui.TextInputWidget.prototype.moveCursorToEnd = function () {
-       return this.selectRange( this.getInputLength() );
-};
-
-/**
- * Insert new content into the input.
- *
- * @param {string} content Content to be inserted
- * @chainable
- */
-OO.ui.TextInputWidget.prototype.insertContent = function ( content ) {
-       var start, end,
-               range = this.getRange(),
-               value = this.getValue();
-
-       start = Math.min( range.from, range.to );
-       end = Math.max( range.from, range.to );
-
-       this.setValue( value.slice( 0, start ) + content + value.slice( end ) );
-       this.selectRange( start + content.length );
-       return this;
-};
-
-/**
- * Insert new content either side of a selection.
- *
- * @param {string} pre Content to be inserted before the selection
- * @param {string} post Content to be inserted after the selection
- * @chainable
- */
-OO.ui.TextInputWidget.prototype.encapsulateContent = function ( pre, post ) {
-       var start, end,
-               range = this.getRange(),
-               offset = pre.length;
-
-       start = Math.min( range.from, range.to );
-       end = Math.max( range.from, range.to );
-
-       this.selectRange( start ).insertContent( pre );
-       this.selectRange( offset + end ).insertContent( post );
-
-       this.selectRange( offset + start, offset + end );
-       return this;
-};
-
-/**
- * Set the validation pattern.
- *
- * The validation pattern is either a regular expression, a function, or the symbolic name of a
- * pattern defined by the class: 'non-empty' (the value cannot be an empty string) or 'integer' (the
- * value must contain only numbers).
- *
- * @param {RegExp|Function|string|null} validate Regular expression, function, or the symbolic name
- *  of a pattern (either ‘integer’ or ‘non-empty’) defined by the class.
- */
-OO.ui.TextInputWidget.prototype.setValidation = function ( validate ) {
-       if ( validate instanceof RegExp || validate instanceof Function ) {
-               this.validate = validate;
-       } else {
-               this.validate = this.constructor.static.validationPatterns[ validate ] || /.*/;
-       }
-};
-
-/**
- * Sets the 'invalid' flag appropriately.
- *
- * @param {boolean} [isValid] Optionally override validation result
- */
-OO.ui.TextInputWidget.prototype.setValidityFlag = function ( isValid ) {
-       var widget = this,
-               setFlag = function ( valid ) {
-                       if ( !valid ) {
-                               widget.$input.attr( 'aria-invalid', 'true' );
-                       } else {
-                               widget.$input.removeAttr( 'aria-invalid' );
-                       }
-                       widget.setFlags( { invalid: !valid } );
-               };
-
-       if ( isValid !== undefined ) {
-               setFlag( isValid );
-       } else {
-               this.getValidity().then( function () {
-                       setFlag( true );
-               }, function () {
-                       setFlag( false );
-               } );
-       }
-};
-
-/**
- * Check if a value is valid.
- *
- * This method returns a promise that resolves with a boolean `true` if the current value is
- * considered valid according to the supplied {@link #validate validation pattern}.
- *
- * @deprecated
- * @return {jQuery.Promise} A promise that resolves to a boolean `true` if the value is valid.
- */
-OO.ui.TextInputWidget.prototype.isValid = function () {
-       var result;
-
-       if ( this.validate instanceof Function ) {
-               result = this.validate( this.getValue() );
-               if ( result && $.isFunction( result.promise ) ) {
-                       return result.promise();
-               } else {
-                       return $.Deferred().resolve( !!result ).promise();
-               }
-       } else {
-               return $.Deferred().resolve( !!this.getValue().match( this.validate ) ).promise();
-       }
-};
-
-/**
- * Get the validity of current value.
- *
- * This method returns a promise that resolves if the value is valid and rejects if
- * it isn't. Uses the {@link #validate validation pattern}  to check for validity.
- *
- * @return {jQuery.Promise} A promise that resolves if the value is valid, rejects if not.
- */
-OO.ui.TextInputWidget.prototype.getValidity = function () {
-       var result;
-
-       function rejectOrResolve( valid ) {
-               if ( valid ) {
-                       return $.Deferred().resolve().promise();
-               } else {
-                       return $.Deferred().reject().promise();
-               }
-       }
-
-       if ( this.validate instanceof Function ) {
-               result = this.validate( this.getValue() );
-               if ( result && $.isFunction( result.promise ) ) {
-                       return result.promise().then( function ( valid ) {
-                               return rejectOrResolve( valid );
-                       } );
-               } else {
-                       return rejectOrResolve( result );
-               }
-       } else {
-               return rejectOrResolve( this.getValue().match( this.validate ) );
-       }
-};
-
-/**
- * Set the position of the inline label relative to that of the value: `‘before’` or `‘after’`.
- *
- * @param {string} labelPosition Label position, 'before' or 'after'
- * @chainable
- */
-OO.ui.TextInputWidget.prototype.setLabelPosition = function ( labelPosition ) {
-       this.labelPosition = labelPosition;
-       this.updatePosition();
-       return this;
-};
-
-/**
- * Update the position of the inline label.
- *
- * This method is called by #setLabelPosition, and can also be called on its own if
- * something causes the label to be mispositioned.
- *
- * @chainable
- */
-OO.ui.TextInputWidget.prototype.updatePosition = function () {
-       var after = this.labelPosition === 'after';
-
-       this.$element
-               .toggleClass( 'oo-ui-textInputWidget-labelPosition-after', !!this.label && after )
-               .toggleClass( 'oo-ui-textInputWidget-labelPosition-before', !!this.label && !after );
-
-       this.valCache = null;
-       this.scrollWidth = null;
-       this.adjustSize();
-       this.positionLabel();
-
-       return this;
-};
-
-/**
- * Update the 'clear' indicator displayed on type: 'search' text fields, hiding it when the field is
- * already empty or when it's not editable.
- */
-OO.ui.TextInputWidget.prototype.updateSearchIndicator = function () {
-       if ( this.type === 'search' ) {
-               if ( this.getValue() === '' || this.isDisabled() || this.isReadOnly() ) {
-                       this.setIndicator( null );
-               } else {
-                       this.setIndicator( 'clear' );
-               }
-       }
-};
-
-/**
- * Position the label by setting the correct padding on the input.
- *
- * @private
- * @chainable
- */
-OO.ui.TextInputWidget.prototype.positionLabel = function () {
-       var after, rtl, property;
-       // Clear old values
-       this.$input
-               // Clear old values if present
-               .css( {
-                       'padding-right': '',
-                       'padding-left': ''
-               } );
-
-       if ( this.label ) {
-               this.$element.append( this.$label );
-       } else {
-               this.$label.detach();
-               return;
-       }
-
-       after = this.labelPosition === 'after';
-       rtl = this.$element.css( 'direction' ) === 'rtl';
-       property = after === rtl ? 'padding-left' : 'padding-right';
-
-       this.$input.css( property, this.$label.outerWidth( true ) + ( after ? this.scrollWidth : 0 ) );
-
-       return this;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.TextInputWidget.prototype.restorePreInfuseState = function ( state ) {
-       OO.ui.TextInputWidget.parent.prototype.restorePreInfuseState.call( this, state );
-       if ( state.scrollTop !== undefined ) {
-               this.$input.scrollTop( state.scrollTop );
-       }
-};
-
-/**
- * ComboBoxInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
- * can be entered manually) and a {@link OO.ui.MenuSelectWidget menu of options} (from which
- * a value can be chosen instead). Users can choose options from the combo box in one of two ways:
- *
- * - by typing a value in the text input field. If the value exactly matches the value of a menu
- *   option, that option will appear to be selected.
- * - by choosing a value from the menu. The value of the chosen option will then appear in the text
- *   input field.
- *
- * This widget can be used inside a HTML form, such as a OO.ui.FormLayout.
- *
- * For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
- *
- *     @example
- *     // Example: A ComboBoxInputWidget.
- *     var comboBox = new OO.ui.ComboBoxInputWidget( {
- *         label: 'ComboBoxInputWidget',
- *         value: 'Option 1',
- *         menu: {
- *             items: [
- *                 new OO.ui.MenuOptionWidget( {
- *                     data: 'Option 1',
- *                     label: 'Option One'
- *                 } ),
- *                 new OO.ui.MenuOptionWidget( {
- *                     data: 'Option 2',
- *                     label: 'Option Two'
- *                 } ),
- *                 new OO.ui.MenuOptionWidget( {
- *                     data: 'Option 3',
- *                     label: 'Option Three'
- *                 } ),
- *                 new OO.ui.MenuOptionWidget( {
- *                     data: 'Option 4',
- *                     label: 'Option Four'
- *                 } ),
- *                 new OO.ui.MenuOptionWidget( {
- *                     data: 'Option 5',
- *                     label: 'Option Five'
- *                 } )
- *             ]
- *         }
- *     } );
- *     $( 'body' ).append( comboBox.$element );
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
- *
- * @class
- * @extends OO.ui.TextInputWidget
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {Object[]} [options=[]] Array of menu options in the format `{ data: …, label: … }`
- * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.FloatingMenuSelectWidget menu select widget}.
- * @cfg {jQuery} [$overlay] Render the menu into a separate layer. This configuration is useful in cases where
- *  the expanded menu is larger than its containing `<div>`. The specified overlay layer is usually on top of the
- *  containing `<div>` and has a larger area. By default, the menu uses relative positioning.
- */
-OO.ui.ComboBoxInputWidget = function OoUiComboBoxInputWidget( config ) {
-       // Configuration initialization
-       config = $.extend( {
-               indicator: 'down'
-       }, config );
-       // For backwards-compatibility with ComboBoxWidget config
-       $.extend( config, config.input );
-
-       // Parent constructor
-       OO.ui.ComboBoxInputWidget.parent.call( this, config );
-
-       // Properties
-       this.$overlay = config.$overlay || this.$element;
-       this.menu = new OO.ui.FloatingMenuSelectWidget( $.extend(
-               {
-                       widget: this,
-                       input: this,
-                       $container: this.$element,
-                       disabled: this.isDisabled()
-               },
-               config.menu
-       ) );
-       // For backwards-compatibility with ComboBoxWidget
-       this.input = this;
-
-       // Events
-       this.$indicator.on( {
-               click: this.onIndicatorClick.bind( this ),
-               keypress: this.onIndicatorKeyPress.bind( this )
-       } );
-       this.connect( this, {
-               change: 'onInputChange',
-               enter: 'onInputEnter'
-       } );
-       this.menu.connect( this, {
-               choose: 'onMenuChoose',
-               add: 'onMenuItemsChange',
-               remove: 'onMenuItemsChange'
-       } );
-
-       // Initialization
-       this.$input.attr( {
-               role: 'combobox',
-               'aria-autocomplete': 'list'
-       } );
-       // Do not override options set via config.menu.items
-       if ( config.options !== undefined ) {
-               this.setOptions( config.options );
-       }
-       // Extra class for backwards-compatibility with ComboBoxWidget
-       this.$element.addClass( 'oo-ui-comboBoxInputWidget oo-ui-comboBoxWidget' );
-       this.$overlay.append( this.menu.$element );
-       this.onMenuItemsChange();
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.ComboBoxInputWidget, OO.ui.TextInputWidget );
-
-/* Methods */
-
-/**
- * Get the combobox's menu.
- * @return {OO.ui.FloatingMenuSelectWidget} Menu widget
- */
-OO.ui.ComboBoxInputWidget.prototype.getMenu = function () {
-       return this.menu;
-};
-
-/**
- * Get the combobox's text input widget.
- * @return {OO.ui.TextInputWidget} Text input widget
- */
-OO.ui.ComboBoxInputWidget.prototype.getInput = function () {
-       return this;
-};
-
-/**
- * Handle input change events.
- *
- * @private
- * @param {string} value New value
- */
-OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
-       var match = this.menu.getItemFromData( value );
-
-       this.menu.selectItem( match );
-       if ( this.menu.getHighlightedItem() ) {
-               this.menu.highlightItem( match );
-       }
-
-       if ( !this.isDisabled() ) {
-               this.menu.toggle( true );
-       }
-};
-
-/**
- * Handle mouse click events.
- *
- * @private
- * @param {jQuery.Event} e Mouse click event
- */
-OO.ui.ComboBoxInputWidget.prototype.onIndicatorClick = function ( e ) {
-       if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
-               this.menu.toggle();
-               this.$input[ 0 ].focus();
-       }
-       return false;
-};
-
-/**
- * Handle key press events.
- *
- * @private
- * @param {jQuery.Event} e Key press event
- */
-OO.ui.ComboBoxInputWidget.prototype.onIndicatorKeyPress = function ( e ) {
-       if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
-               this.menu.toggle();
-               this.$input[ 0 ].focus();
-               return false;
-       }
-};
-
-/**
- * Handle input enter events.
- *
- * @private
- */
-OO.ui.ComboBoxInputWidget.prototype.onInputEnter = function () {
-       if ( !this.isDisabled() ) {
-               this.menu.toggle( false );
-       }
-};
-
-/**
- * Handle menu choose events.
- *
- * @private
- * @param {OO.ui.OptionWidget} item Chosen item
- */
-OO.ui.ComboBoxInputWidget.prototype.onMenuChoose = function ( item ) {
-       this.setValue( item.getData() );
-};
-
-/**
- * Handle menu item change events.
- *
- * @private
- */
-OO.ui.ComboBoxInputWidget.prototype.onMenuItemsChange = function () {
-       var match = this.menu.getItemFromData( this.getValue() );
-       this.menu.selectItem( match );
-       if ( this.menu.getHighlightedItem() ) {
-               this.menu.highlightItem( match );
-       }
-       this.$element.toggleClass( 'oo-ui-comboBoxInputWidget-empty', this.menu.isEmpty() );
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.ComboBoxInputWidget.prototype.setDisabled = function ( disabled ) {
-       // Parent method
-       OO.ui.ComboBoxInputWidget.parent.prototype.setDisabled.call( this, disabled );
-
-       if ( this.menu ) {
-               this.menu.setDisabled( this.isDisabled() );
-       }
-
-       return this;
-};
-
-/**
- * Set the options available for this input.
- *
- * @param {Object[]} options Array of menu options in the format `{ data: …, label: … }`
- * @chainable
- */
-OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
-       this.getMenu()
-               .clearItems()
-               .addItems( options.map( function ( opt ) {
-                       return new OO.ui.MenuOptionWidget( {
-                               data: opt.data,
-                               label: opt.label !== undefined ? opt.label : opt.data
-                       } );
-               } ) );
-
-       return this;
-};
-
-/**
- * @class
- * @deprecated Use OO.ui.ComboBoxInputWidget instead.
- */
-OO.ui.ComboBoxWidget = OO.ui.ComboBoxInputWidget;
-
-/**
- * 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.mixin.LabelElement
- * @mixins OO.ui.mixin.TitledElement
- *
- * @constructor
- * @param {OO.ui.Widget} fieldWidget Field widget
- * @param {Object} [config] Configuration options
- * @cfg {string} [align='left'] Alignment of the label: 'left', 'right', 'top' or 'inline'
- * @cfg {Array} [errors] Error messages about the widget, which will be displayed below the widget.
- *  The array may contain strings or OO.ui.HtmlSnippet instances.
- * @cfg {Array} [notices] Notices about the widget, which will be displayed below the widget.
- *  The array may contain strings or OO.ui.HtmlSnippet instances.
- * @cfg {string|OO.ui.HtmlSnippet} [help] Help text. When help text is specified, a "help" icon will appear
- *  in the upper-right corner of the rendered field; clicking it will display the text in a popup.
- *  For important messages, you are advised to use `notices`, as they are always shown.
- *
- * @throws {Error} An error is thrown if no widget is specified
- */
-OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
-       var hasInputWidget, div;
-
-       // Allow passing positional parameters inside the config object
-       if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
-               config = fieldWidget;
-               fieldWidget = config.fieldWidget;
-       }
-
-       // Make sure we have required constructor arguments
-       if ( fieldWidget === undefined ) {
-               throw new Error( 'Widget not found' );
-       }
-
-       hasInputWidget = fieldWidget.constructor.static.supportsSimpleLabel;
-
-       // Configuration initialization
-       config = $.extend( { align: 'left' }, config );
-
-       // Parent constructor
-       OO.ui.FieldLayout.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.LabelElement.call( this, config );
-       OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$label } ) );
-
-       // Properties
-       this.fieldWidget = fieldWidget;
-       this.errors = [];
-       this.notices = [];
-       this.$field = $( '<div>' );
-       this.$messages = $( '<ul>' );
-       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'
-               } );
-
-               div = $( '<div>' );
-               if ( config.help instanceof OO.ui.HtmlSnippet ) {
-                       div.html( config.help.toString() );
-               } else {
-                       div.text( config.help );
-               }
-               this.popupButtonWidget.getPopup().$body.append(
-                       div.addClass( 'oo-ui-fieldLayout-help-content' )
-               );
-               this.$help = this.popupButtonWidget.$element;
-       } else {
-               this.$help = $( [] );
-       }
-
-       // Events
-       if ( hasInputWidget ) {
-               this.$label.on( 'click', this.onLabelClick.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.$messages.addClass( 'oo-ui-fieldLayout-messages' );
-       this.$field
-               .addClass( 'oo-ui-fieldLayout-field' )
-               .toggleClass( 'oo-ui-fieldLayout-disable', this.fieldWidget.isDisabled() )
-               .append( this.fieldWidget.$element );
-
-       this.setErrors( config.errors || [] );
-       this.setNotices( config.notices || [] );
-       this.setAlignment( config.align );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.FieldLayout, OO.ui.Layout );
-OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.LabelElement );
-OO.mixinClass( OO.ui.FieldLayout, OO.ui.mixin.TitledElement );
-
-/* Methods */
-
-/**
- * Handle field disable events.
- *
- * @private
- * @param {boolean} value Field is disabled
- */
-OO.ui.FieldLayout.prototype.onFieldDisable = function ( value ) {
-       this.$element.toggleClass( 'oo-ui-fieldLayout-disabled', value );
-};
-
-/**
- * Handle label mouse click events.
- *
- * @private
- * @param {jQuery.Event} e Mouse click event
- */
-OO.ui.FieldLayout.prototype.onLabelClick = function () {
-       this.fieldWidget.simulateLabelClick();
-       return false;
-};
-
-/**
- * Get the widget contained by the field.
- *
- * @return {OO.ui.Widget} Field widget
- */
-OO.ui.FieldLayout.prototype.getField = function () {
-       return this.fieldWidget;
-};
-
-/**
- * @protected
- * @param {string} kind 'error' or 'notice'
- * @param {string|OO.ui.HtmlSnippet} text
- * @return {jQuery}
- */
-OO.ui.FieldLayout.prototype.makeMessage = function ( kind, text ) {
-       var $listItem, $icon, message;
-       $listItem = $( '<li>' );
-       if ( kind === 'error' ) {
-               $icon = new OO.ui.IconWidget( { icon: 'alert', flags: [ 'warning' ] } ).$element;
-       } else if ( kind === 'notice' ) {
-               $icon = new OO.ui.IconWidget( { icon: 'info' } ).$element;
-       } else {
-               $icon = '';
-       }
-       message = new OO.ui.LabelWidget( { label: text } );
-       $listItem
-               .append( $icon, message.$element )
-               .addClass( 'oo-ui-fieldLayout-messages-' + kind );
-       return $listItem;
-};
-
-/**
- * Set the field alignment mode.
- *
- * @private
- * @param {string} value Alignment mode, either 'left', 'right', 'top' or 'inline'
- * @chainable
- */
-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;
-};
-
-/**
- * Set the list of error messages.
- *
- * @param {Array} errors Error messages about the widget, which will be displayed below the widget.
- *  The array may contain strings or OO.ui.HtmlSnippet instances.
- * @chainable
- */
-OO.ui.FieldLayout.prototype.setErrors = function ( errors ) {
-       this.errors = errors.slice();
-       this.updateMessages();
-       return this;
-};
-
-/**
- * Set the list of notice messages.
- *
- * @param {Array} notices Notices about the widget, which will be displayed below the widget.
- *  The array may contain strings or OO.ui.HtmlSnippet instances.
- * @chainable
- */
-OO.ui.FieldLayout.prototype.setNotices = function ( notices ) {
-       this.notices = notices.slice();
-       this.updateMessages();
-       return this;
-};
-
-/**
- * Update the rendering of error and notice messages.
- *
- * @private
- */
-OO.ui.FieldLayout.prototype.updateMessages = function () {
-       var i;
-       this.$messages.empty();
-
-       if ( this.errors.length || this.notices.length ) {
-               this.$body.after( this.$messages );
-       } else {
-               this.$messages.remove();
-               return;
-       }
-
-       for ( i = 0; i < this.notices.length; i++ ) {
-               this.$messages.append( this.makeMessage( 'notice', this.notices[ i ] ) );
-       }
-       for ( i = 0; i < this.errors.length; i++ ) {
-               this.$messages.append( this.makeMessage( 'error', this.errors[ i ] ) );
-       }
-};
-
-/**
- * ActionFieldLayouts are used with OO.ui.FieldsetLayout. The layout consists of a field-widget, a button,
- * and an optional label and/or help text. The field-widget (e.g., a {@link OO.ui.TextInputWidget TextInputWidget}),
- * is required and is specified before any optional configuration settings.
- *
- * Labels can be 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 when help
- * text is specified.
- *
- *     @example
- *     // Example of an ActionFieldLayout
- *     var actionFieldLayout = new OO.ui.ActionFieldLayout(
- *         new OO.ui.TextInputWidget( {
- *             placeholder: 'Field widget'
- *         } ),
- *         new OO.ui.ButtonWidget( {
- *             label: 'Button'
- *         } ),
- *         {
- *             label: 'An ActionFieldLayout. This label is aligned top',
- *             align: 'top',
- *             help: 'This is help text'
- *         }
- *     );
- *
- *     $( 'body' ).append( actionFieldLayout.$element );
- *
- * @class
- * @extends OO.ui.FieldLayout
- *
- * @constructor
- * @param {OO.ui.Widget} fieldWidget Field widget
- * @param {OO.ui.ButtonWidget} buttonWidget Button widget
- */
-OO.ui.ActionFieldLayout = function OoUiActionFieldLayout( fieldWidget, buttonWidget, config ) {
-       // Allow passing positional parameters inside the config object
-       if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
-               config = fieldWidget;
-               fieldWidget = config.fieldWidget;
-               buttonWidget = config.buttonWidget;
-       }
-
-       // Parent constructor
-       OO.ui.ActionFieldLayout.parent.call( this, fieldWidget, config );
-
-       // Properties
-       this.buttonWidget = buttonWidget;
-       this.$button = $( '<div>' );
-       this.$input = $( '<div>' );
-
-       // Initialization
-       this.$element
-               .addClass( 'oo-ui-actionFieldLayout' );
-       this.$button
-               .addClass( 'oo-ui-actionFieldLayout-button' )
-               .append( this.buttonWidget.$element );
-       this.$input
-               .addClass( 'oo-ui-actionFieldLayout-input' )
-               .append( this.fieldWidget.$element );
-       this.$field
-               .append( this.$input, this.$button );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
-
-/**
- * FieldsetLayouts are composed of one or more {@link OO.ui.FieldLayout FieldLayouts},
- * which each contain an individual widget and, optionally, a label. Each Fieldset can be
- * configured with a label as well. For more information and examples,
- * please see the [OOjs UI documentation on MediaWiki][1].
- *
- *     @example
- *     // Example of a fieldset layout
- *     var input1 = new OO.ui.TextInputWidget( {
- *         placeholder: 'A text input field'
- *     } );
- *
- *     var input2 = new OO.ui.TextInputWidget( {
- *         placeholder: 'A text input field'
- *     } );
- *
- *     var fieldset = new OO.ui.FieldsetLayout( {
- *         label: 'Example of a fieldset layout'
- *     } );
- *
- *     fieldset.addItems( [
- *         new OO.ui.FieldLayout( input1, {
- *             label: 'Field One'
- *         } ),
- *         new OO.ui.FieldLayout( input2, {
- *             label: 'Field Two'
- *         } )
- *     ] );
- *     $( 'body' ).append( fieldset.$element );
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Fields_and_Fieldsets
- *
- * @class
- * @extends OO.ui.Layout
- * @mixins OO.ui.mixin.IconElement
- * @mixins OO.ui.mixin.LabelElement
- * @mixins OO.ui.mixin.GroupElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {OO.ui.FieldLayout[]} [items] An array of fields to add to the fieldset. See OO.ui.FieldLayout for more information about fields.
- */
-OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.FieldsetLayout.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.IconElement.call( this, config );
-       OO.ui.mixin.LabelElement.call( this, config );
-       OO.ui.mixin.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(
-                       $( '<div>' )
-                               .text( config.help )
-                               .addClass( 'oo-ui-fieldsetLayout-help-content' )
-               );
-               this.$help = this.popupButtonWidget.$element;
-       } else {
-               this.$help = $( [] );
-       }
-
-       // 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.mixin.IconElement );
-OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.LabelElement );
-OO.mixinClass( OO.ui.FieldsetLayout, OO.ui.mixin.GroupElement );
-
-/**
- * FormLayouts are used to wrap {@link OO.ui.FieldsetLayout FieldsetLayouts} when you intend to use browser-based
- * form submission for the fields instead of handling them in JavaScript. Form layouts can be configured with an
- * HTML form action, an encoding type, and a method using the #action, #enctype, and #method configs, respectively.
- * See the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
- *
- * Only widgets from the {@link OO.ui.InputWidget InputWidget} family support form submission. It
- * includes standard form elements like {@link OO.ui.CheckboxInputWidget checkboxes}, {@link
- * OO.ui.RadioInputWidget radio buttons} and {@link OO.ui.TextInputWidget text fields}, as well as
- * some fancier controls. Some controls have both regular and InputWidget variants, for example
- * OO.ui.DropdownWidget and OO.ui.DropdownInputWidget – only the latter support form submission and
- * often have simplified APIs to match the capabilities of HTML forms.
- * See the [OOjs UI Inputs documentation on MediaWiki] [2] for more information about InputWidgets.
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Layouts/Forms
- * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Inputs
- *
- *     @example
- *     // Example of a form layout that wraps a fieldset layout
- *     var input1 = new OO.ui.TextInputWidget( {
- *         placeholder: 'Username'
- *     } );
- *     var input2 = new OO.ui.TextInputWidget( {
- *         placeholder: 'Password',
- *         type: 'password'
- *     } );
- *     var submit = new OO.ui.ButtonInputWidget( {
- *         label: 'Submit'
- *     } );
- *
- *     var fieldset = new OO.ui.FieldsetLayout( {
- *         label: 'A form layout'
- *     } );
- *     fieldset.addItems( [
- *         new OO.ui.FieldLayout( input1, {
- *             label: 'Username',
- *             align: 'top'
- *         } ),
- *         new OO.ui.FieldLayout( input2, {
- *             label: 'Password',
- *             align: 'top'
- *         } ),
- *         new OO.ui.FieldLayout( submit )
- *     ] );
- *     var form = new OO.ui.FormLayout( {
- *         items: [ fieldset ],
- *         action: '/api/formhandler',
- *         method: 'get'
- *     } )
- *     $( 'body' ).append( form.$element );
- *
- * @class
- * @extends OO.ui.Layout
- * @mixins OO.ui.mixin.GroupElement
- *
- * @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
- * @cfg {OO.ui.FieldsetLayout[]} [items] Fieldset layouts to add to the form layout.
- */
-OO.ui.FormLayout = function OoUiFormLayout( config ) {
-       var action;
-
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.FormLayout.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
-
-       // Events
-       this.$element.on( 'submit', this.onFormSubmit.bind( this ) );
-
-       // Make sure the action is safe
-       action = config.action;
-       if ( action !== undefined && !OO.ui.isSafeUrl( action ) ) {
-               action = './' + action;
-       }
-
-       // Initialization
-       this.$element
-               .addClass( 'oo-ui-formLayout' )
-               .attr( {
-                       method: config.method,
-                       action: action,
-                       enctype: config.enctype
-               } );
-       if ( Array.isArray( config.items ) ) {
-               this.addItems( config.items );
-       }
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.FormLayout, OO.ui.Layout );
-OO.mixinClass( OO.ui.FormLayout, OO.ui.mixin.GroupElement );
-
-/* Events */
-
-/**
- * A 'submit' event is emitted when the form is submitted.
- *
- * @event submit
- */
-
-/* Static Properties */
-
-OO.ui.FormLayout.static.tagName = 'form';
-
-/* Methods */
-
-/**
- * Handle form submit events.
- *
- * @private
- * @param {jQuery.Event} e Submit event
- * @fires submit
- */
-OO.ui.FormLayout.prototype.onFormSubmit = function () {
-       if ( this.emit( 'submit' ) ) {
-               return false;
-       }
-};
-
-/**
- * PanelLayouts expand to cover the entire area of their parent. They can be configured with scrolling, padding,
- * and a frame, and are often used together with {@link OO.ui.StackLayout StackLayouts}.
- *
- *     @example
- *     // Example of a panel layout
- *     var panel = new OO.ui.PanelLayout( {
- *         expanded: false,
- *         framed: true,
- *         padded: true,
- *         $content: $( '<p>A panel layout with padding and a frame.</p>' )
- *     } );
- *     $( 'body' ).append( panel.$element );
- *
- * @class
- * @extends OO.ui.Layout
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {boolean} [scrollable=false] Allow vertical scrolling
- * @cfg {boolean} [padded=false] Add padding between the content and the edges of the panel.
- * @cfg {boolean} [expanded=true] Expand the panel to fill the entire parent element.
- * @cfg {boolean} [framed=false] Render the panel with a frame to visually separate it from outside content.
- */
-OO.ui.PanelLayout = function OoUiPanelLayout( config ) {
-       // Configuration initialization
-       config = $.extend( {
-               scrollable: false,
-               padded: false,
-               expanded: true,
-               framed: false
-       }, config );
-
-       // Parent constructor
-       OO.ui.PanelLayout.parent.call( this, config );
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-panelLayout' );
-       if ( config.scrollable ) {
-               this.$element.addClass( 'oo-ui-panelLayout-scrollable' );
-       }
-       if ( config.padded ) {
-               this.$element.addClass( 'oo-ui-panelLayout-padded' );
-       }
-       if ( config.expanded ) {
-               this.$element.addClass( 'oo-ui-panelLayout-expanded' );
-       }
-       if ( config.framed ) {
-               this.$element.addClass( 'oo-ui-panelLayout-framed' );
-       }
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.PanelLayout, OO.ui.Layout );
-
-/* Methods */
-
-/**
- * Focus the panel layout
- *
- * The default implementation just focuses the first focusable element in the panel
- */
-OO.ui.PanelLayout.prototype.focus = function () {
-       OO.ui.findFocusable( this.$element ).focus();
-};
-
-/**
- * HorizontalLayout arranges its contents in a single line (using `display: inline-block` for its
- * items), with small margins between them. Convenient when you need to put a number of block-level
- * widgets on a single line next to each other.
- *
- * Note that inline elements, such as OO.ui.ButtonWidgets, do not need this wrapper.
- *
- *     @example
- *     // HorizontalLayout with a text input and a label
- *     var layout = new OO.ui.HorizontalLayout( {
- *       items: [
- *         new OO.ui.LabelWidget( { label: 'Label' } ),
- *         new OO.ui.TextInputWidget( { value: 'Text' } )
- *       ]
- *     } );
- *     $( 'body' ).append( layout.$element );
- *
- * @class
- * @extends OO.ui.Layout
- * @mixins OO.ui.mixin.GroupElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {OO.ui.Widget[]|OO.ui.Layout[]} [items] Widgets or other layouts to add to the layout.
- */
-OO.ui.HorizontalLayout = function OoUiHorizontalLayout( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.HorizontalLayout.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-horizontalLayout' );
-       if ( Array.isArray( config.items ) ) {
-               this.addItems( config.items );
-       }
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
-OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
-
-}( OO ) );
-
-/*!
- * OOjs UI v0.15.2
- * https://www.mediawiki.org/wiki/OOjs_UI
- *
- * Copyright 2011–2016 OOjs UI Team and other contributors.
- * Released under the MIT license
- * http://oojs.mit-license.org
- *
- * Date: 2016-02-02T22:07:00Z
- */
-( function ( OO ) {
-
-'use strict';
-
-/**
- * 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.mixin.DraggableGroupElement, which provides a container for
- * the draggable elements.
- *
- * @abstract
- * @class
- *
- * @constructor
- */
-OO.ui.mixin.DraggableElement = function OoUiMixinDraggableElement() {
-       // Properties
-       this.index = null;
-
-       // Initialize and events
-       this.$element
-               .attr( 'draggable', true )
-               .addClass( 'oo-ui-draggableElement' )
-               .on( {
-                       dragstart: this.onDragStart.bind( this ),
-                       dragover: this.onDragOver.bind( this ),
-                       dragend: this.onDragEnd.bind( this ),
-                       drop: this.onDrop.bind( this )
-               } );
-};
-
-OO.initClass( OO.ui.mixin.DraggableElement );
-
-/* Events */
-
-/**
- * @event dragstart
- *
- * A dragstart event is emitted when the user clicks and begins dragging an item.
- * @param {OO.ui.mixin.DraggableElement} item The item the user has clicked and is dragging with the mouse.
- */
-
-/**
- * @event dragend
- * A dragend event is emitted when the user drags an item and releases the mouse,
- * thus terminating the drag operation.
- */
-
-/**
- * @event drop
- * A drop event is emitted when the user drags an item and then releases the mouse button
- * over a valid target.
- */
-
-/* Static Properties */
-
-/**
- * @inheritdoc OO.ui.mixin.ButtonElement
- */
-OO.ui.mixin.DraggableElement.static.cancelButtonMouseDownEvents = false;
-
-/* Methods */
-
-/**
- * Respond to dragstart event.
- *
- * @private
- * @param {jQuery.Event} event jQuery event
- * @fires dragstart
- */
-OO.ui.mixin.DraggableElement.prototype.onDragStart = function ( e ) {
-       var dataTransfer = e.originalEvent.dataTransfer;
-       // Define drop effect
-       dataTransfer.dropEffect = 'none';
-       dataTransfer.effectAllowed = 'move';
-       // Support: Firefox
-       // We must set up a dataTransfer data property or Firefox seems to
-       // ignore the fact the element is draggable.
-       try {
-               dataTransfer.setData( 'application-x/OOjs-UI-draggable', this.getIndex() );
-       } catch ( err ) {
-               // The above is only for Firefox. Move on if it fails.
-       }
-       // Add dragging class
-       this.$element.addClass( 'oo-ui-draggableElement-dragging' );
-       // Emit event
-       this.emit( 'dragstart', this );
-       return true;
-};
-
-/**
- * Respond to dragend event.
- *
- * @private
- * @fires dragend
- */
-OO.ui.mixin.DraggableElement.prototype.onDragEnd = function () {
-       this.$element.removeClass( 'oo-ui-draggableElement-dragging' );
-       this.emit( 'dragend' );
-};
-
-/**
- * Handle drop event.
- *
- * @private
- * @param {jQuery.Event} event jQuery event
- * @fires drop
- */
-OO.ui.mixin.DraggableElement.prototype.onDrop = function ( e ) {
-       e.preventDefault();
-       this.emit( 'drop', e );
-};
-
-/**
- * In order for drag/drop to work, the dragover event must
- * return false and stop propogation.
- *
- * @private
- */
-OO.ui.mixin.DraggableElement.prototype.onDragOver = function ( e ) {
-       e.preventDefault();
-};
-
-/**
- * Set item index.
- * Store it in the DOM so we can access from the widget drag event
- *
- * @private
- * @param {number} Item index
- */
-OO.ui.mixin.DraggableElement.prototype.setIndex = function ( index ) {
-       if ( this.index !== index ) {
-               this.index = index;
-               this.$element.data( 'index', index );
-       }
-};
-
-/**
- * Get item index
- *
- * @private
- * @return {number} Item index
- */
-OO.ui.mixin.DraggableElement.prototype.getIndex = function () {
-       return this.index;
-};
-
-/**
- * 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.mixin.DraggableElement.
- *
- * @abstract
- * @class
- * @mixins OO.ui.mixin.GroupElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {string} [orientation] Item orientation: 'horizontal' or 'vertical'. The orientation
- *  should match the layout of the items. Items displayed in a single row
- *  or in several rows should use horizontal orientation. The vertical orientation should only be
- *  used when the items are displayed in a single column. Defaults to 'vertical'
- */
-OO.ui.mixin.DraggableGroupElement = function OoUiMixinDraggableGroupElement( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.mixin.GroupElement.call( this, config );
-
-       // Properties
-       this.orientation = config.orientation || 'vertical';
-       this.dragItem = null;
-       this.itemDragOver = null;
-       this.itemKeys = {};
-       this.sideInsertion = '';
-
-       // Events
-       this.aggregate( {
-               dragstart: 'itemDragStart',
-               dragend: 'itemDragEnd',
-               drop: 'itemDrop'
-       } );
-       this.connect( this, {
-               itemDragStart: 'onItemDragStart',
-               itemDrop: 'onItemDrop',
-               itemDragEnd: 'onItemDragEnd'
-       } );
-       this.$element.on( {
-               dragover: this.onDragOver.bind( this ),
-               dragleave: this.onDragLeave.bind( this )
-       } );
-
-       // Initialize
-       if ( Array.isArray( config.items ) ) {
-               this.addItems( config.items );
-       }
-       this.$placeholder = $( '<div>' )
-               .addClass( 'oo-ui-draggableGroupElement-placeholder' );
-       this.$element
-               .addClass( 'oo-ui-draggableGroupElement' )
-               .append( this.$status )
-               .toggleClass( 'oo-ui-draggableGroupElement-horizontal', this.orientation === 'horizontal' )
-               .prepend( this.$placeholder );
-};
-
-/* Setup */
-OO.mixinClass( OO.ui.mixin.DraggableGroupElement, OO.ui.mixin.GroupElement );
-
-/* Events */
-
-/**
- * A 'reorder' event is emitted when the order of items in the group changes.
- *
- * @event reorder
- * @param {OO.ui.mixin.DraggableElement} item Reordered item
- * @param {number} [newIndex] New index for the item
- */
-
-/* Methods */
-
-/**
- * Respond to item drag start event
- *
- * @private
- * @param {OO.ui.mixin.DraggableElement} item Dragged item
- */
-OO.ui.mixin.DraggableGroupElement.prototype.onItemDragStart = function ( item ) {
-       var i, len;
-
-       // Map the index of each object
-       for ( i = 0, len = this.items.length; i < len; i++ ) {
-               this.items[ i ].setIndex( i );
-       }
-
-       if ( this.orientation === 'horizontal' ) {
-               // Set the height of the indicator
-               this.$placeholder.css( {
-                       height: item.$element.outerHeight(),
-                       width: 2
-               } );
-       } else {
-               // Set the width of the indicator
-               this.$placeholder.css( {
-                       height: 2,
-                       width: item.$element.outerWidth()
-               } );
-       }
-       this.setDragItem( item );
-};
-
-/**
- * Respond to item drag end event
- *
- * @private
- */
-OO.ui.mixin.DraggableGroupElement.prototype.onItemDragEnd = function () {
-       this.unsetDragItem();
-       return false;
-};
-
-/**
- * Handle drop event and switch the order of the items accordingly
- *
- * @private
- * @param {OO.ui.mixin.DraggableElement} item Dropped item
- * @fires reorder
- */
-OO.ui.mixin.DraggableGroupElement.prototype.onItemDrop = function ( item ) {
-       var toIndex = item.getIndex();
-       // Check if the dropped item is from the current group
-       // TODO: Figure out a way to configure a list of legally droppable
-       // elements even if they are not yet in the list
-       if ( this.getDragItem() ) {
-               // If the insertion point is 'after', the insertion index
-               // is shifted to the right (or to the left in RTL, hence 'after')
-               if ( this.sideInsertion === 'after' ) {
-                       toIndex++;
-               }
-               // Emit change event
-               this.emit( 'reorder', this.getDragItem(), toIndex );
-       }
-       this.unsetDragItem();
-       // Return false to prevent propogation
-       return false;
-};
-
-/**
- * Handle dragleave event.
- *
- * @private
- */
-OO.ui.mixin.DraggableGroupElement.prototype.onDragLeave = function () {
-       // This means the item was dragged outside the widget
-       this.$placeholder
-               .css( 'left', 0 )
-               .addClass( 'oo-ui-element-hidden' );
-};
-
-/**
- * Respond to dragover event
- *
- * @private
- * @param {jQuery.Event} event Event details
- */
-OO.ui.mixin.DraggableGroupElement.prototype.onDragOver = function ( e ) {
-       var dragOverObj, $optionWidget, itemOffset, itemMidpoint, itemBoundingRect,
-               itemSize, cssOutput, dragPosition, itemIndex, itemPosition,
-               clientX = e.originalEvent.clientX,
-               clientY = e.originalEvent.clientY;
-
-       // Get the OptionWidget item we are dragging over
-       dragOverObj = this.getElementDocument().elementFromPoint( clientX, clientY );
-       $optionWidget = $( dragOverObj ).closest( '.oo-ui-draggableElement' );
-       if ( $optionWidget[ 0 ] ) {
-               itemOffset = $optionWidget.offset();
-               itemBoundingRect = $optionWidget[ 0 ].getBoundingClientRect();
-               itemPosition = $optionWidget.position();
-               itemIndex = $optionWidget.data( 'index' );
-       }
-
-       if (
-               itemOffset &&
-               this.isDragging() &&
-               itemIndex !== this.getDragItem().getIndex()
-       ) {
-               if ( this.orientation === 'horizontal' ) {
-                       // Calculate where the mouse is relative to the item width
-                       itemSize = itemBoundingRect.width;
-                       itemMidpoint = itemBoundingRect.left + itemSize / 2;
-                       dragPosition = clientX;
-                       // Which side of the item we hover over will dictate
-                       // where the placeholder will appear, on the left or
-                       // on the right
-                       cssOutput = {
-                               left: dragPosition < itemMidpoint ? itemPosition.left : itemPosition.left + itemSize,
-                               top: itemPosition.top
-                       };
-               } else {
-                       // Calculate where the mouse is relative to the item height
-                       itemSize = itemBoundingRect.height;
-                       itemMidpoint = itemBoundingRect.top + itemSize / 2;
-                       dragPosition = clientY;
-                       // Which side of the item we hover over will dictate
-                       // where the placeholder will appear, on the top or
-                       // on the bottom
-                       cssOutput = {
-                               top: dragPosition < itemMidpoint ? itemPosition.top : itemPosition.top + itemSize,
-                               left: itemPosition.left
-                       };
-               }
-               // Store whether we are before or after an item to rearrange
-               // For horizontal layout, we need to account for RTL, as this is flipped
-               if (  this.orientation === 'horizontal' && this.$element.css( 'direction' ) === 'rtl' ) {
-                       this.sideInsertion = dragPosition < itemMidpoint ? 'after' : 'before';
-               } else {
-                       this.sideInsertion = dragPosition < itemMidpoint ? 'before' : 'after';
-               }
-               // Add drop indicator between objects
-               this.$placeholder
-                       .css( cssOutput )
-                       .removeClass( 'oo-ui-element-hidden' );
-       } else {
-               // This means the item was dragged outside the widget
-               this.$placeholder
-                       .css( 'left', 0 )
-                       .addClass( 'oo-ui-element-hidden' );
-       }
-       // Prevent default
-       e.preventDefault();
-};
-
-/**
- * Set a dragged item
- *
- * @param {OO.ui.mixin.DraggableElement} item Dragged item
- */
-OO.ui.mixin.DraggableGroupElement.prototype.setDragItem = function ( item ) {
-       this.dragItem = item;
-};
-
-/**
- * Unset the current dragged item
- */
-OO.ui.mixin.DraggableGroupElement.prototype.unsetDragItem = function () {
-       this.dragItem = null;
-       this.itemDragOver = null;
-       this.$placeholder.addClass( 'oo-ui-element-hidden' );
-       this.sideInsertion = '';
-};
-
-/**
- * Get the item that is currently being dragged.
- *
- * @return {OO.ui.mixin.DraggableElement|null} The currently dragged item, or `null` if no item is being dragged
- */
-OO.ui.mixin.DraggableGroupElement.prototype.getDragItem = function () {
-       return this.dragItem;
-};
-
-/**
- * Check if an item in the group is currently being dragged.
- *
- * @return {Boolean} Item is being dragged
- */
-OO.ui.mixin.DraggableGroupElement.prototype.isDragging = function () {
-       return this.getDragItem() !== null;
-};
-
-/**
- * RequestManager is a mixin that manages the lifecycle of a promise-backed request for a widget, such as
- * the {@link OO.ui.mixin.LookupElement}.
- *
- * @class
- * @abstract
- *
- * @constructor
- */
-OO.ui.mixin.RequestManager = function OoUiMixinRequestManager() {
-       this.requestCache = {};
-       this.requestQuery = null;
-       this.requestRequest = null;
-};
-
-/* Setup */
-
-OO.initClass( OO.ui.mixin.RequestManager );
-
-/**
- * Get request results for the current query.
- *
- * @return {jQuery.Promise} Promise object which will be passed response data as the first argument of
- *   the done event. If the request was aborted to make way for a subsequent request, this promise
- *   may not be rejected, depending on what jQuery feels like doing.
- */
-OO.ui.mixin.RequestManager.prototype.getRequestData = function () {
-       var widget = this,
-               value = this.getRequestQuery(),
-               deferred = $.Deferred(),
-               ourRequest;
-
-       this.abortRequest();
-       if ( Object.prototype.hasOwnProperty.call( this.requestCache, value ) ) {
-               deferred.resolve( this.requestCache[ value ] );
-       } else {
-               if ( this.pushPending ) {
-                       this.pushPending();
-               }
-               this.requestQuery = value;
-               ourRequest = this.requestRequest = this.getRequest();
-               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.
-                               if ( widget.popPending ) {
-                                       widget.popPending();
-                               }
-                       } )
-                       .done( function ( response ) {
-                               // If this is an old request (and aborting it somehow caused it to still succeed),
-                               // ignore its success completely
-                               if ( ourRequest === widget.requestRequest ) {
-                                       widget.requestQuery = null;
-                                       widget.requestRequest = null;
-                                       widget.requestCache[ value ] = widget.getRequestCacheDataFromResponse( response );
-                                       deferred.resolve( widget.requestCache[ value ] );
-                               }
-                       } )
-                       .fail( function () {
-                               // If this is an old request (or a request failing because it's being aborted),
-                               // ignore its failure completely
-                               if ( ourRequest === widget.requestRequest ) {
-                                       widget.requestQuery = null;
-                                       widget.requestRequest = null;
-                                       deferred.reject();
-                               }
-                       } );
-       }
-       return deferred.promise();
-};
-
-/**
- * Abort the currently pending request, if any.
- *
- * @private
- */
-OO.ui.mixin.RequestManager.prototype.abortRequest = function () {
-       var oldRequest = this.requestRequest;
-       if ( oldRequest ) {
-               // First unset this.requestRequest to the fail handler will notice
-               // that the request is no longer current
-               this.requestRequest = null;
-               this.requestQuery = null;
-               oldRequest.abort();
-       }
-};
-
-/**
- * Get the query to be made.
- *
- * @protected
- * @method
- * @abstract
- * @return {string} query to be used
- */
-OO.ui.mixin.RequestManager.prototype.getRequestQuery = null;
-
-/**
- * Get a new request object of the current query value.
- *
- * @protected
- * @method
- * @abstract
- * @return {jQuery.Promise} jQuery AJAX object, or promise object with an .abort() method
- */
-OO.ui.mixin.RequestManager.prototype.getRequest = null;
-
-/**
- * Pre-process data returned by the request from #getRequest.
- *
- * 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.
- *
- * @protected
- * @method
- * @abstract
- * @param {Mixed} response Response from server
- * @return {Mixed} Cached result data
- */
-OO.ui.mixin.RequestManager.prototype.getRequestCacheDataFromResponse = null;
-
-/**
- * LookupElement is a mixin that creates a {@link OO.ui.FloatingMenuSelectWidget menu} of suggested values for
- * a {@link OO.ui.TextInputWidget text input widget}. Suggested values are based on the characters the user types
- * into the text input field and, in general, the menu is only displayed when the user types. If a suggested value is chosen
- * from the lookup menu, that value becomes the value of the input field.
- *
- * Note that a new menu of suggested items is displayed when a value is chosen from the lookup menu. If this is
- * not the desired behavior, disable lookup menus with the #setLookupsDisabled method, then set the value, then
- * re-enable lookups.
- *
- * See the [OOjs UI demos][1] for an example.
- *
- * [1]: https://tools.wmflabs.org/oojs-ui/oojs-ui/demos/index.html#widgets-apex-vector-ltr
- *
- * @class
- * @abstract
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {jQuery} [$overlay] Overlay for the lookup menu; defaults to relative positioning
- * @cfg {jQuery} [$container=this.$element] The container element. The lookup menu is rendered beneath the specified element.
- * @cfg {boolean} [allowSuggestionsWhenEmpty=false] Request and display a lookup menu when the text input is empty.
- *  By default, the lookup menu is not generated and displayed until the user begins to type.
- * @cfg {boolean} [highlightFirst=true] Whether the first lookup result should be highlighted (so, that the user can
- *  take it over into the input with simply pressing return) automatically or not.
- */
-OO.ui.mixin.LookupElement = function OoUiMixinLookupElement( config ) {
-       // Configuration initialization
-       config = $.extend( { highlightFirst: true }, config );
-
-       // Mixin constructors
-       OO.ui.mixin.RequestManager.call( this, config );
-
-       // Properties
-       this.$overlay = config.$overlay || this.$element;
-       this.lookupMenu = new OO.ui.FloatingMenuSelectWidget( {
-               widget: this,
-               input: this,
-               $container: config.$container || this.$element
-       } );
-
-       this.allowSuggestionsWhenEmpty = config.allowSuggestionsWhenEmpty || false;
-
-       this.lookupsDisabled = false;
-       this.lookupInputFocused = false;
-       this.lookupHighlightFirstItem = config.highlightFirst;
-
-       // 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 );
-};
-
-/* Setup */
-
-OO.mixinClass( OO.ui.mixin.LookupElement, OO.ui.mixin.RequestManager );
-
-/* Methods */
-
-/**
- * Handle input focus event.
- *
- * @protected
- * @param {jQuery.Event} e Input focus event
- */
-OO.ui.mixin.LookupElement.prototype.onLookupInputFocus = function () {
-       this.lookupInputFocused = true;
-       this.populateLookupMenu();
-};
-
-/**
- * Handle input blur event.
- *
- * @protected
- * @param {jQuery.Event} e Input blur event
- */
-OO.ui.mixin.LookupElement.prototype.onLookupInputBlur = function () {
-       this.closeLookupMenu();
-       this.lookupInputFocused = false;
-};
-
-/**
- * Handle input mouse down event.
- *
- * @protected
- * @param {jQuery.Event} e Input mouse down event
- */
-OO.ui.mixin.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();
-       }
-};
-
-/**
- * Handle input change event.
- *
- * @protected
- * @param {string} value New input value
- */
-OO.ui.mixin.LookupElement.prototype.onLookupInputChange = function () {
-       if ( this.lookupInputFocused ) {
-               this.populateLookupMenu();
-       }
-};
-
-/**
- * Handle the lookup menu being shown/hidden.
- *
- * @protected
- * @param {boolean} visible Whether the lookup menu is now visible.
- */
-OO.ui.mixin.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();
-       }
-};
-
-/**
- * Handle menu item 'choose' event, updating the text input value to the value of the clicked item.
- *
- * @protected
- * @param {OO.ui.MenuOptionWidget} item Selected item
- */
-OO.ui.mixin.LookupElement.prototype.onLookupMenuItemChoose = function ( item ) {
-       this.setValue( item.getData() );
-};
-
-/**
- * Get lookup menu.
- *
- * @private
- * @return {OO.ui.FloatingMenuSelectWidget}
- */
-OO.ui.mixin.LookupElement.prototype.getLookupMenu = function () {
-       return this.lookupMenu;
-};
-
-/**
- * Disable or re-enable lookups.
- *
- * When lookups are disabled, calls to #populateLookupMenu will be ignored.
- *
- * @param {boolean} disabled Disable lookups
- */
-OO.ui.mixin.LookupElement.prototype.setLookupsDisabled = function ( disabled ) {
-       this.lookupsDisabled = !!disabled;
-};
-
-/**
- * Open the menu. If there are no entries in the menu, this does nothing.
- *
- * @private
- * @chainable
- */
-OO.ui.mixin.LookupElement.prototype.openLookupMenu = function () {
-       if ( !this.lookupMenu.isEmpty() ) {
-               this.lookupMenu.toggle( true );
-       }
-       return this;
-};
-
-/**
- * Close the menu, empty it, and abort any pending request.
- *
- * @private
- * @chainable
- */
-OO.ui.mixin.LookupElement.prototype.closeLookupMenu = function () {
-       this.lookupMenu.toggle( false );
-       this.abortLookupRequest();
-       this.lookupMenu.clearItems();
-       return this;
-};
-
-/**
- * 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.
- *
- * @private
- * @chainable
- */
-OO.ui.mixin.LookupElement.prototype.populateLookupMenu = function () {
-       var widget = this,
-               value = this.getValue();
-
-       if ( this.lookupsDisabled || this.isReadOnly() ) {
-               return;
-       }
-
-       // If the input is empty, clear the menu, unless suggestions when empty are allowed.
-       if ( !this.allowSuggestionsWhenEmpty && 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;
-};
-
-/**
- * Highlight the first selectable item in the menu, if configured.
- *
- * @private
- * @chainable
- */
-OO.ui.mixin.LookupElement.prototype.initializeLookupMenuSelection = function () {
-       if ( this.lookupHighlightFirstItem && !this.lookupMenu.getSelectedItem() ) {
-               this.lookupMenu.highlightItem( this.lookupMenu.getFirstSelectableItem() );
-       }
-};
-
-/**
- * Get lookup menu items for the current query.
- *
- * @private
- * @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.mixin.LookupElement.prototype.getLookupMenuItems = function () {
-       return this.getRequestData().then( function ( data ) {
-               return this.getLookupMenuOptionsFromData( data );
-       }.bind( this ) );
-};
-
-/**
- * Abort the currently pending lookup request, if any.
- *
- * @private
- */
-OO.ui.mixin.LookupElement.prototype.abortLookupRequest = function () {
-       this.abortRequest();
-};
-
-/**
- * Get a new request object of the current lookup query value.
- *
- * @protected
- * @method
- * @abstract
- * @return {jQuery.Promise} jQuery AJAX object, or promise object with an .abort() method
- */
-OO.ui.mixin.LookupElement.prototype.getLookupRequest = null;
-
-/**
- * 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.
- *
- * @protected
- * @method
- * @abstract
- * @param {Mixed} response Response from server
- * @return {Mixed} Cached result data
- */
-OO.ui.mixin.LookupElement.prototype.getLookupCacheDataFromResponse = null;
-
-/**
- * Get a list of menu option widgets from the (possibly cached) data returned by
- * #getLookupCacheDataFromResponse.
- *
- * @protected
- * @method
- * @abstract
- * @param {Mixed} data Cached result data, usually an array
- * @return {OO.ui.MenuOptionWidget[]} Menu items
- */
-OO.ui.mixin.LookupElement.prototype.getLookupMenuOptionsFromData = null;
-
-/**
- * Set the read-only state of the widget.
- *
- * This will also disable/enable the lookups functionality.
- *
- * @param {boolean} readOnly Make input read-only
- * @chainable
- */
-OO.ui.mixin.LookupElement.prototype.setReadOnly = function ( readOnly ) {
-       // Parent method
-       // Note: Calling #setReadOnly this way assumes this is mixed into an OO.ui.TextInputWidget
-       OO.ui.TextInputWidget.prototype.setReadOnly.call( this, readOnly );
-
-       // During construction, #setReadOnly is called before the OO.ui.mixin.LookupElement constructor
-       if ( this.isReadOnly() && this.lookupMenu ) {
-               this.closeLookupMenu();
-       }
-
-       return this;
-};
-
-/**
- * @inheritdoc OO.ui.mixin.RequestManager
- */
-OO.ui.mixin.LookupElement.prototype.getRequestQuery = function () {
-       return this.getValue();
-};
-
-/**
- * @inheritdoc OO.ui.mixin.RequestManager
- */
-OO.ui.mixin.LookupElement.prototype.getRequest = function () {
-       return this.getLookupRequest();
-};
-
-/**
- * @inheritdoc OO.ui.mixin.RequestManager
- */
-OO.ui.mixin.LookupElement.prototype.getRequestCacheDataFromResponse = function ( response ) {
-       return this.getLookupCacheDataFromResponse( response );
-};
-
-/**
- * CardLayouts are used within {@link OO.ui.IndexLayout index layouts} to create cards that users can select and display
- * from the index's optional {@link OO.ui.TabSelectWidget tab} navigation. Cards are usually not instantiated directly,
- * rather extended to include the required content and functionality.
- *
- * Each card must have a unique symbolic name, which is passed to the constructor. In addition, the card's tab
- * item is customized (with a label) using the #setupTabItem method. See
- * {@link OO.ui.IndexLayout IndexLayout} for an example.
- *
- * @class
- * @extends OO.ui.PanelLayout
- *
- * @constructor
- * @param {string} name Unique symbolic name of card
- * @param {Object} [config] Configuration options
- * @cfg {jQuery|string|Function|OO.ui.HtmlSnippet} [label] Label for card's tab
- */
-OO.ui.CardLayout = function OoUiCardLayout( name, config ) {
-       // Allow passing positional parameters inside the config object
-       if ( OO.isPlainObject( name ) && config === undefined ) {
-               config = name;
-               name = config.name;
-       }
-
-       // Configuration initialization
-       config = $.extend( { scrollable: true }, config );
-
-       // Parent constructor
-       OO.ui.CardLayout.parent.call( this, config );
-
-       // Properties
-       this.name = name;
-       this.label = config.label;
-       this.tabItem = null;
-       this.active = false;
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-cardLayout' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.CardLayout, OO.ui.PanelLayout );
-
-/* Events */
-
-/**
- * An 'active' event is emitted when the card becomes active. Cards become active when they are
- * shown in a index layout that is configured to display only one card at a time.
- *
- * @event active
- * @param {boolean} active Card is active
- */
-
-/* Methods */
-
-/**
- * Get the symbolic name of the card.
- *
- * @return {string} Symbolic name of card
- */
-OO.ui.CardLayout.prototype.getName = function () {
-       return this.name;
-};
-
-/**
- * Check if card is active.
- *
- * Cards become active when they are shown in a {@link OO.ui.IndexLayout index layout} that is configured to display
- * only one card at a time. Additional CSS is applied to the card's tab item to reflect the active state.
- *
- * @return {boolean} Card is active
- */
-OO.ui.CardLayout.prototype.isActive = function () {
-       return this.active;
-};
-
-/**
- * Get tab item.
- *
- * The tab item allows users to access the card from the index's tab
- * navigation. The tab item itself can be customized (with a label, level, etc.) using the #setupTabItem method.
- *
- * @return {OO.ui.TabOptionWidget|null} Tab option widget
- */
-OO.ui.CardLayout.prototype.getTabItem = function () {
-       return this.tabItem;
-};
-
-/**
- * Set or unset the tab item.
- *
- * Specify a {@link OO.ui.TabOptionWidget tab option} to set it,
- * or `null` to clear the tab item. To customize the tab item itself (e.g., to set a label or tab
- * level), use #setupTabItem instead of this method.
- *
- * @param {OO.ui.TabOptionWidget|null} tabItem Tab option widget, null to clear
- * @chainable
- */
-OO.ui.CardLayout.prototype.setTabItem = function ( tabItem ) {
-       this.tabItem = tabItem || null;
-       if ( tabItem ) {
-               this.setupTabItem();
-       }
-       return this;
-};
-
-/**
- * Set up the tab item.
- *
- * Use this method to customize the tab item (e.g., to add a label or tab level). To set or unset
- * the tab item itself (with a {@link OO.ui.TabOptionWidget tab option} or `null`), use
- * the #setTabItem method instead.
- *
- * @param {OO.ui.TabOptionWidget} tabItem Tab option widget to set up
- * @chainable
- */
-OO.ui.CardLayout.prototype.setupTabItem = function () {
-       if ( this.label ) {
-               this.tabItem.setLabel( this.label );
-       }
-       return this;
-};
-
-/**
- * Set the card to its 'active' state.
- *
- * Cards become active when they are shown in a index layout that is configured to display only one card at a time. Additional
- * CSS is applied to the tab item to reflect the card's active state. Outside of the index
- * context, setting the active state on a card does nothing.
- *
- * @param {boolean} value Card is active
- * @fires active
- */
-OO.ui.CardLayout.prototype.setActive = function ( active ) {
-       active = !!active;
-
-       if ( active !== this.active ) {
-               this.active = active;
-               this.$element.toggleClass( 'oo-ui-cardLayout-active', this.active );
-               this.emit( 'active', this.active );
-       }
-};
-
-/**
- * PageLayouts are used within {@link OO.ui.BookletLayout booklet layouts} to create pages that users can select and display
- * from the booklet's optional {@link OO.ui.OutlineSelectWidget outline} navigation. Pages are usually not instantiated directly,
- * rather extended to include the required content and functionality.
- *
- * Each page must have a unique symbolic name, which is passed to the constructor. In addition, the page's outline
- * item is customized (with a label, outline level, etc.) using the #setupOutlineItem method. See
- * {@link OO.ui.BookletLayout BookletLayout} for an example.
- *
- * @class
- * @extends OO.ui.PanelLayout
- *
- * @constructor
- * @param {string} name Unique symbolic name of page
- * @param {Object} [config] Configuration options
- */
-OO.ui.PageLayout = function OoUiPageLayout( name, config ) {
-       // Allow passing positional parameters inside the config object
-       if ( OO.isPlainObject( name ) && config === undefined ) {
-               config = name;
-               name = config.name;
-       }
-
-       // Configuration initialization
-       config = $.extend( { scrollable: true }, config );
-
-       // Parent constructor
-       OO.ui.PageLayout.parent.call( this, config );
-
-       // Properties
-       this.name = name;
-       this.outlineItem = null;
-       this.active = false;
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-pageLayout' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.PageLayout, OO.ui.PanelLayout );
-
-/* Events */
-
-/**
- * An 'active' event is emitted when the page becomes active. Pages become active when they are
- * shown in a booklet layout that is configured to display only one page at a time.
- *
- * @event active
- * @param {boolean} active Page is active
- */
-
-/* Methods */
-
-/**
- * Get the symbolic name of the page.
- *
- * @return {string} Symbolic name of page
- */
-OO.ui.PageLayout.prototype.getName = function () {
-       return this.name;
-};
-
-/**
- * Check if page is active.
- *
- * Pages become active when they are shown in a {@link OO.ui.BookletLayout booklet layout} that is configured to display
- * only one page at a time. Additional CSS is applied to the page's outline item to reflect the active state.
- *
- * @return {boolean} Page is active
- */
-OO.ui.PageLayout.prototype.isActive = function () {
-       return this.active;
-};
-
-/**
- * Get outline item.
- *
- * The outline item allows users to access the page from the booklet's outline
- * navigation. The outline item itself can be customized (with a label, level, etc.) using the #setupOutlineItem method.
- *
- * @return {OO.ui.OutlineOptionWidget|null} Outline option widget
- */
-OO.ui.PageLayout.prototype.getOutlineItem = function () {
-       return this.outlineItem;
-};
-
-/**
- * Set or unset the outline item.
- *
- * Specify an {@link OO.ui.OutlineOptionWidget outline option} to set it,
- * or `null` to clear the outline item. To customize the outline item itself (e.g., to set a label or outline
- * level), use #setupOutlineItem instead of this method.
- *
- * @param {OO.ui.OutlineOptionWidget|null} outlineItem Outline option widget, null to clear
- * @chainable
- */
-OO.ui.PageLayout.prototype.setOutlineItem = function ( outlineItem ) {
-       this.outlineItem = outlineItem || null;
-       if ( outlineItem ) {
-               this.setupOutlineItem();
-       }
-       return this;
-};
-
-/**
- * Set up the outline item.
- *
- * Use this method to customize the outline item (e.g., to add a label or outline level). To set or unset
- * the outline item itself (with an {@link OO.ui.OutlineOptionWidget outline option} or `null`), use
- * the #setOutlineItem method instead.
- *
- * @param {OO.ui.OutlineOptionWidget} outlineItem Outline option widget to set up
- * @chainable
- */
-OO.ui.PageLayout.prototype.setupOutlineItem = function () {
-       return this;
-};
-
-/**
- * Set the page to its 'active' state.
- *
- * Pages become active when they are shown in a booklet layout that is configured to display only one page at a time. Additional
- * CSS is applied to the outline item to reflect the page's active state. Outside of the booklet
- * context, setting the active state on a page does nothing.
- *
- * @param {boolean} value Page is active
- * @fires active
- */
-OO.ui.PageLayout.prototype.setActive = function ( active ) {
-       active = !!active;
-
-       if ( active !== this.active ) {
-               this.active = active;
-               this.$element.toggleClass( 'oo-ui-pageLayout-active', active );
-               this.emit( 'active', this.active );
-       }
-};
-
-/**
- * StackLayouts contain a series of {@link OO.ui.PanelLayout panel layouts}. By default, only one panel is displayed
- * at a time, though the stack layout can also be configured to show all contained panels, one after another,
- * by setting the #continuous option to 'true'.
- *
- *     @example
- *     // A stack layout with two panels, configured to be displayed continously
- *     var myStack = new OO.ui.StackLayout( {
- *         items: [
- *             new OO.ui.PanelLayout( {
- *                 $content: $( '<p>Panel One</p>' ),
- *                 padded: true,
- *                 framed: true
- *             } ),
- *             new OO.ui.PanelLayout( {
- *                 $content: $( '<p>Panel Two</p>' ),
- *                 padded: true,
- *                 framed: true
- *             } )
- *         ],
- *         continuous: true
- *     } );
- *     $( 'body' ).append( myStack.$element );
- *
- * @class
- * @extends OO.ui.PanelLayout
- * @mixins OO.ui.mixin.GroupElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {boolean} [continuous=false] Show all panels, one after another. By default, only one panel is displayed at a time.
- * @cfg {OO.ui.Layout[]} [items] Panel layouts to add to the stack layout.
- */
-OO.ui.StackLayout = function OoUiStackLayout( config ) {
-       // Configuration initialization
-       config = $.extend( { scrollable: true }, config );
-
-       // Parent constructor
-       OO.ui.StackLayout.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.GroupElement.call( this, $.extend( {}, config, { $group: this.$element } ) );
-
-       // Properties
-       this.currentItem = null;
-       this.continuous = !!config.continuous;
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-stackLayout' );
-       if ( this.continuous ) {
-               this.$element.addClass( 'oo-ui-stackLayout-continuous' );
-               this.$element.on( 'scroll', OO.ui.debounce( this.onScroll.bind( this ), 250 ) );
-       }
-       if ( Array.isArray( config.items ) ) {
-               this.addItems( config.items );
-       }
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.StackLayout, OO.ui.PanelLayout );
-OO.mixinClass( OO.ui.StackLayout, OO.ui.mixin.GroupElement );
-
-/* Events */
-
-/**
- * A 'set' event is emitted when panels are {@link #addItems added}, {@link #removeItems removed},
- * {@link #clearItems cleared} or {@link #setItem displayed}.
- *
- * @event set
- * @param {OO.ui.Layout|null} item Current panel or `null` if no panel is shown
- */
-
-/**
- * When used in continuous mode, this event is emitted when the user scrolls down
- * far enough such that currentItem is no longer visible.
- *
- * @event visibleItemChange
- * @param {OO.ui.PanelLayout} panel The next visible item in the layout
- */
-
-/* Methods */
-
-/**
- * Handle scroll events from the layout element
- *
- * @param {jQuery.Event} e
- * @fires visibleItemChange
- */
-OO.ui.StackLayout.prototype.onScroll = function () {
-       var currentRect,
-               len = this.items.length,
-               currentIndex = this.items.indexOf( this.currentItem ),
-               newIndex = currentIndex,
-               containerRect = this.$element[ 0 ].getBoundingClientRect();
-
-       if ( !containerRect || ( !containerRect.top && !containerRect.bottom ) ) {
-               // Can't get bounding rect, possibly not attached.
-               return;
-       }
-
-       function getRect( item ) {
-               return item.$element[ 0 ].getBoundingClientRect();
-       }
-
-       function isVisible( item ) {
-               var rect = getRect( item );
-               return rect.bottom > containerRect.top && rect.top < containerRect.bottom;
-       }
-
-       currentRect = getRect( this.currentItem );
-
-       if ( currentRect.bottom < containerRect.top ) {
-               // Scrolled down past current item
-               while ( ++newIndex < len ) {
-                       if ( isVisible( this.items[ newIndex ] ) ) {
-                               break;
-                       }
-               }
-       } else if ( currentRect.top > containerRect.bottom ) {
-               // Scrolled up past current item
-               while ( --newIndex >= 0 ) {
-                       if ( isVisible( this.items[ newIndex ] ) ) {
-                               break;
-                       }
-               }
-       }
-
-       if ( newIndex !== currentIndex ) {
-               this.emit( 'visibleItemChange', this.items[ newIndex ] );
-       }
-};
-
-/**
- * Get the current panel.
- *
- * @return {OO.ui.Layout|null}
- */
-OO.ui.StackLayout.prototype.getCurrentItem = function () {
-       return this.currentItem;
-};
-
-/**
- * Unset the current item.
- *
- * @private
- * @param {OO.ui.StackLayout} layout
- * @fires set
- */
-OO.ui.StackLayout.prototype.unsetCurrentItem = function () {
-       var prevItem = this.currentItem;
-       if ( prevItem === null ) {
-               return;
-       }
-
-       this.currentItem = null;
-       this.emit( 'set', null );
-};
-
-/**
- * Add panel layouts to the stack layout.
- *
- * Panels will be added to the end of the stack layout array unless the optional index parameter specifies a different
- * insertion point. Adding a panel that is already in the stack will move it to the end of the array or the point specified
- * by the index.
- *
- * @param {OO.ui.Layout[]} items Panels to add
- * @param {number} [index] Index of the insertion point
- * @chainable
- */
-OO.ui.StackLayout.prototype.addItems = function ( items, index ) {
-       // Update the visibility
-       this.updateHiddenState( items, this.currentItem );
-
-       // Mixin method
-       OO.ui.mixin.GroupElement.prototype.addItems.call( this, items, index );
-
-       if ( !this.currentItem && items.length ) {
-               this.setItem( items[ 0 ] );
-       }
-
-       return this;
-};
-
-/**
- * Remove the specified panels from the stack layout.
- *
- * Removed panels are detached from the DOM, not removed, so that they may be reused. To remove all panels,
- * you may wish to use the #clearItems method instead.
- *
- * @param {OO.ui.Layout[]} items Panels to remove
- * @chainable
- * @fires set
- */
-OO.ui.StackLayout.prototype.removeItems = function ( items ) {
-       // Mixin method
-       OO.ui.mixin.GroupElement.prototype.removeItems.call( this, items );
-
-       if ( items.indexOf( this.currentItem ) !== -1 ) {
-               if ( this.items.length ) {
-                       this.setItem( this.items[ 0 ] );
-               } else {
-                       this.unsetCurrentItem();
-               }
-       }
-
-       return this;
-};
-
-/**
- * Clear all panels from the stack layout.
- *
- * Cleared panels are detached from the DOM, not removed, so that they may be reused. To remove only
- * a subset of panels, use the #removeItems method.
- *
- * @chainable
- * @fires set
- */
-OO.ui.StackLayout.prototype.clearItems = function () {
-       this.unsetCurrentItem();
-       OO.ui.mixin.GroupElement.prototype.clearItems.call( this );
-
-       return this;
-};
-
-/**
- * Show the specified panel.
- *
- * If another panel is currently displayed, it will be hidden.
- *
- * @param {OO.ui.Layout} item Panel to show
- * @chainable
- * @fires set
- */
-OO.ui.StackLayout.prototype.setItem = function ( item ) {
-       if ( item !== this.currentItem ) {
-               this.updateHiddenState( this.items, item );
-
-               if ( this.items.indexOf( item ) !== -1 ) {
-                       this.currentItem = item;
-                       this.emit( 'set', item );
-               } else {
-                       this.unsetCurrentItem();
-               }
-       }
-
-       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.
- *
- * @private
- * @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' );
-               }
-       }
-};
-
-/**
- * MenuLayouts combine a menu and a content {@link OO.ui.PanelLayout panel}. The menu is positioned relative to the content (after, before, top, or bottom)
- * and its size is customized with the #menuSize config. The content area will fill all remaining space.
- *
- *     @example
- *     var menuLayout = new OO.ui.MenuLayout( {
- *         position: 'top'
- *     } ),
- *         menuPanel = new OO.ui.PanelLayout( { padded: true, expanded: true, scrollable: true } ),
- *         contentPanel = new OO.ui.PanelLayout( { padded: true, expanded: true, scrollable: true } ),
- *         select = new OO.ui.SelectWidget( {
- *             items: [
- *                 new OO.ui.OptionWidget( {
- *                     data: 'before',
- *                     label: 'Before',
- *                 } ),
- *                 new OO.ui.OptionWidget( {
- *                     data: 'after',
- *                     label: 'After',
- *                 } ),
- *                 new OO.ui.OptionWidget( {
- *                     data: 'top',
- *                     label: 'Top',
- *                 } ),
- *                 new OO.ui.OptionWidget( {
- *                     data: 'bottom',
- *                     label: 'Bottom',
- *                 } )
- *              ]
- *         } ).on( 'select', function ( item ) {
- *            menuLayout.setMenuPosition( item.getData() );
- *         } );
- *
- *     menuLayout.$menu.append(
- *         menuPanel.$element.append( '<b>Menu panel</b>', select.$element )
- *     );
- *     menuLayout.$content.append(
- *         contentPanel.$element.append( '<b>Content panel</b>', '<p>Note that the menu is positioned relative to the content panel: top, bottom, after, before.</p>')
- *     );
- *     $( 'body' ).append( menuLayout.$element );
- *
- * If menu size needs to be overridden, it can be accomplished using CSS similar to the snippet
- * below. MenuLayout's CSS will override the appropriate values with 'auto' or '0' to display the
- * menu correctly. If `menuPosition` is known beforehand, CSS rules corresponding to other positions
- * may be omitted.
- *
- *     .oo-ui-menuLayout-menu {
- *         height: 200px;
- *         width: 200px;
- *     }
- *     .oo-ui-menuLayout-content {
- *         top: 200px;
- *         left: 200px;
- *         right: 200px;
- *         bottom: 200px;
- *     }
- *
- * @class
- * @extends OO.ui.Layout
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {boolean} [showMenu=true] Show menu
- * @cfg {string} [menuPosition='before'] Position of menu: `top`, `after`, `bottom` or `before`
- */
-OO.ui.MenuLayout = function OoUiMenuLayout( config ) {
-       // Configuration initialization
-       config = $.extend( {
-               showMenu: true,
-               menuPosition: 'before'
-       }, config );
-
-       // Parent constructor
-       OO.ui.MenuLayout.parent.call( this, config );
-
-       /**
-        * Menu DOM node
-        *
-        * @property {jQuery}
-        */
-       this.$menu = $( '<div>' );
-       /**
-        * Content DOM node
-        *
-        * @property {jQuery}
-        */
-       this.$content = $( '<div>' );
-
-       // Initialization
-       this.$menu
-               .addClass( 'oo-ui-menuLayout-menu' );
-       this.$content.addClass( 'oo-ui-menuLayout-content' );
-       this.$element
-               .addClass( 'oo-ui-menuLayout' )
-               .append( this.$content, this.$menu );
-       this.setMenuPosition( config.menuPosition );
-       this.toggleMenu( config.showMenu );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.MenuLayout, OO.ui.Layout );
-
-/* Methods */
-
-/**
- * Toggle menu.
- *
- * @param {boolean} showMenu Show menu, omit to toggle
- * @chainable
- */
-OO.ui.MenuLayout.prototype.toggleMenu = function ( showMenu ) {
-       showMenu = showMenu === undefined ? !this.showMenu : !!showMenu;
-
-       if ( this.showMenu !== showMenu ) {
-               this.showMenu = showMenu;
-               this.$element
-                       .toggleClass( 'oo-ui-menuLayout-showMenu', this.showMenu )
-                       .toggleClass( 'oo-ui-menuLayout-hideMenu', !this.showMenu );
-       }
-
-       return this;
-};
-
-/**
- * Check if menu is visible
- *
- * @return {boolean} Menu is visible
- */
-OO.ui.MenuLayout.prototype.isMenuVisible = function () {
-       return this.showMenu;
-};
-
-/**
- * Set menu position.
- *
- * @param {string} position Position of menu, either `top`, `after`, `bottom` or `before`
- * @throws {Error} If position value is not supported
- * @chainable
- */
-OO.ui.MenuLayout.prototype.setMenuPosition = function ( position ) {
-       this.$element.removeClass( 'oo-ui-menuLayout-' + this.menuPosition );
-       this.menuPosition = position;
-       this.$element.addClass( 'oo-ui-menuLayout-' + position );
-
-       return this;
-};
-
-/**
- * Get menu position.
- *
- * @return {string} Menu position
- */
-OO.ui.MenuLayout.prototype.getMenuPosition = function () {
-       return this.menuPosition;
-};
-
-/**
- * BookletLayouts contain {@link OO.ui.PageLayout page layouts} as well as
- * an {@link OO.ui.OutlineSelectWidget outline} that allows users to easily navigate
- * through the pages and select which one to display. By default, only one page is
- * displayed at a time and the outline is hidden. When a user navigates to a new page,
- * the booklet layout automatically focuses on the first focusable element, unless the
- * default setting is changed. Optionally, booklets can be configured to show
- * {@link OO.ui.OutlineControlsWidget controls} for adding, moving, and removing items.
- *
- *     @example
- *     // Example of a BookletLayout that contains two PageLayouts.
- *
- *     function PageOneLayout( name, config ) {
- *         PageOneLayout.parent.call( this, name, config );
- *         this.$element.append( '<p>First page</p><p>(This booklet has an outline, displayed on the left)</p>' );
- *     }
- *     OO.inheritClass( PageOneLayout, OO.ui.PageLayout );
- *     PageOneLayout.prototype.setupOutlineItem = function () {
- *         this.outlineItem.setLabel( 'Page One' );
- *     };
- *
- *     function PageTwoLayout( name, config ) {
- *         PageTwoLayout.parent.call( this, name, config );
- *         this.$element.append( '<p>Second page</p>' );
- *     }
- *     OO.inheritClass( PageTwoLayout, OO.ui.PageLayout );
- *     PageTwoLayout.prototype.setupOutlineItem = function () {
- *         this.outlineItem.setLabel( 'Page Two' );
- *     };
- *
- *     var page1 = new PageOneLayout( 'one' ),
- *         page2 = new PageTwoLayout( 'two' );
- *
- *     var booklet = new OO.ui.BookletLayout( {
- *         outlined: true
- *     } );
- *
- *     booklet.addPages ( [ page1, page2 ] );
- *     $( 'body' ).append( booklet.$element );
- *
- * @class
- * @extends OO.ui.MenuLayout
- *
- * @constructor
- * @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 a new page is displayed.
- * @cfg {boolean} [outlined=false] Show the outline. The outline is used to navigate through the pages of the booklet.
- * @cfg {boolean} [editable=false] Show controls for adding, removing and reordering pages
- */
-OO.ui.BookletLayout = function OoUiBookletLayout( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.BookletLayout.parent.call( this, config );
-
-       // Properties
-       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
-       this.stackLayout.connect( this, { set: 'onStackLayoutSet' } );
-       if ( this.outlined ) {
-               this.outlineSelectWidget.connect( this, { select: 'onOutlineSelectWidgetSelect' } );
-               this.scrolling = false;
-               this.stackLayout.connect( this, { visibleItemChange: 'onStackLayoutVisibleItemChange' } );
-       }
-       if ( this.autoFocus ) {
-               // Event 'focus' does not bubble, but 'focusin' does
-               this.stackLayout.$element.on( 'focusin', this.onStackLayoutFocus.bind( this ) );
-       }
-
-       // 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 );
-               }
-       }
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.BookletLayout, OO.ui.MenuLayout );
-
-/* Events */
-
-/**
- * A 'set' event is emitted when a page is {@link #setPage set} to be displayed by the booklet layout.
- * @event set
- * @param {OO.ui.PageLayout} page Current page
- */
-
-/**
- * An 'add' event is emitted when pages are {@link #addPages added} to the booklet layout.
- *
- * @event add
- * @param {OO.ui.PageLayout[]} page Added pages
- * @param {number} index Index pages were added at
- */
-
-/**
- * A 'remove' event is emitted when pages are {@link #clearPages cleared} or
- * {@link #removePages removed} from the booklet.
- *
- * @event remove
- * @param {OO.ui.PageLayout[]} pages Removed pages
- */
-
-/* Methods */
-
-/**
- * Handle stack layout focus.
- *
- * @private
- * @param {jQuery.Event} e Focusin event
- */
-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 visibleItemChange events from the stackLayout
- *
- * The next visible page is set as the current page by selecting it
- * in the outline
- *
- * @param {OO.ui.PageLayout} page The next visible page in the layout
- */
-OO.ui.BookletLayout.prototype.onStackLayoutVisibleItemChange = function ( page ) {
-       // Set a flag to so that the resulting call to #onStackLayoutSet doesn't
-       // try and scroll the item into view again.
-       this.scrolling = true;
-       this.outlineSelectWidget.selectItemByData( page.getName() );
-       this.scrolling = false;
-};
-
-/**
- * Handle stack layout set events.
- *
- * @private
- * @param {OO.ui.PanelLayout|null} page The page panel that is now the current panel
- */
-OO.ui.BookletLayout.prototype.onStackLayoutSet = function ( page ) {
-       var layout = this;
-       if ( !this.scrolling && page ) {
-               page.scrollElementIntoView( { complete: function () {
-                       if ( layout.autoFocus ) {
-                               layout.focus();
-                       }
-               } } );
-       }
-};
-
-/**
- * Focus the first input in the current page.
- *
- * 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.
- * @param {number} [itemIndex] A specific item to focus on
- */
-OO.ui.BookletLayout.prototype.focus = function ( itemIndex ) {
-       var page,
-               items = this.stackLayout.getItems();
-
-       if ( itemIndex !== undefined && items[ itemIndex ] ) {
-               page = items[ itemIndex ];
-       } else {
-               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 ( !OO.ui.contains( page.$element[ 0 ], this.getElementDocument().activeElement, true ) ) {
-               page.focus();
-       }
-};
-
-/**
- * Find the first focusable input in the booklet layout and focus
- * on it.
- */
-OO.ui.BookletLayout.prototype.focusFirstFocusable = function () {
-       OO.ui.findFocusable( this.stackLayout.$element ).focus();
-};
-
-/**
- * Handle outline widget select events.
- *
- * @private
- * @param {OO.ui.OptionWidget|null} item Selected item
- */
-OO.ui.BookletLayout.prototype.onOutlineSelectWidgetSelect = function ( item ) {
-       if ( item ) {
-               this.setPage( item.getData() );
-       }
-};
-
-/**
- * Check if booklet has an outline.
- *
- * @return {boolean} Booklet has an outline
- */
-OO.ui.BookletLayout.prototype.isOutlined = function () {
-       return this.outlined;
-};
-
-/**
- * Check if booklet has editing controls.
- *
- * @return {boolean} Booklet is editable
- */
-OO.ui.BookletLayout.prototype.isEditable = function () {
-       return this.editable;
-};
-
-/**
- * Check if booklet has a visible outline.
- *
- * @return {boolean} Outline is visible
- */
-OO.ui.BookletLayout.prototype.isOutlineVisible = function () {
-       return this.outlined && this.outlineVisible;
-};
-
-/**
- * Hide or show the outline.
- *
- * @param {boolean} [show] Show outline, omit to invert current state
- * @chainable
- */
-OO.ui.BookletLayout.prototype.toggleOutline = function ( show ) {
-       if ( this.outlined ) {
-               show = show === undefined ? !this.outlineVisible : !!show;
-               this.outlineVisible = show;
-               this.toggleMenu( show );
-       }
-
-       return this;
-};
-
-/**
- * Get the page closest to the specified page.
- *
- * @param {OO.ui.PageLayout} page Page to use as a reference point
- * @return {OO.ui.PageLayout|null} Page closest to the specified page
- */
-OO.ui.BookletLayout.prototype.getClosestPage = function ( page ) {
-       var next, prev, level,
-               pages = this.stackLayout.getItems(),
-               index = pages.indexOf( page );
-
-       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;
-};
-
-/**
- * Get the outline widget.
- *
- * If the booklet is not outlined, the method will return `null`.
- *
- * @return {OO.ui.OutlineSelectWidget|null} Outline widget, or null if the booklet is not outlined
- */
-OO.ui.BookletLayout.prototype.getOutline = function () {
-       return this.outlineSelectWidget;
-};
-
-/**
- * Get the outline controls widget.
- *
- * If the outline is not editable, the method will return `null`.
- *
- * @return {OO.ui.OutlineControlsWidget|null} The outline controls widget.
- */
-OO.ui.BookletLayout.prototype.getOutlineControls = function () {
-       return this.outlineControlsWidget;
-};
-
-/**
- * Get a page by its symbolic 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 ];
-};
-
-/**
- * Get the current page.
- *
- * @return {OO.ui.PageLayout|undefined} Current page, if found
- */
-OO.ui.BookletLayout.prototype.getCurrentPage = function () {
-       var name = this.getCurrentPageName();
-       return name ? this.getPage( name ) : undefined;
-};
-
-/**
- * Get the symbolic name of the current page.
- *
- * @return {string|null} Symbolic name of the current page
- */
-OO.ui.BookletLayout.prototype.getCurrentPageName = function () {
-       return this.currentPageName;
-};
-
-/**
- * Add pages to the booklet 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.
- *
- * @param {OO.ui.PageLayout[]} pages Pages to add
- * @param {number} index Index of the insertion point
- * @fires add
- * @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 = stackLayoutPages.indexOf( this.pages[ name ] );
-                       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( { 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 );
-
-       return this;
-};
-
-/**
- * Remove the specified pages from the booklet layout.
- *
- * To remove all pages from the booklet, you may wish to use the #clearPages method instead.
- *
- * @param {OO.ui.PageLayout[]} pages An array of pages to remove
- * @fires remove
- * @chainable
- */
-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();
-       }
-       this.stackLayout.removeItems( pages );
-       this.emit( 'remove', pages );
-
-       return this;
-};
-
-/**
- * Clear all pages from the booklet layout.
- *
- * To remove only a subset of pages from the booklet, use the #removePages method.
- *
- * @fires remove
- * @chainable
- */
-OO.ui.BookletLayout.prototype.clearPages = function () {
-       var i, len,
-               pages = this.stackLayout.getItems();
-
-       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();
-
-       this.emit( 'remove', pages );
-
-       return this;
-};
-
-/**
- * Set the current page by symbolic name.
- *
- * @fires set
- * @param {string} name Symbolic name of page
- */
-OO.ui.BookletLayout.prototype.setPage = function ( name ) {
-       var selectedItem,
-               $focused,
-               page = this.pages[ name ],
-               previousPage = this.currentPageName && this.pages[ this.currentPageName ];
-
-       if ( name !== this.currentPageName ) {
-               if ( this.outlined ) {
-                       selectedItem = this.outlineSelectWidget.getSelectedItem();
-                       if ( selectedItem && selectedItem.getData() !== name ) {
-                               this.outlineSelectWidget.selectItemByData( name );
-                       }
-               }
-               if ( page ) {
-                       if ( previousPage ) {
-                               previousPage.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 the layout is non-continuous, this check is
-                               // meaningless because the next page is not visible yet and thus can't hold focus.
-                               if (
-                                       this.autoFocus &&
-                                       this.stackLayout.continuous &&
-                                       OO.ui.findFocusable( page.$element ).length !== 0
-                               ) {
-                                       $focused = previousPage.$element.find( ':focus' );
-                                       if ( $focused.length ) {
-                                               $focused[ 0 ].blur();
-                                       }
-                               }
-                       }
-                       this.currentPageName = name;
-                       page.setActive( true );
-                       this.stackLayout.setItem( page );
-                       if ( !this.stackLayout.continuous && previousPage ) {
-                               // This should not be necessary, since any inputs on the previous page should have been
-                               // blurred when it was hidden, but browsers are not very consistent about this.
-                               $focused = previousPage.$element.find( ':focus' );
-                               if ( $focused.length ) {
-                                       $focused[ 0 ].blur();
-                               }
-                       }
-                       this.emit( 'set', page );
-               }
-       }
-};
-
-/**
- * Select the first selectable page.
- *
- * @chainable
- */
-OO.ui.BookletLayout.prototype.selectFirstSelectablePage = function () {
-       if ( !this.outlineSelectWidget.getSelectedItem() ) {
-               this.outlineSelectWidget.selectItem( this.outlineSelectWidget.getFirstSelectableItem() );
-       }
-
-       return this;
-};
-
-/**
- * IndexLayouts contain {@link OO.ui.CardLayout card layouts} as well as
- * {@link OO.ui.TabSelectWidget tabs} that allow users to easily navigate through the cards and
- * select which one to display. By default, only one card is displayed at a time. When a user
- * navigates to a new card, the index layout automatically focuses on the first focusable element,
- * unless the default setting is changed.
- *
- * TODO: This class is similar to BookletLayout, we may want to refactor to reduce duplication
- *
- *     @example
- *     // Example of a IndexLayout that contains two CardLayouts.
- *
- *     function CardOneLayout( name, config ) {
- *         CardOneLayout.parent.call( this, name, config );
- *         this.$element.append( '<p>First card</p>' );
- *     }
- *     OO.inheritClass( CardOneLayout, OO.ui.CardLayout );
- *     CardOneLayout.prototype.setupTabItem = function () {
- *         this.tabItem.setLabel( 'Card one' );
- *     };
- *
- *     var card1 = new CardOneLayout( 'one' ),
- *         card2 = new CardLayout( 'two', { label: 'Card two' } );
- *
- *     card2.$element.append( '<p>Second card</p>' );
- *
- *     var index = new OO.ui.IndexLayout();
- *
- *     index.addCards ( [ card1, card2 ] );
- *     $( 'body' ).append( index.$element );
- *
- * @class
- * @extends OO.ui.MenuLayout
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {boolean} [continuous=false] Show all cards, one after another
- * @cfg {boolean} [expanded=true] Expand the content panel to fill the entire parent element.
- * @cfg {boolean} [autoFocus=true] Focus on the first focusable element when a new card is displayed.
- */
-OO.ui.IndexLayout = function OoUiIndexLayout( config ) {
-       // Configuration initialization
-       config = $.extend( {}, config, { menuPosition: 'top' } );
-
-       // Parent constructor
-       OO.ui.IndexLayout.parent.call( this, config );
-
-       // Properties
-       this.currentCardName = null;
-       this.cards = {};
-       this.ignoreFocus = false;
-       this.stackLayout = new OO.ui.StackLayout( {
-               continuous: !!config.continuous,
-               expanded: config.expanded
-       } );
-       this.$content.append( this.stackLayout.$element );
-       this.autoFocus = config.autoFocus === undefined || !!config.autoFocus;
-
-       this.tabSelectWidget = new OO.ui.TabSelectWidget();
-       this.tabPanel = new OO.ui.PanelLayout();
-       this.$menu.append( this.tabPanel.$element );
-
-       this.toggleMenu( true );
-
-       // Events
-       this.stackLayout.connect( this, { set: 'onStackLayoutSet' } );
-       this.tabSelectWidget.connect( this, { select: 'onTabSelectWidgetSelect' } );
-       if ( this.autoFocus ) {
-               // Event 'focus' does not bubble, but 'focusin' does
-               this.stackLayout.$element.on( 'focusin', this.onStackLayoutFocus.bind( this ) );
-       }
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-indexLayout' );
-       this.stackLayout.$element.addClass( 'oo-ui-indexLayout-stackLayout' );
-       this.tabPanel.$element
-               .addClass( 'oo-ui-indexLayout-tabPanel' )
-               .append( this.tabSelectWidget.$element );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.IndexLayout, OO.ui.MenuLayout );
-
-/* Events */
-
-/**
- * A 'set' event is emitted when a card is {@link #setCard set} to be displayed by the index layout.
- * @event set
- * @param {OO.ui.CardLayout} card Current card
- */
-
-/**
- * An 'add' event is emitted when cards are {@link #addCards added} to the index layout.
- *
- * @event add
- * @param {OO.ui.CardLayout[]} card Added cards
- * @param {number} index Index cards were added at
- */
-
-/**
- * A 'remove' event is emitted when cards are {@link #clearCards cleared} or
- * {@link #removeCards removed} from the index.
- *
- * @event remove
- * @param {OO.ui.CardLayout[]} cards Removed cards
- */
-
-/* Methods */
-
-/**
- * Handle stack layout focus.
- *
- * @private
- * @param {jQuery.Event} e Focusin event
- */
-OO.ui.IndexLayout.prototype.onStackLayoutFocus = function ( e ) {
-       var name, $target;
-
-       // Find the card that an element was focused within
-       $target = $( e.target ).closest( '.oo-ui-cardLayout' );
-       for ( name in this.cards ) {
-               // Check for card match, exclude current card to find only card changes
-               if ( this.cards[ name ].$element[ 0 ] === $target[ 0 ] && name !== this.currentCardName ) {
-                       this.setCard( name );
-                       break;
-               }
-       }
-};
-
-/**
- * Handle stack layout set events.
- *
- * @private
- * @param {OO.ui.PanelLayout|null} card The card panel that is now the current panel
- */
-OO.ui.IndexLayout.prototype.onStackLayoutSet = function ( card ) {
-       var layout = this;
-       if ( card ) {
-               card.scrollElementIntoView( { complete: function () {
-                       if ( layout.autoFocus ) {
-                               layout.focus();
-                       }
-               } } );
-       }
-};
-
-/**
- * Focus the first input in the current card.
- *
- * If no card is selected, the first selectable card will be selected.
- * If the focus is already in an element on the current card, nothing will happen.
- * @param {number} [itemIndex] A specific item to focus on
- */
-OO.ui.IndexLayout.prototype.focus = function ( itemIndex ) {
-       var card,
-               items = this.stackLayout.getItems();
-
-       if ( itemIndex !== undefined && items[ itemIndex ] ) {
-               card = items[ itemIndex ];
-       } else {
-               card = this.stackLayout.getCurrentItem();
-       }
-
-       if ( !card ) {
-               this.selectFirstSelectableCard();
-               card = this.stackLayout.getCurrentItem();
-       }
-       if ( !card ) {
-               return;
-       }
-       // Only change the focus if is not already in the current page
-       if ( !OO.ui.contains( card.$element[ 0 ], this.getElementDocument().activeElement, true ) ) {
-               card.focus();
-       }
-};
-
-/**
- * Find the first focusable input in the index layout and focus
- * on it.
- */
-OO.ui.IndexLayout.prototype.focusFirstFocusable = function () {
-       OO.ui.findFocusable( this.stackLayout.$element ).focus();
-};
-
-/**
- * Handle tab widget select events.
- *
- * @private
- * @param {OO.ui.OptionWidget|null} item Selected item
- */
-OO.ui.IndexLayout.prototype.onTabSelectWidgetSelect = function ( item ) {
-       if ( item ) {
-               this.setCard( item.getData() );
-       }
-};
-
-/**
- * Get the card closest to the specified card.
- *
- * @param {OO.ui.CardLayout} card Card to use as a reference point
- * @return {OO.ui.CardLayout|null} Card closest to the specified card
- */
-OO.ui.IndexLayout.prototype.getClosestCard = function ( card ) {
-       var next, prev, level,
-               cards = this.stackLayout.getItems(),
-               index = cards.indexOf( card );
-
-       if ( index !== -1 ) {
-               next = cards[ index + 1 ];
-               prev = cards[ index - 1 ];
-               // Prefer adjacent cards at the same level
-               level = this.tabSelectWidget.getItemFromData( card.getName() ).getLevel();
-               if (
-                       prev &&
-                       level === this.tabSelectWidget.getItemFromData( prev.getName() ).getLevel()
-               ) {
-                       return prev;
-               }
-               if (
-                       next &&
-                       level === this.tabSelectWidget.getItemFromData( next.getName() ).getLevel()
-               ) {
-                       return next;
-               }
-       }
-       return prev || next || null;
-};
-
-/**
- * Get the tabs widget.
- *
- * @return {OO.ui.TabSelectWidget} Tabs widget
- */
-OO.ui.IndexLayout.prototype.getTabs = function () {
-       return this.tabSelectWidget;
-};
-
-/**
- * Get a card by its symbolic name.
- *
- * @param {string} name Symbolic name of card
- * @return {OO.ui.CardLayout|undefined} Card, if found
- */
-OO.ui.IndexLayout.prototype.getCard = function ( name ) {
-       return this.cards[ name ];
-};
-
-/**
- * Get the current card.
- *
- * @return {OO.ui.CardLayout|undefined} Current card, if found
- */
-OO.ui.IndexLayout.prototype.getCurrentCard = function () {
-       var name = this.getCurrentCardName();
-       return name ? this.getCard( name ) : undefined;
-};
-
-/**
- * Get the symbolic name of the current card.
- *
- * @return {string|null} Symbolic name of the current card
- */
-OO.ui.IndexLayout.prototype.getCurrentCardName = function () {
-       return this.currentCardName;
-};
-
-/**
- * Add cards to the index layout
- *
- * When cards are added with the same names as existing cards, the existing cards will be
- * automatically removed before the new cards are added.
- *
- * @param {OO.ui.CardLayout[]} cards Cards to add
- * @param {number} index Index of the insertion point
- * @fires add
- * @chainable
- */
-OO.ui.IndexLayout.prototype.addCards = function ( cards, index ) {
-       var i, len, name, card, item, currentIndex,
-               stackLayoutCards = this.stackLayout.getItems(),
-               remove = [],
-               items = [];
-
-       // Remove cards with same names
-       for ( i = 0, len = cards.length; i < len; i++ ) {
-               card = cards[ i ];
-               name = card.getName();
-
-               if ( Object.prototype.hasOwnProperty.call( this.cards, name ) ) {
-                       // Correct the insertion index
-                       currentIndex = stackLayoutCards.indexOf( this.cards[ name ] );
-                       if ( currentIndex !== -1 && currentIndex + 1 < index ) {
-                               index--;
-                       }
-                       remove.push( this.cards[ name ] );
-               }
-       }
-       if ( remove.length ) {
-               this.removeCards( remove );
-       }
-
-       // Add new cards
-       for ( i = 0, len = cards.length; i < len; i++ ) {
-               card = cards[ i ];
-               name = card.getName();
-               this.cards[ card.getName() ] = card;
-               item = new OO.ui.TabOptionWidget( { data: name } );
-               card.setTabItem( item );
-               items.push( item );
-       }
-
-       if ( items.length ) {
-               this.tabSelectWidget.addItems( items, index );
-               this.selectFirstSelectableCard();
-       }
-       this.stackLayout.addItems( cards, index );
-       this.emit( 'add', cards, index );
-
-       return this;
-};
-
-/**
- * Remove the specified cards from the index layout.
- *
- * To remove all cards from the index, you may wish to use the #clearCards method instead.
- *
- * @param {OO.ui.CardLayout[]} cards An array of cards to remove
- * @fires remove
- * @chainable
- */
-OO.ui.IndexLayout.prototype.removeCards = function ( cards ) {
-       var i, len, name, card,
-               items = [];
-
-       for ( i = 0, len = cards.length; i < len; i++ ) {
-               card = cards[ i ];
-               name = card.getName();
-               delete this.cards[ name ];
-               items.push( this.tabSelectWidget.getItemFromData( name ) );
-               card.setTabItem( null );
-       }
-       if ( items.length ) {
-               this.tabSelectWidget.removeItems( items );
-               this.selectFirstSelectableCard();
-       }
-       this.stackLayout.removeItems( cards );
-       this.emit( 'remove', cards );
-
-       return this;
-};
-
-/**
- * Clear all cards from the index layout.
- *
- * To remove only a subset of cards from the index, use the #removeCards method.
- *
- * @fires remove
- * @chainable
- */
-OO.ui.IndexLayout.prototype.clearCards = function () {
-       var i, len,
-               cards = this.stackLayout.getItems();
-
-       this.cards = {};
-       this.currentCardName = null;
-       this.tabSelectWidget.clearItems();
-       for ( i = 0, len = cards.length; i < len; i++ ) {
-               cards[ i ].setTabItem( null );
-       }
-       this.stackLayout.clearItems();
-
-       this.emit( 'remove', cards );
-
-       return this;
-};
-
-/**
- * Set the current card by symbolic name.
- *
- * @fires set
- * @param {string} name Symbolic name of card
- */
-OO.ui.IndexLayout.prototype.setCard = function ( name ) {
-       var selectedItem,
-               $focused,
-               card = this.cards[ name ],
-               previousCard = this.currentCardName && this.cards[ this.currentCardName ];
-
-       if ( name !== this.currentCardName ) {
-               selectedItem = this.tabSelectWidget.getSelectedItem();
-               if ( selectedItem && selectedItem.getData() !== name ) {
-                       this.tabSelectWidget.selectItemByData( name );
-               }
-               if ( card ) {
-                       if ( previousCard ) {
-                               previousCard.setActive( false );
-                               // Blur anything focused if the next card doesn't have anything focusable.
-                               // This is not needed if the next card has something focusable (because once it is focused
-                               // this blur happens automatically). If the layout is non-continuous, this check is
-                               // meaningless because the next card is not visible yet and thus can't hold focus.
-                               if (
-                                       this.autoFocus &&
-                                       this.stackLayout.continuous &&
-                                       OO.ui.findFocusable( card.$element ).length !== 0
-                               ) {
-                                       $focused = previousCard.$element.find( ':focus' );
-                                       if ( $focused.length ) {
-                                               $focused[ 0 ].blur();
-                                       }
-                               }
-                       }
-                       this.currentCardName = name;
-                       card.setActive( true );
-                       this.stackLayout.setItem( card );
-                       if ( !this.stackLayout.continuous && previousCard ) {
-                               // This should not be necessary, since any inputs on the previous card should have been
-                               // blurred when it was hidden, but browsers are not very consistent about this.
-                               $focused = previousCard.$element.find( ':focus' );
-                               if ( $focused.length ) {
-                                       $focused[ 0 ].blur();
-                               }
-                       }
-                       this.emit( 'set', card );
-               }
-       }
-};
-
-/**
- * Select the first selectable card.
- *
- * @chainable
- */
-OO.ui.IndexLayout.prototype.selectFirstSelectableCard = function () {
-       if ( !this.tabSelectWidget.getSelectedItem() ) {
-               this.tabSelectWidget.selectItem( this.tabSelectWidget.getFirstSelectableItem() );
-       }
-
-       return this;
-};
-
-/**
- * ToggleWidget implements basic behavior of widgets with an on/off state.
- * Please see OO.ui.ToggleButtonWidget and OO.ui.ToggleSwitchWidget for examples.
- *
- * @abstract
- * @class
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {boolean} [value=false] The toggle’s initial on/off state.
- *  By default, the toggle is in the 'off' state.
- */
-OO.ui.ToggleWidget = function OoUiToggleWidget( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.ToggleWidget.parent.call( this, config );
-
-       // Properties
-       this.value = null;
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-toggleWidget' );
-       this.setValue( !!config.value );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.ToggleWidget, OO.ui.Widget );
-
-/* Events */
-
-/**
- * @event change
- *
- * A change event is emitted when the on/off state of the toggle changes.
- *
- * @param {boolean} value Value representing the new state of the toggle
- */
-
-/* Methods */
-
-/**
- * Get the value representing the toggle’s state.
- *
- * @return {boolean} The on/off state of the toggle
- */
-OO.ui.ToggleWidget.prototype.getValue = function () {
-       return this.value;
-};
-
-/**
- * Set the state of the toggle: `true` for 'on', `false' for 'off'.
- *
- * @param {boolean} value The state of the toggle
- * @fires change
- * @chainable
- */
-OO.ui.ToggleWidget.prototype.setValue = function ( value ) {
-       value = !!value;
-       if ( this.value !== value ) {
-               this.value = 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;
-};
-
-/**
- * ToggleButtons are buttons that have a state (‘on’ or ‘off’) that is represented by a
- * Boolean value. Like other {@link OO.ui.ButtonWidget buttons}, toggle buttons can be
- * configured with {@link OO.ui.mixin.IconElement icons}, {@link OO.ui.mixin.IndicatorElement indicators},
- * {@link OO.ui.mixin.TitledElement titles}, {@link OO.ui.mixin.FlaggedElement styling flags},
- * and {@link OO.ui.mixin.LabelElement labels}. Please see
- * the [OOjs UI documentation][1] on MediaWiki for more information.
- *
- *     @example
- *     // Toggle buttons in the 'off' and 'on' state.
- *     var toggleButton1 = new OO.ui.ToggleButtonWidget( {
- *         label: 'Toggle Button off'
- *     } );
- *     var toggleButton2 = new OO.ui.ToggleButtonWidget( {
- *         label: 'Toggle Button on',
- *         value: true
- *     } );
- *     // Append the buttons to the DOM.
- *     $( 'body' ).append( toggleButton1.$element, toggleButton2.$element );
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Buttons_and_Switches#Toggle_buttons
- *
- * @class
- * @extends OO.ui.ToggleWidget
- * @mixins OO.ui.mixin.ButtonElement
- * @mixins OO.ui.mixin.IconElement
- * @mixins OO.ui.mixin.IndicatorElement
- * @mixins OO.ui.mixin.LabelElement
- * @mixins OO.ui.mixin.TitledElement
- * @mixins OO.ui.mixin.FlaggedElement
- * @mixins OO.ui.mixin.TabIndexedElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {boolean} [value=false] The toggle button’s initial on/off
- *  state. By default, the button is in the 'off' state.
- */
-OO.ui.ToggleButtonWidget = function OoUiToggleButtonWidget( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.ToggleButtonWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.ButtonElement.call( this, config );
-       OO.ui.mixin.IconElement.call( this, config );
-       OO.ui.mixin.IndicatorElement.call( this, config );
-       OO.ui.mixin.LabelElement.call( this, config );
-       OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
-       OO.ui.mixin.FlaggedElement.call( this, config );
-       OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$button } ) );
-
-       // Events
-       this.connect( this, { click: 'onAction' } );
-
-       // Initialization
-       this.$button.append( this.$icon, this.$label, this.$indicator );
-       this.$element
-               .addClass( 'oo-ui-toggleButtonWidget' )
-               .append( this.$button );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.ToggleButtonWidget, OO.ui.ToggleWidget );
-OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.ButtonElement );
-OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.IconElement );
-OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.IndicatorElement );
-OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.LabelElement );
-OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.TitledElement );
-OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.FlaggedElement );
-OO.mixinClass( OO.ui.ToggleButtonWidget, OO.ui.mixin.TabIndexedElement );
-
-/* Methods */
-
-/**
- * Handle the button action being triggered.
- *
- * @private
- */
-OO.ui.ToggleButtonWidget.prototype.onAction = function () {
-       this.setValue( !this.value );
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.ToggleButtonWidget.prototype.setValue = function ( value ) {
-       value = !!value;
-       if ( value !== this.value ) {
-               // Might be called from parent constructor before ButtonElement constructor
-               if ( this.$button ) {
-                       this.$button.attr( 'aria-pressed', value.toString() );
-               }
-               this.setActive( value );
-       }
-
-       // Parent method
-       OO.ui.ToggleButtonWidget.parent.prototype.setValue.call( this, value );
-
-       return this;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.ToggleButtonWidget.prototype.setButtonElement = function ( $button ) {
-       if ( this.$button ) {
-               this.$button.removeAttr( 'aria-pressed' );
-       }
-       OO.ui.mixin.ButtonElement.prototype.setButtonElement.call( this, $button );
-       this.$button.attr( 'aria-pressed', this.value.toString() );
-};
-
-/**
- * ToggleSwitches are switches that slide on and off. Their state is represented by a Boolean
- * value (`true` for ‘on’, and `false` otherwise, the default). The ‘off’ state is represented
- * visually by a slider in the leftmost position.
- *
- *     @example
- *     // Toggle switches in the 'off' and 'on' position.
- *     var toggleSwitch1 = new OO.ui.ToggleSwitchWidget();
- *     var toggleSwitch2 = new OO.ui.ToggleSwitchWidget( {
- *         value: true
- *     } );
- *
- *     // Create a FieldsetLayout to layout and label switches
- *     var fieldset = new OO.ui.FieldsetLayout( {
- *        label: 'Toggle switches'
- *     } );
- *     fieldset.addItems( [
- *         new OO.ui.FieldLayout( toggleSwitch1, { label: 'Off', align: 'top' } ),
- *         new OO.ui.FieldLayout( toggleSwitch2, { label: 'On', align: 'top' } )
- *     ] );
- *     $( 'body' ).append( fieldset.$element );
- *
- * @class
- * @extends OO.ui.ToggleWidget
- * @mixins OO.ui.mixin.TabIndexedElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {boolean} [value=false] The toggle switch’s initial on/off state.
- *  By default, the toggle switch is in the 'off' position.
- */
-OO.ui.ToggleSwitchWidget = function OoUiToggleSwitchWidget( config ) {
-       // Parent constructor
-       OO.ui.ToggleSwitchWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.TabIndexedElement.call( this, config );
-
-       // Properties
-       this.dragging = false;
-       this.dragStart = null;
-       this.sliding = false;
-       this.$glow = $( '<span>' );
-       this.$grip = $( '<span>' );
-
-       // Events
-       this.$element.on( {
-               click: this.onClick.bind( this ),
-               keypress: this.onKeyPress.bind( this )
-       } );
-
-       // Initialization
-       this.$glow.addClass( 'oo-ui-toggleSwitchWidget-glow' );
-       this.$grip.addClass( 'oo-ui-toggleSwitchWidget-grip' );
-       this.$element
-               .addClass( 'oo-ui-toggleSwitchWidget' )
-               .attr( 'role', 'checkbox' )
-               .append( this.$glow, this.$grip );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.ToggleSwitchWidget, OO.ui.ToggleWidget );
-OO.mixinClass( OO.ui.ToggleSwitchWidget, OO.ui.mixin.TabIndexedElement );
-
-/* Methods */
-
-/**
- * Handle mouse click events.
- *
- * @private
- * @param {jQuery.Event} e Mouse click event
- */
-OO.ui.ToggleSwitchWidget.prototype.onClick = function ( e ) {
-       if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
-               this.setValue( !this.value );
-       }
-       return false;
-};
-
-/**
- * Handle key press events.
- *
- * @private
- * @param {jQuery.Event} e Key press event
- */
-OO.ui.ToggleSwitchWidget.prototype.onKeyPress = function ( e ) {
-       if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
-               this.setValue( !this.value );
-               return false;
-       }
-};
-
-/**
- * OutlineControlsWidget is a set of controls for an {@link OO.ui.OutlineSelectWidget outline select widget}.
- * Controls include moving items up and down, removing items, and adding different kinds of items.
- *
- * **Currently, this class is only used by {@link OO.ui.BookletLayout booklet layouts}.**
- *
- * @class
- * @extends OO.ui.Widget
- * @mixins OO.ui.mixin.GroupElement
- * @mixins OO.ui.mixin.IconElement
- *
- * @constructor
- * @param {OO.ui.OutlineSelectWidget} outline Outline to control
- * @param {Object} [config] Configuration options
- * @cfg {Object} [abilities] List of abilties
- * @cfg {boolean} [abilities.move=true] Allow moving movable items
- * @cfg {boolean} [abilities.remove=true] Allow removing removable items
- */
-OO.ui.OutlineControlsWidget = function OoUiOutlineControlsWidget( outline, config ) {
-       // Allow passing positional parameters inside the config object
-       if ( OO.isPlainObject( outline ) && config === undefined ) {
-               config = outline;
-               outline = config.outline;
-       }
-
-       // Configuration initialization
-       config = $.extend( { icon: 'add' }, config );
-
-       // Parent constructor
-       OO.ui.OutlineControlsWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.GroupElement.call( this, config );
-       OO.ui.mixin.IconElement.call( this, config );
-
-       // Properties
-       this.outline = outline;
-       this.$movers = $( '<div>' );
-       this.upButton = new OO.ui.ButtonWidget( {
-               framed: false,
-               icon: 'collapse',
-               title: OO.ui.msg( 'ooui-outline-control-move-up' )
-       } );
-       this.downButton = new OO.ui.ButtonWidget( {
-               framed: false,
-               icon: 'expand',
-               title: OO.ui.msg( 'ooui-outline-control-move-down' )
-       } );
-       this.removeButton = new OO.ui.ButtonWidget( {
-               framed: false,
-               icon: 'remove',
-               title: OO.ui.msg( 'ooui-outline-control-remove' )
-       } );
-       this.abilities = { move: true, remove: true };
-
-       // Events
-       outline.connect( this, {
-               select: 'onOutlineChange',
-               add: 'onOutlineChange',
-               remove: 'onOutlineChange'
-       } );
-       this.upButton.connect( this, { click: [ 'emit', 'move', -1 ] } );
-       this.downButton.connect( this, { click: [ 'emit', 'move', 1 ] } );
-       this.removeButton.connect( this, { click: [ 'emit', 'remove' ] } );
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-outlineControlsWidget' );
-       this.$group.addClass( 'oo-ui-outlineControlsWidget-items' );
-       this.$movers
-               .addClass( 'oo-ui-outlineControlsWidget-movers' )
-               .append( this.removeButton.$element, this.upButton.$element, this.downButton.$element );
-       this.$element.append( this.$icon, this.$group, this.$movers );
-       this.setAbilities( config.abilities || {} );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.OutlineControlsWidget, OO.ui.Widget );
-OO.mixinClass( OO.ui.OutlineControlsWidget, OO.ui.mixin.GroupElement );
-OO.mixinClass( OO.ui.OutlineControlsWidget, OO.ui.mixin.IconElement );
-
-/* Events */
-
-/**
- * @event move
- * @param {number} places Number of places to move
- */
-
-/**
- * @event remove
- */
-
-/* Methods */
-
-/**
- * Set abilities.
- *
- * @param {Object} abilities List of abilties
- * @param {boolean} [abilities.move] Allow moving movable items
- * @param {boolean} [abilities.remove] Allow removing removable items
- */
-OO.ui.OutlineControlsWidget.prototype.setAbilities = function ( abilities ) {
-       var ability;
-
-       for ( ability in this.abilities ) {
-               if ( abilities[ ability ] !== undefined ) {
-                       this.abilities[ ability ] = !!abilities[ ability ];
-               }
-       }
-
-       this.onOutlineChange();
-};
-
-/**
- * @private
- * Handle outline change events.
- */
-OO.ui.OutlineControlsWidget.prototype.onOutlineChange = function () {
-       var i, len, firstMovable, lastMovable,
-               items = this.outline.getItems(),
-               selectedItem = this.outline.getSelectedItem(),
-               movable = this.abilities.move && selectedItem && selectedItem.isMovable(),
-               removable = this.abilities.remove && selectedItem && selectedItem.isRemovable();
-
-       if ( movable ) {
-               i = -1;
-               len = items.length;
-               while ( ++i < len ) {
-                       if ( items[ i ].isMovable() ) {
-                               firstMovable = items[ i ];
-                               break;
-                       }
-               }
-               i = len;
-               while ( i-- ) {
-                       if ( items[ i ].isMovable() ) {
-                               lastMovable = items[ i ];
-                               break;
-                       }
-               }
-       }
-       this.upButton.setDisabled( !movable || selectedItem === firstMovable );
-       this.downButton.setDisabled( !movable || selectedItem === lastMovable );
-       this.removeButton.setDisabled( !removable );
-};
-
-/**
- * OutlineOptionWidget is an item in an {@link OO.ui.OutlineSelectWidget OutlineSelectWidget}.
- *
- * Currently, this class is only used by {@link OO.ui.BookletLayout booklet layouts}, which contain
- * {@link OO.ui.PageLayout page layouts}. See {@link OO.ui.BookletLayout BookletLayout}
- * for an example.
- *
- * @class
- * @extends OO.ui.DecoratedOptionWidget
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {number} [level] Indentation level
- * @cfg {boolean} [movable] Allow modification from {@link OO.ui.OutlineControlsWidget outline controls}.
- */
-OO.ui.OutlineOptionWidget = function OoUiOutlineOptionWidget( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.OutlineOptionWidget.parent.call( this, config );
-
-       // Properties
-       this.level = 0;
-       this.movable = !!config.movable;
-       this.removable = !!config.removable;
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-outlineOptionWidget' );
-       this.setLevel( config.level );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.OutlineOptionWidget, OO.ui.DecoratedOptionWidget );
-
-/* Static Properties */
-
-OO.ui.OutlineOptionWidget.static.highlightable = false;
-
-OO.ui.OutlineOptionWidget.static.scrollIntoViewOnSelect = true;
-
-OO.ui.OutlineOptionWidget.static.levelClass = 'oo-ui-outlineOptionWidget-level-';
-
-OO.ui.OutlineOptionWidget.static.levels = 3;
-
-/* Methods */
-
-/**
- * Check if item is movable.
- *
- * Movability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
- *
- * @return {boolean} Item is movable
- */
-OO.ui.OutlineOptionWidget.prototype.isMovable = function () {
-       return this.movable;
-};
-
-/**
- * Check if item is removable.
- *
- * Removability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
- *
- * @return {boolean} Item is removable
- */
-OO.ui.OutlineOptionWidget.prototype.isRemovable = function () {
-       return this.removable;
-};
-
-/**
- * Get indentation level.
- *
- * @return {number} Indentation level
- */
-OO.ui.OutlineOptionWidget.prototype.getLevel = function () {
-       return this.level;
-};
-
-/**
- * Set movability.
- *
- * Movability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
- *
- * @param {boolean} movable Item is movable
- * @chainable
- */
-OO.ui.OutlineOptionWidget.prototype.setMovable = function ( movable ) {
-       this.movable = !!movable;
-       this.updateThemeClasses();
-       return this;
-};
-
-/**
- * Set removability.
- *
- * Removability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
- *
- * @param {boolean} removable Item is removable
- * @chainable
- */
-OO.ui.OutlineOptionWidget.prototype.setRemovable = function ( removable ) {
-       this.removable = !!removable;
-       this.updateThemeClasses();
-       return this;
-};
-
-/**
- * Set indentation level.
- *
- * @param {number} [level=0] Indentation level, in the range of [0,#maxLevel]
- * @chainable
- */
-OO.ui.OutlineOptionWidget.prototype.setLevel = function ( level ) {
-       var levels = this.constructor.static.levels,
-               levelClass = this.constructor.static.levelClass,
-               i = levels;
-
-       this.level = level ? Math.max( 0, Math.min( levels - 1, level ) ) : 0;
-       while ( i-- ) {
-               if ( this.level === i ) {
-                       this.$element.addClass( levelClass + i );
-               } else {
-                       this.$element.removeClass( levelClass + i );
-               }
-       }
-       this.updateThemeClasses();
-
-       return this;
-};
-
-/**
- * OutlineSelectWidget is a structured list that contains {@link OO.ui.OutlineOptionWidget outline options}
- * A set of controls can be provided with an {@link OO.ui.OutlineControlsWidget outline controls} widget.
- *
- * **Currently, this class is only used by {@link OO.ui.BookletLayout booklet layouts}.**
- *
- * @class
- * @extends OO.ui.SelectWidget
- * @mixins OO.ui.mixin.TabIndexedElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.OutlineSelectWidget = function OoUiOutlineSelectWidget( config ) {
-       // Parent constructor
-       OO.ui.OutlineSelectWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.TabIndexedElement.call( this, config );
-
-       // Events
-       this.$element.on( {
-               focus: this.bindKeyDownListener.bind( this ),
-               blur: this.unbindKeyDownListener.bind( this )
-       } );
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-outlineSelectWidget' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.OutlineSelectWidget, OO.ui.SelectWidget );
-OO.mixinClass( OO.ui.OutlineSelectWidget, OO.ui.mixin.TabIndexedElement );
-
-/**
- * ButtonOptionWidget is a special type of {@link OO.ui.mixin.ButtonElement button element} that
- * can be selected and configured with data. The class is
- * used with OO.ui.ButtonSelectWidget to create a selection of button options. Please see the
- * [OOjs UI documentation on MediaWiki] [1] for more information.
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Button_selects_and_options
- *
- * @class
- * @extends OO.ui.DecoratedOptionWidget
- * @mixins OO.ui.mixin.ButtonElement
- * @mixins OO.ui.mixin.TabIndexedElement
- * @mixins OO.ui.mixin.TitledElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.ButtonOptionWidget = function OoUiButtonOptionWidget( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.ButtonOptionWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.ButtonElement.call( this, config );
-       OO.ui.mixin.TitledElement.call( this, $.extend( {}, config, { $titled: this.$button } ) );
-       OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, {
-               $tabIndexed: this.$button,
-               tabIndex: -1
-       } ) );
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-buttonOptionWidget' );
-       this.$button.append( this.$element.contents() );
-       this.$element.append( this.$button );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.ButtonOptionWidget, OO.ui.DecoratedOptionWidget );
-OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.ButtonElement );
-OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.TitledElement );
-OO.mixinClass( OO.ui.ButtonOptionWidget, OO.ui.mixin.TabIndexedElement );
-
-/* Static Properties */
-
-// Allow button mouse down events to pass through so they can be handled by the parent select widget
-OO.ui.ButtonOptionWidget.static.cancelButtonMouseDownEvents = false;
-
-OO.ui.ButtonOptionWidget.static.highlightable = false;
-
-/* Methods */
-
-/**
- * @inheritdoc
- */
-OO.ui.ButtonOptionWidget.prototype.setSelected = function ( state ) {
-       OO.ui.ButtonOptionWidget.parent.prototype.setSelected.call( this, state );
-
-       if ( this.constructor.static.selectable ) {
-               this.setActive( state );
-       }
-
-       return this;
-};
-
-/**
- * ButtonSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains
- * button options and is used together with
- * OO.ui.ButtonOptionWidget. The ButtonSelectWidget provides an interface for
- * highlighting, choosing, and selecting mutually exclusive options. Please see
- * the [OOjs UI documentation on MediaWiki] [1] for more information.
- *
- *     @example
- *     // Example: A ButtonSelectWidget that contains three ButtonOptionWidgets
- *     var option1 = new OO.ui.ButtonOptionWidget( {
- *         data: 1,
- *         label: 'Option 1',
- *         title: 'Button option 1'
- *     } );
- *
- *     var option2 = new OO.ui.ButtonOptionWidget( {
- *         data: 2,
- *         label: 'Option 2',
- *         title: 'Button option 2'
- *     } );
- *
- *     var option3 = new OO.ui.ButtonOptionWidget( {
- *         data: 3,
- *         label: 'Option 3',
- *         title: 'Button option 3'
- *     } );
- *
- *     var buttonSelect=new OO.ui.ButtonSelectWidget( {
- *         items: [ option1, option2, option3 ]
- *     } );
- *     $( 'body' ).append( buttonSelect.$element );
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
- *
- * @class
- * @extends OO.ui.SelectWidget
- * @mixins OO.ui.mixin.TabIndexedElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.ButtonSelectWidget = function OoUiButtonSelectWidget( config ) {
-       // Parent constructor
-       OO.ui.ButtonSelectWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.TabIndexedElement.call( this, config );
-
-       // Events
-       this.$element.on( {
-               focus: this.bindKeyDownListener.bind( this ),
-               blur: this.unbindKeyDownListener.bind( this )
-       } );
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-buttonSelectWidget' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.ButtonSelectWidget, OO.ui.SelectWidget );
-OO.mixinClass( OO.ui.ButtonSelectWidget, OO.ui.mixin.TabIndexedElement );
-
-/**
- * TabOptionWidget is an item in a {@link OO.ui.TabSelectWidget TabSelectWidget}.
- *
- * Currently, this class is only used by {@link OO.ui.IndexLayout index layouts}, which contain
- * {@link OO.ui.CardLayout card layouts}. See {@link OO.ui.IndexLayout IndexLayout}
- * for an example.
- *
- * @class
- * @extends OO.ui.OptionWidget
- *
- * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.TabOptionWidget = function OoUiTabOptionWidget( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.TabOptionWidget.parent.call( this, config );
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-tabOptionWidget' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.TabOptionWidget, OO.ui.OptionWidget );
-
-/* Static Properties */
-
-OO.ui.TabOptionWidget.static.highlightable = false;
-
-/**
- * TabSelectWidget is a list that contains {@link OO.ui.TabOptionWidget tab options}
- *
- * **Currently, this class is only used by {@link OO.ui.IndexLayout index layouts}.**
- *
- * @class
- * @extends OO.ui.SelectWidget
- * @mixins OO.ui.mixin.TabIndexedElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.TabSelectWidget = function OoUiTabSelectWidget( config ) {
-       // Parent constructor
-       OO.ui.TabSelectWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.TabIndexedElement.call( this, config );
-
-       // Events
-       this.$element.on( {
-               focus: this.bindKeyDownListener.bind( this ),
-               blur: this.unbindKeyDownListener.bind( this )
-       } );
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-tabSelectWidget' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.TabSelectWidget, OO.ui.SelectWidget );
-OO.mixinClass( OO.ui.TabSelectWidget, OO.ui.mixin.TabIndexedElement );
-
-/**
- * CapsuleItemWidgets are used within a {@link OO.ui.CapsuleMultiSelectWidget
- * CapsuleMultiSelectWidget} to display the selected items.
- *
- * @class
- * @extends OO.ui.Widget
- * @mixins OO.ui.mixin.ItemWidget
- * @mixins OO.ui.mixin.IndicatorElement
- * @mixins OO.ui.mixin.LabelElement
- * @mixins OO.ui.mixin.FlaggedElement
- * @mixins OO.ui.mixin.TabIndexedElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.CapsuleItemWidget = function OoUiCapsuleItemWidget( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.CapsuleItemWidget.parent.call( this, config );
-
-       // Properties (must be set before mixin constructor calls)
-       this.$indicator = $( '<span>' );
-
-       // Mixin constructors
-       OO.ui.mixin.ItemWidget.call( this );
-       OO.ui.mixin.IndicatorElement.call( this, $.extend( {}, config, { $indicator: this.$indicator, indicator: 'clear' } ) );
-       OO.ui.mixin.LabelElement.call( this, config );
-       OO.ui.mixin.FlaggedElement.call( this, config );
-       OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$indicator } ) );
-
-       // Events
-       this.$indicator.on( {
-               keydown: this.onCloseKeyDown.bind( this ),
-               click: this.onCloseClick.bind( this )
-       } );
-
-       // Initialization
-       this.$element
-               .addClass( 'oo-ui-capsuleItemWidget' )
-               .append( this.$indicator, this.$label );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.CapsuleItemWidget, OO.ui.Widget );
-OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.ItemWidget );
-OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.IndicatorElement );
-OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.LabelElement );
-OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.FlaggedElement );
-OO.mixinClass( OO.ui.CapsuleItemWidget, OO.ui.mixin.TabIndexedElement );
-
-/* Methods */
-
-/**
- * Handle close icon clicks
- * @param {jQuery.Event} event
- */
-OO.ui.CapsuleItemWidget.prototype.onCloseClick = function () {
-       var element = this.getElementGroup();
-
-       if ( !this.isDisabled() && element && $.isFunction( element.removeItems ) ) {
-               element.removeItems( [ this ] );
-               element.focus();
-       }
-};
-
-/**
- * Handle close keyboard events
- * @param {jQuery.Event} event Key down event
- */
-OO.ui.CapsuleItemWidget.prototype.onCloseKeyDown = function ( e ) {
-       if ( !this.isDisabled() && $.isFunction( this.getElementGroup().removeItems ) ) {
-               switch ( e.which ) {
-                       case OO.ui.Keys.ENTER:
-                       case OO.ui.Keys.BACKSPACE:
-                       case OO.ui.Keys.SPACE:
-                               this.getElementGroup().removeItems( [ this ] );
-                               return false;
-               }
-       }
-};
-
-/**
- * CapsuleMultiSelectWidgets are something like a {@link OO.ui.ComboBoxInputWidget combo box widget}
- * that allows for selecting multiple values.
- *
- * For more information about menus and options, please see the [OOjs UI documentation on MediaWiki][1].
- *
- *     @example
- *     // Example: A CapsuleMultiSelectWidget.
- *     var capsule = new OO.ui.CapsuleMultiSelectWidget( {
- *         label: 'CapsuleMultiSelectWidget',
- *         selected: [ 'Option 1', 'Option 3' ],
- *         menu: {
- *             items: [
- *                 new OO.ui.MenuOptionWidget( {
- *                     data: 'Option 1',
- *                     label: 'Option One'
- *                 } ),
- *                 new OO.ui.MenuOptionWidget( {
- *                     data: 'Option 2',
- *                     label: 'Option Two'
- *                 } ),
- *                 new OO.ui.MenuOptionWidget( {
- *                     data: 'Option 3',
- *                     label: 'Option Three'
- *                 } ),
- *                 new OO.ui.MenuOptionWidget( {
- *                     data: 'Option 4',
- *                     label: 'Option Four'
- *                 } ),
- *                 new OO.ui.MenuOptionWidget( {
- *                     data: 'Option 5',
- *                     label: 'Option Five'
- *                 } )
- *             ]
- *         }
- *     } );
- *     $( 'body' ).append( capsule.$element );
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options#Menu_selects_and_options
- *
- * @class
- * @extends OO.ui.Widget
- * @mixins OO.ui.mixin.TabIndexedElement
- * @mixins OO.ui.mixin.GroupElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {boolean} [allowArbitrary=false] Allow data items to be added even if not present in the menu.
- * @cfg {Object} [menu] Configuration options to pass to the {@link OO.ui.MenuSelectWidget menu select widget}.
- * @cfg {Object} [popup] Configuration options to pass to the {@link OO.ui.PopupWidget popup widget}.
- *  If specified, this popup will be shown instead of the menu (but the menu
- *  will still be used for item labels and allowArbitrary=false). The widgets
- *  in the popup should use this.addItemsFromData() or this.addItems() as necessary.
- * @cfg {jQuery} [$overlay] Render the menu or popup into a separate layer.
- *  This configuration is useful in cases where the expanded menu is larger than
- *  its containing `<div>`. The specified overlay layer is usually on top of
- *  the containing `<div>` and has a larger area. By default, the menu uses
- *  relative positioning.
- */
-OO.ui.CapsuleMultiSelectWidget = function OoUiCapsuleMultiSelectWidget( config ) {
-       var $tabFocus;
-
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.CapsuleMultiSelectWidget.parent.call( this, config );
-
-       // Properties (must be set before mixin constructor calls)
-       this.$input = config.popup ? null : $( '<input>' );
-       this.$handle = $( '<div>' );
-
-       // Mixin constructors
-       OO.ui.mixin.GroupElement.call( this, config );
-       if ( config.popup ) {
-               config.popup = $.extend( {}, config.popup, {
-                       align: 'forwards',
-                       anchor: false
-               } );
-               OO.ui.mixin.PopupElement.call( this, config );
-               $tabFocus = $( '<span>' );
-               OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: $tabFocus } ) );
-       } else {
-               this.popup = null;
-               $tabFocus = null;
-               OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$input } ) );
-       }
-       OO.ui.mixin.IndicatorElement.call( this, config );
-       OO.ui.mixin.IconElement.call( this, config );
-
-       // Properties
-       this.$content = $( '<div>' );
-       this.allowArbitrary = !!config.allowArbitrary;
-       this.$overlay = config.$overlay || this.$element;
-       this.menu = new OO.ui.FloatingMenuSelectWidget( $.extend(
-               {
-                       widget: this,
-                       $input: this.$input,
-                       $container: this.$element,
-                       filterFromInput: true,
-                       disabled: this.isDisabled()
-               },
-               config.menu
-       ) );
-
-       // Events
-       if ( this.popup ) {
-               $tabFocus.on( {
-                       focus: this.onFocusForPopup.bind( this )
-               } );
-               this.popup.$element.on( 'focusout', this.onPopupFocusOut.bind( this ) );
-               if ( this.popup.$autoCloseIgnore ) {
-                       this.popup.$autoCloseIgnore.on( 'focusout', this.onPopupFocusOut.bind( this ) );
-               }
-               this.popup.connect( this, {
-                       toggle: function ( visible ) {
-                               $tabFocus.toggle( !visible );
-                       }
-               } );
-       } else {
-               this.$input.on( {
-                       focus: this.onInputFocus.bind( this ),
-                       blur: this.onInputBlur.bind( this ),
-                       'propertychange change click mouseup keydown keyup input cut paste select focus':
-                               OO.ui.debounce( this.updateInputSize.bind( this ) ),
-                       keydown: this.onKeyDown.bind( this ),
-                       keypress: this.onKeyPress.bind( this )
-               } );
-       }
-       this.menu.connect( this, {
-               choose: 'onMenuChoose',
-               add: 'onMenuItemsChange',
-               remove: 'onMenuItemsChange'
-       } );
-       this.$handle.on( {
-               mousedown: this.onMouseDown.bind( this )
-       } );
-
-       // Initialization
-       if ( this.$input ) {
-               this.$input.prop( 'disabled', this.isDisabled() );
-               this.$input.attr( {
-                       role: 'combobox',
-                       'aria-autocomplete': 'list'
-               } );
-               this.updateInputSize();
-       }
-       if ( config.data ) {
-               this.setItemsFromData( config.data );
-       }
-       this.$content.addClass( 'oo-ui-capsuleMultiSelectWidget-content' )
-               .append( this.$group );
-       this.$group.addClass( 'oo-ui-capsuleMultiSelectWidget-group' );
-       this.$handle.addClass( 'oo-ui-capsuleMultiSelectWidget-handle' )
-               .append( this.$indicator, this.$icon, this.$content );
-       this.$element.addClass( 'oo-ui-capsuleMultiSelectWidget' )
-               .append( this.$handle );
-       if ( this.popup ) {
-               this.$content.append( $tabFocus );
-               this.$overlay.append( this.popup.$element );
-       } else {
-               this.$content.append( this.$input );
-               this.$overlay.append( this.menu.$element );
-       }
-       this.onMenuItemsChange();
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.Widget );
-OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.GroupElement );
-OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.PopupElement );
-OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.TabIndexedElement );
-OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.IndicatorElement );
-OO.mixinClass( OO.ui.CapsuleMultiSelectWidget, OO.ui.mixin.IconElement );
-
-/* Events */
-
-/**
- * @event change
- *
- * A change event is emitted when the set of selected items changes.
- *
- * @param {Mixed[]} datas Data of the now-selected items
- */
-
-/* Methods */
-
-/**
- * Construct a OO.ui.CapsuleItemWidget (or a subclass thereof) from given label and data.
- *
- * @protected
- * @param {Mixed} data Custom data of any type.
- * @param {string} label The label text.
- * @return {OO.ui.CapsuleItemWidget}
- */
-OO.ui.CapsuleMultiSelectWidget.prototype.createItemWidget = function ( data, label ) {
-       return new OO.ui.CapsuleItemWidget( { data: data, label: label } );
-};
-
-/**
- * Get the data of the items in the capsule
- * @return {Mixed[]}
- */
-OO.ui.CapsuleMultiSelectWidget.prototype.getItemsData = function () {
-       return $.map( this.getItems(), function ( e ) { return e.data; } );
-};
-
-/**
- * Set the items in the capsule by providing data
- * @chainable
- * @param {Mixed[]} datas
- * @return {OO.ui.CapsuleMultiSelectWidget}
- */
-OO.ui.CapsuleMultiSelectWidget.prototype.setItemsFromData = function ( datas ) {
-       var widget = this,
-               menu = this.menu,
-               items = this.getItems();
-
-       $.each( datas, function ( i, data ) {
-               var j, label,
-                       item = menu.getItemFromData( data );
-
-               if ( item ) {
-                       label = item.label;
-               } else if ( widget.allowArbitrary ) {
-                       label = String( data );
-               } else {
-                       return;
-               }
-
-               item = null;
-               for ( j = 0; j < items.length; j++ ) {
-                       if ( items[ j ].data === data && items[ j ].label === label ) {
-                               item = items[ j ];
-                               items.splice( j, 1 );
-                               break;
-                       }
-               }
-               if ( !item ) {
-                       item = widget.createItemWidget( data, label );
-               }
-               widget.addItems( [ item ], i );
-       } );
-
-       if ( items.length ) {
-               widget.removeItems( items );
-       }
-
-       return this;
-};
-
-/**
- * Add items to the capsule by providing their data
- * @chainable
- * @param {Mixed[]} datas
- * @return {OO.ui.CapsuleMultiSelectWidget}
- */
-OO.ui.CapsuleMultiSelectWidget.prototype.addItemsFromData = function ( datas ) {
-       var widget = this,
-               menu = this.menu,
-               items = [];
-
-       $.each( datas, function ( i, data ) {
-               var item;
-
-               if ( !widget.getItemFromData( data ) ) {
-                       item = menu.getItemFromData( data );
-                       if ( item ) {
-                               items.push( widget.createItemWidget( data, item.label ) );
-                       } else if ( widget.allowArbitrary ) {
-                               items.push( widget.createItemWidget( data, String( data ) ) );
-                       }
-               }
-       } );
-
-       if ( items.length ) {
-               this.addItems( items );
-       }
-
-       return this;
-};
-
-/**
- * Remove items by data
- * @chainable
- * @param {Mixed[]} datas
- * @return {OO.ui.CapsuleMultiSelectWidget}
- */
-OO.ui.CapsuleMultiSelectWidget.prototype.removeItemsFromData = function ( datas ) {
-       var widget = this,
-               items = [];
-
-       $.each( datas, function ( i, data ) {
-               var item = widget.getItemFromData( data );
-               if ( item ) {
-                       items.push( item );
-               }
-       } );
-
-       if ( items.length ) {
-               this.removeItems( items );
-       }
-
-       return this;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.CapsuleMultiSelectWidget.prototype.addItems = function ( items ) {
-       var same, i, l,
-               oldItems = this.items.slice();
-
-       OO.ui.mixin.GroupElement.prototype.addItems.call( this, items );
-
-       if ( this.items.length !== oldItems.length ) {
-               same = false;
-       } else {
-               same = true;
-               for ( i = 0, l = oldItems.length; same && i < l; i++ ) {
-                       same = same && this.items[ i ] === oldItems[ i ];
-               }
-       }
-       if ( !same ) {
-               this.emit( 'change', this.getItemsData() );
-               this.menu.position();
-       }
-
-       return this;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.CapsuleMultiSelectWidget.prototype.removeItems = function ( items ) {
-       var same, i, l,
-               oldItems = this.items.slice();
-
-       OO.ui.mixin.GroupElement.prototype.removeItems.call( this, items );
-
-       if ( this.items.length !== oldItems.length ) {
-               same = false;
-       } else {
-               same = true;
-               for ( i = 0, l = oldItems.length; same && i < l; i++ ) {
-                       same = same && this.items[ i ] === oldItems[ i ];
-               }
-       }
-       if ( !same ) {
-               this.emit( 'change', this.getItemsData() );
-               this.menu.position();
-       }
-
-       return this;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.CapsuleMultiSelectWidget.prototype.clearItems = function () {
-       if ( this.items.length ) {
-               OO.ui.mixin.GroupElement.prototype.clearItems.call( this );
-               this.emit( 'change', this.getItemsData() );
-               this.menu.position();
-       }
-       return this;
-};
-
-/**
- * Get the capsule widget's menu.
- * @return {OO.ui.MenuSelectWidget} Menu widget
- */
-OO.ui.CapsuleMultiSelectWidget.prototype.getMenu = function () {
-       return this.menu;
-};
-
-/**
- * Handle focus events
- *
- * @private
- * @param {jQuery.Event} event
- */
-OO.ui.CapsuleMultiSelectWidget.prototype.onInputFocus = function () {
-       if ( !this.isDisabled() ) {
-               this.menu.toggle( true );
-       }
-};
-
-/**
- * Handle blur events
- *
- * @private
- * @param {jQuery.Event} event
- */
-OO.ui.CapsuleMultiSelectWidget.prototype.onInputBlur = function () {
-       if ( this.allowArbitrary && this.$input.val().trim() !== '' ) {
-               this.addItemsFromData( [ this.$input.val() ] );
-       }
-       this.clearInput();
-};
-
-/**
- * Handle focus events
- *
- * @private
- * @param {jQuery.Event} event
- */
-OO.ui.CapsuleMultiSelectWidget.prototype.onFocusForPopup = function () {
-       if ( !this.isDisabled() ) {
-               this.popup.setSize( this.$handle.width() );
-               this.popup.toggle( true );
-               this.popup.$element.find( '*' )
-                       .filter( function () { return OO.ui.isFocusableElement( $( this ), true ); } )
-                       .first()
-                       .focus();
-       }
-};
-
-/**
- * Handles popup focus out events.
- *
- * @private
- * @param {Event} e Focus out event
- */
-OO.ui.CapsuleMultiSelectWidget.prototype.onPopupFocusOut = function () {
-       var widget = this.popup;
-
-       setTimeout( function () {
-               if (
-                       widget.isVisible() &&
-                       !OO.ui.contains( widget.$element[ 0 ], document.activeElement, true ) &&
-                       ( !widget.$autoCloseIgnore || !widget.$autoCloseIgnore.has( document.activeElement ).length )
-               ) {
-                       widget.toggle( false );
-               }
-       } );
-};
-
-/**
- * Handle mouse down events.
- *
- * @private
- * @param {jQuery.Event} e Mouse down event
- */
-OO.ui.CapsuleMultiSelectWidget.prototype.onMouseDown = function ( e ) {
-       if ( e.which === OO.ui.MouseButtons.LEFT ) {
-               this.focus();
-               return false;
-       } else {
-               this.updateInputSize();
-       }
-};
-
-/**
- * Handle key press events.
- *
- * @private
- * @param {jQuery.Event} e Key press event
- */
-OO.ui.CapsuleMultiSelectWidget.prototype.onKeyPress = function ( e ) {
-       var item;
-
-       if ( !this.isDisabled() ) {
-               if ( e.which === OO.ui.Keys.ESCAPE ) {
-                       this.clearInput();
-                       return false;
-               }
-
-               if ( !this.popup ) {
-                       this.menu.toggle( true );
-                       if ( e.which === OO.ui.Keys.ENTER ) {
-                               item = this.menu.getItemFromLabel( this.$input.val(), true );
-                               if ( item ) {
-                                       this.addItemsFromData( [ item.data ] );
-                                       this.clearInput();
-                               } else if ( this.allowArbitrary && this.$input.val().trim() !== '' ) {
-                                       this.addItemsFromData( [ this.$input.val() ] );
-                                       this.clearInput();
-                               }
-                               return false;
-                       }
-
-                       // Make sure the input gets resized.
-                       setTimeout( this.updateInputSize.bind( this ), 0 );
-               }
-       }
-};
-
-/**
- * Handle key down events.
- *
- * @private
- * @param {jQuery.Event} e Key down event
- */
-OO.ui.CapsuleMultiSelectWidget.prototype.onKeyDown = function ( e ) {
-       if ( !this.isDisabled() ) {
-               // 'keypress' event is not triggered for Backspace
-               if ( e.keyCode === OO.ui.Keys.BACKSPACE && this.$input.val() === '' ) {
-                       if ( this.items.length ) {
-                               this.removeItems( this.items.slice( -1 ) );
-                       }
-                       return false;
-               }
-       }
-};
-
-/**
- * Update the dimensions of the text input field to encompass all available area.
- *
- * @private
- * @param {jQuery.Event} e Event of some sort
- */
-OO.ui.CapsuleMultiSelectWidget.prototype.updateInputSize = function () {
-       var $lastItem, direction, contentWidth, currentWidth, bestWidth;
-       if ( !this.isDisabled() ) {
-               this.$input.css( 'width', '1em' );
-               $lastItem = this.$group.children().last();
-               direction = OO.ui.Element.static.getDir( this.$handle );
-               contentWidth = this.$input[ 0 ].scrollWidth;
-               currentWidth = this.$input.width();
-
-               if ( contentWidth < currentWidth ) {
-                       // All is fine, don't perform expensive calculations
-                       return;
-               }
-
-               if ( !$lastItem.length ) {
-                       bestWidth = this.$content.innerWidth();
-               } else {
-                       bestWidth = direction === 'ltr' ?
-                               this.$content.innerWidth() - $lastItem.position().left - $lastItem.outerWidth() :
-                               $lastItem.position().left;
-               }
-               // Some safety margin for sanity, because I *really* don't feel like finding out where the few
-               // pixels this is off by are coming from.
-               bestWidth -= 10;
-               if ( contentWidth > bestWidth ) {
-                       // This will result in the input getting shifted to the next line
-                       bestWidth = this.$content.innerWidth() - 10;
-               }
-               this.$input.width( Math.floor( bestWidth ) );
-
-               this.menu.position();
-       }
-};
-
-/**
- * Handle menu choose events.
- *
- * @private
- * @param {OO.ui.OptionWidget} item Chosen item
- */
-OO.ui.CapsuleMultiSelectWidget.prototype.onMenuChoose = function ( item ) {
-       if ( item && item.isVisible() ) {
-               this.addItemsFromData( [ item.getData() ] );
-               this.clearInput();
-       }
-};
-
-/**
- * Handle menu item change events.
- *
- * @private
- */
-OO.ui.CapsuleMultiSelectWidget.prototype.onMenuItemsChange = function () {
-       this.setItemsFromData( this.getItemsData() );
-       this.$element.toggleClass( 'oo-ui-capsuleMultiSelectWidget-empty', this.menu.isEmpty() );
-};
-
-/**
- * Clear the input field
- * @private
- */
-OO.ui.CapsuleMultiSelectWidget.prototype.clearInput = function () {
-       if ( this.$input ) {
-               this.$input.val( '' );
-               this.updateInputSize();
-       }
-       if ( this.popup ) {
-               this.popup.toggle( false );
-       }
-       this.menu.toggle( false );
-       this.menu.selectItem();
-       this.menu.highlightItem();
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.CapsuleMultiSelectWidget.prototype.setDisabled = function ( disabled ) {
-       var i, len;
-
-       // Parent method
-       OO.ui.CapsuleMultiSelectWidget.parent.prototype.setDisabled.call( this, disabled );
-
-       if ( this.$input ) {
-               this.$input.prop( 'disabled', this.isDisabled() );
-       }
-       if ( this.menu ) {
-               this.menu.setDisabled( this.isDisabled() );
-       }
-       if ( this.popup ) {
-               this.popup.setDisabled( this.isDisabled() );
-       }
-
-       if ( this.items ) {
-               for ( i = 0, len = this.items.length; i < len; i++ ) {
-                       this.items[ i ].updateDisabled();
-               }
-       }
-
-       return this;
-};
-
-/**
- * Focus the widget
- * @chainable
- * @return {OO.ui.CapsuleMultiSelectWidget}
- */
-OO.ui.CapsuleMultiSelectWidget.prototype.focus = function () {
-       if ( !this.isDisabled() ) {
-               if ( this.popup ) {
-                       this.popup.setSize( this.$handle.width() );
-                       this.popup.toggle( true );
-                       this.popup.$element.find( '*' )
-                               .filter( function () { return OO.ui.isFocusableElement( $( this ), true ); } )
-                               .first()
-                               .focus();
-               } else {
-                       this.updateInputSize();
-                       this.menu.toggle( true );
-                       this.$input.focus();
-               }
-       }
-       return this;
-};
-
-/**
- * SelectFileWidgets allow for selecting files, using the HTML5 File API. These
- * widgets can be configured with {@link OO.ui.mixin.IconElement icons} and {@link
- * OO.ui.mixin.IndicatorElement indicators}.
- * Please see the [OOjs UI documentation on MediaWiki] [1] for more information and examples.
- *
- *     @example
- *     // Example of a file select widget
- *     var selectFile = new OO.ui.SelectFileWidget();
- *     $( 'body' ).append( selectFile.$element );
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets
- *
- * @class
- * @extends OO.ui.Widget
- * @mixins OO.ui.mixin.IconElement
- * @mixins OO.ui.mixin.IndicatorElement
- * @mixins OO.ui.mixin.PendingElement
- * @mixins OO.ui.mixin.LabelElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {string[]|null} [accept=null] MIME types to accept. null accepts all types.
- * @cfg {string} [placeholder] Text to display when no file is selected.
- * @cfg {string} [notsupported] Text to display when file support is missing in the browser.
- * @cfg {boolean} [droppable=true] Whether to accept files by drag and drop.
- * @cfg {boolean} [showDropTarget=false] Whether to show a drop target. Requires droppable to be true.
- * @cfg {boolean} [dragDropUI=false] Deprecated alias for showDropTarget
- */
-OO.ui.SelectFileWidget = function OoUiSelectFileWidget( config ) {
-       var dragHandler;
-
-       // TODO: Remove in next release
-       if ( config && config.dragDropUI ) {
-               config.showDropTarget = true;
-       }
-
-       // Configuration initialization
-       config = $.extend( {
-               accept: null,
-               placeholder: OO.ui.msg( 'ooui-selectfile-placeholder' ),
-               notsupported: OO.ui.msg( 'ooui-selectfile-not-supported' ),
-               droppable: true,
-               showDropTarget: false
-       }, config );
-
-       // Parent constructor
-       OO.ui.SelectFileWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.IconElement.call( this, config );
-       OO.ui.mixin.IndicatorElement.call( this, config );
-       OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$info } ) );
-       OO.ui.mixin.LabelElement.call( this, $.extend( {}, config, { autoFitLabel: true } ) );
-
-       // Properties
-       this.$info = $( '<span>' );
-
-       // Properties
-       this.showDropTarget = config.showDropTarget;
-       this.isSupported = this.constructor.static.isSupported();
-       this.currentFile = null;
-       if ( Array.isArray( config.accept ) ) {
-               this.accept = config.accept;
-       } else {
-               this.accept = null;
-       }
-       this.placeholder = config.placeholder;
-       this.notsupported = config.notsupported;
-       this.onFileSelectedHandler = this.onFileSelected.bind( this );
-
-       this.selectButton = new OO.ui.ButtonWidget( {
-               classes: [ 'oo-ui-selectFileWidget-selectButton' ],
-               label: OO.ui.msg( 'ooui-selectfile-button-select' ),
-               disabled: this.disabled || !this.isSupported
-       } );
-
-       this.clearButton = new OO.ui.ButtonWidget( {
-               classes: [ 'oo-ui-selectFileWidget-clearButton' ],
-               framed: false,
-               icon: 'remove',
-               disabled: this.disabled
-       } );
-
-       // Events
-       this.selectButton.$button.on( {
-               keypress: this.onKeyPress.bind( this )
-       } );
-       this.clearButton.connect( this, {
-               click: 'onClearClick'
-       } );
-       if ( config.droppable ) {
-               dragHandler = this.onDragEnterOrOver.bind( this );
-               this.$element.on( {
-                       dragenter: dragHandler,
-                       dragover: dragHandler,
-                       dragleave: this.onDragLeave.bind( this ),
-                       drop: this.onDrop.bind( this )
-               } );
-       }
-
-       // Initialization
-       this.addInput();
-       this.updateUI();
-       this.$label.addClass( 'oo-ui-selectFileWidget-label' );
-       this.$info
-               .addClass( 'oo-ui-selectFileWidget-info' )
-               .append( this.$icon, this.$label, this.clearButton.$element, this.$indicator );
-       this.$element
-               .addClass( 'oo-ui-selectFileWidget' )
-               .append( this.$info, this.selectButton.$element );
-       if ( config.droppable && config.showDropTarget ) {
-               this.$dropTarget = $( '<div>' )
-                       .addClass( 'oo-ui-selectFileWidget-dropTarget' )
-                       .text( OO.ui.msg( 'ooui-selectfile-dragdrop-placeholder' ) )
-                       .on( {
-                               click: this.onDropTargetClick.bind( this )
-                       } );
-               this.$element.prepend( this.$dropTarget );
-       }
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.SelectFileWidget, OO.ui.Widget );
-OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.IconElement );
-OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.IndicatorElement );
-OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.PendingElement );
-OO.mixinClass( OO.ui.SelectFileWidget, OO.ui.mixin.LabelElement );
-
-/* Static Properties */
-
-/**
- * Check if this widget is supported
- *
- * @static
- * @return {boolean}
- */
-OO.ui.SelectFileWidget.static.isSupported = function () {
-       var $input;
-       if ( OO.ui.SelectFileWidget.static.isSupportedCache === null ) {
-               $input = $( '<input type="file">' );
-               OO.ui.SelectFileWidget.static.isSupportedCache = $input[ 0 ].files !== undefined;
-       }
-       return OO.ui.SelectFileWidget.static.isSupportedCache;
-};
-
-OO.ui.SelectFileWidget.static.isSupportedCache = null;
-
-/* Events */
-
-/**
- * @event change
- *
- * A change event is emitted when the on/off state of the toggle changes.
- *
- * @param {File|null} value New value
- */
-
-/* Methods */
-
-/**
- * Get the current value of the field
- *
- * @return {File|null}
- */
-OO.ui.SelectFileWidget.prototype.getValue = function () {
-       return this.currentFile;
-};
-
-/**
- * Set the current value of the field
- *
- * @param {File|null} file File to select
- */
-OO.ui.SelectFileWidget.prototype.setValue = function ( file ) {
-       if ( this.currentFile !== file ) {
-               this.currentFile = file;
-               this.updateUI();
-               this.emit( 'change', this.currentFile );
-       }
-};
-
-/**
- * Focus the widget.
- *
- * Focusses the select file button.
- *
- * @chainable
- */
-OO.ui.SelectFileWidget.prototype.focus = function () {
-       this.selectButton.$button[ 0 ].focus();
-       return this;
-};
-
-/**
- * Update the user interface when a file is selected or unselected
- *
- * @protected
- */
-OO.ui.SelectFileWidget.prototype.updateUI = function () {
-       var $label;
-       if ( !this.isSupported ) {
-               this.$element.addClass( 'oo-ui-selectFileWidget-notsupported' );
-               this.$element.removeClass( 'oo-ui-selectFileWidget-empty' );
-               this.setLabel( this.notsupported );
-       } else {
-               this.$element.addClass( 'oo-ui-selectFileWidget-supported' );
-               if ( this.currentFile ) {
-                       this.$element.removeClass( 'oo-ui-selectFileWidget-empty' );
-                       $label = $( [] );
-                       $label = $label.add(
-                               $( '<span>' )
-                                       .addClass( 'oo-ui-selectFileWidget-fileName' )
-                                       .text( this.currentFile.name )
-                       );
-                       if ( this.currentFile.type !== '' ) {
-                               $label = $label.add(
-                                       $( '<span>' )
-                                               .addClass( 'oo-ui-selectFileWidget-fileType' )
-                                               .text( this.currentFile.type )
-                               );
-                       }
-                       this.setLabel( $label );
-               } else {
-                       this.$element.addClass( 'oo-ui-selectFileWidget-empty' );
-                       this.setLabel( this.placeholder );
-               }
-       }
-};
-
-/**
- * Add the input to the widget
- *
- * @private
- */
-OO.ui.SelectFileWidget.prototype.addInput = function () {
-       if ( this.$input ) {
-               this.$input.remove();
-       }
-
-       if ( !this.isSupported ) {
-               this.$input = null;
-               return;
-       }
-
-       this.$input = $( '<input type="file">' );
-       this.$input.on( 'change', this.onFileSelectedHandler );
-       this.$input.attr( {
-               tabindex: -1
-       } );
-       if ( this.accept ) {
-               this.$input.attr( 'accept', this.accept.join( ', ' ) );
-       }
-       this.selectButton.$button.append( this.$input );
-};
-
-/**
- * Determine if we should accept this file
- *
- * @private
- * @param {string} File MIME type
- * @return {boolean}
- */
-OO.ui.SelectFileWidget.prototype.isAllowedType = function ( mimeType ) {
-       var i, mimeTest;
-
-       if ( !this.accept || !mimeType ) {
-               return true;
-       }
-
-       for ( i = 0; i < this.accept.length; i++ ) {
-               mimeTest = this.accept[ i ];
-               if ( mimeTest === mimeType ) {
-                       return true;
-               } else if ( mimeTest.substr( -2 ) === '/*' ) {
-                       mimeTest = mimeTest.substr( 0, mimeTest.length - 1 );
-                       if ( mimeType.substr( 0, mimeTest.length ) === mimeTest ) {
-                               return true;
-                       }
-               }
-       }
-
-       return false;
-};
-
-/**
- * Handle file selection from the input
- *
- * @private
- * @param {jQuery.Event} e
- */
-OO.ui.SelectFileWidget.prototype.onFileSelected = function ( e ) {
-       var file = OO.getProp( e.target, 'files', 0 ) || null;
-
-       if ( file && !this.isAllowedType( file.type ) ) {
-               file = null;
-       }
-
-       this.setValue( file );
-       this.addInput();
-};
-
-/**
- * Handle clear button click events.
- *
- * @private
- */
-OO.ui.SelectFileWidget.prototype.onClearClick = function () {
-       this.setValue( null );
-       return false;
-};
-
-/**
- * Handle key press events.
- *
- * @private
- * @param {jQuery.Event} e Key press event
- */
-OO.ui.SelectFileWidget.prototype.onKeyPress = function ( e ) {
-       if ( this.isSupported && !this.isDisabled() && this.$input &&
-               ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
-       ) {
-               this.$input.click();
-               return false;
-       }
-};
-
-/**
- * Handle drop target click events.
- *
- * @private
- * @param {jQuery.Event} e Key press event
- */
-OO.ui.SelectFileWidget.prototype.onDropTargetClick = function () {
-       if ( this.isSupported && !this.isDisabled() && this.$input ) {
-               this.$input.click();
-               return false;
-       }
-};
-
-/**
- * Handle drag enter and over events
- *
- * @private
- * @param {jQuery.Event} e Drag event
- */
-OO.ui.SelectFileWidget.prototype.onDragEnterOrOver = function ( e ) {
-       var itemOrFile,
-               droppableFile = false,
-               dt = e.originalEvent.dataTransfer;
-
-       e.preventDefault();
-       e.stopPropagation();
-
-       if ( this.isDisabled() || !this.isSupported ) {
-               this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
-               dt.dropEffect = 'none';
-               return false;
-       }
-
-       // DataTransferItem and File both have a type property, but in Chrome files
-       // have no information at this point.
-       itemOrFile = OO.getProp( dt, 'items', 0 ) || OO.getProp( dt, 'files', 0 );
-       if ( itemOrFile ) {
-               if ( this.isAllowedType( itemOrFile.type ) ) {
-                       droppableFile = true;
-               }
-       // dt.types is Array-like, but not an Array
-       } else if ( Array.prototype.indexOf.call( OO.getProp( dt, 'types' ) || [], 'Files' ) !== -1 ) {
-               // File information is not available at this point for security so just assume
-               // it is acceptable for now.
-               // https://bugzilla.mozilla.org/show_bug.cgi?id=640534
-               droppableFile = true;
-       }
-
-       this.$element.toggleClass( 'oo-ui-selectFileWidget-canDrop', droppableFile );
-       if ( !droppableFile ) {
-               dt.dropEffect = 'none';
-       }
-
-       return false;
-};
-
-/**
- * Handle drag leave events
- *
- * @private
- * @param {jQuery.Event} e Drag event
- */
-OO.ui.SelectFileWidget.prototype.onDragLeave = function () {
-       this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
-};
-
-/**
- * Handle drop events
- *
- * @private
- * @param {jQuery.Event} e Drop event
- */
-OO.ui.SelectFileWidget.prototype.onDrop = function ( e ) {
-       var file = null,
-               dt = e.originalEvent.dataTransfer;
-
-       e.preventDefault();
-       e.stopPropagation();
-       this.$element.removeClass( 'oo-ui-selectFileWidget-canDrop' );
-
-       if ( this.isDisabled() || !this.isSupported ) {
-               return false;
-       }
-
-       file = OO.getProp( dt, 'files', 0 );
-       if ( file && !this.isAllowedType( file.type ) ) {
-               file = null;
-       }
-       if ( file ) {
-               this.setValue( file );
-       }
-
-       return false;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.SelectFileWidget.prototype.setDisabled = function ( disabled ) {
-       OO.ui.SelectFileWidget.parent.prototype.setDisabled.call( this, disabled );
-       if ( this.selectButton ) {
-               this.selectButton.setDisabled( disabled );
-       }
-       if ( this.clearButton ) {
-               this.clearButton.setDisabled( disabled );
-       }
-       return this;
-};
-
-/**
- * Progress bars visually display the status of an operation, such as a download,
- * and can be either determinate or indeterminate:
- *
- * - **determinate** process bars show the percent of an operation that is complete.
- *
- * - **indeterminate** process bars use a visual display of motion to indicate that an operation
- *   is taking place. Because the extent of an indeterminate operation is unknown, the bar does
- *   not use percentages.
- *
- * The value of the `progress` configuration determines whether the bar is determinate or indeterminate.
- *
- *     @example
- *     // Examples of determinate and indeterminate progress bars.
- *     var progressBar1 = new OO.ui.ProgressBarWidget( {
- *         progress: 33
- *     } );
- *     var progressBar2 = new OO.ui.ProgressBarWidget();
- *
- *     // Create a FieldsetLayout to layout progress bars
- *     var fieldset = new OO.ui.FieldsetLayout;
- *     fieldset.addItems( [
- *        new OO.ui.FieldLayout( progressBar1, {label: 'Determinate', align: 'top'}),
- *        new OO.ui.FieldLayout( progressBar2, {label: 'Indeterminate', align: 'top'})
- *     ] );
- *     $( 'body' ).append( fieldset.$element );
- *
- * @class
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {number|boolean} [progress=false] The type of progress bar (determinate or indeterminate).
- *  To create a determinate progress bar, specify a number that reflects the initial percent complete.
- *  By default, the progress bar is indeterminate.
- */
-OO.ui.ProgressBarWidget = function OoUiProgressBarWidget( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.ProgressBarWidget.parent.call( this, config );
-
-       // Properties
-       this.$bar = $( '<div>' );
-       this.progress = null;
-
-       // Initialization
-       this.setProgress( config.progress !== undefined ? config.progress : false );
-       this.$bar.addClass( 'oo-ui-progressBarWidget-bar' );
-       this.$element
-               .attr( {
-                       role: 'progressbar',
-                       'aria-valuemin': 0,
-                       'aria-valuemax': 100
-               } )
-               .addClass( 'oo-ui-progressBarWidget' )
-               .append( this.$bar );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.ProgressBarWidget, OO.ui.Widget );
-
-/* Static Properties */
-
-OO.ui.ProgressBarWidget.static.tagName = 'div';
-
-/* Methods */
-
-/**
- * Get the percent of the progress that has been completed. Indeterminate progresses will return `false`.
- *
- * @return {number|boolean} Progress percent
- */
-OO.ui.ProgressBarWidget.prototype.getProgress = function () {
-       return this.progress;
-};
-
-/**
- * Set the percent of the process completed or `false` for an indeterminate process.
- *
- * @param {number|boolean} progress Progress percent or `false` for indeterminate
- */
-OO.ui.ProgressBarWidget.prototype.setProgress = function ( progress ) {
-       this.progress = progress;
-
-       if ( progress !== false ) {
-               this.$bar.css( 'width', this.progress + '%' );
-               this.$element.attr( 'aria-valuenow', this.progress );
-       } else {
-               this.$bar.css( 'width', '' );
-               this.$element.removeAttr( 'aria-valuenow' );
-       }
-       this.$element.toggleClass( 'oo-ui-progressBarWidget-indeterminate', !progress );
-};
-
-/**
- * SearchWidgets combine a {@link OO.ui.TextInputWidget text input field}, where users can type a search query,
- * and a menu of search results, which is displayed beneath the query
- * field. Unlike {@link OO.ui.mixin.LookupElement lookup menus}, search result menus are always visible to the user.
- * Users can choose an item from the menu or type a query into the text field to search for a matching result item.
- * In general, search widgets are used inside a separate {@link OO.ui.Dialog dialog} window.
- *
- * Each time the query is changed, the search result menu is cleared and repopulated. Please see
- * the [OOjs UI demos][1] for an example.
- *
- * [1]: https://tools.wmflabs.org/oojs-ui/oojs-ui/demos/#dialogs-mediawiki-vector-ltr
- *
- * @class
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {string|jQuery} [placeholder] Placeholder text for query input
- * @cfg {string} [value] Initial query value
- */
-OO.ui.SearchWidget = function OoUiSearchWidget( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.SearchWidget.parent.call( this, config );
-
-       // Properties
-       this.query = new OO.ui.TextInputWidget( {
-               icon: 'search',
-               placeholder: config.placeholder,
-               value: config.value
-       } );
-       this.results = new OO.ui.SelectWidget();
-       this.$query = $( '<div>' );
-       this.$results = $( '<div>' );
-
-       // Events
-       this.query.connect( this, {
-               change: 'onQueryChange',
-               enter: 'onQueryEnter'
-       } );
-       this.query.$input.on( 'keydown', this.onQueryKeydown.bind( this ) );
-
-       // Initialization
-       this.$query
-               .addClass( 'oo-ui-searchWidget-query' )
-               .append( this.query.$element );
-       this.$results
-               .addClass( 'oo-ui-searchWidget-results' )
-               .append( this.results.$element );
-       this.$element
-               .addClass( 'oo-ui-searchWidget' )
-               .append( this.$results, this.$query );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.SearchWidget, OO.ui.Widget );
-
-/* Methods */
-
-/**
- * Handle query key down events.
- *
- * @private
- * @param {jQuery.Event} e Key down event
- */
-OO.ui.SearchWidget.prototype.onQueryKeydown = function ( e ) {
-       var highlightedItem, nextItem,
-               dir = e.which === OO.ui.Keys.DOWN ? 1 : ( e.which === OO.ui.Keys.UP ? -1 : 0 );
-
-       if ( dir ) {
-               highlightedItem = this.results.getHighlightedItem();
-               if ( !highlightedItem ) {
-                       highlightedItem = this.results.getSelectedItem();
-               }
-               nextItem = this.results.getRelativeSelectableItem( highlightedItem, dir );
-               this.results.highlightItem( nextItem );
-               nextItem.scrollElementIntoView();
-       }
-};
-
-/**
- * Handle select widget select events.
- *
- * Clears existing results. Subclasses should repopulate items according to new query.
- *
- * @private
- * @param {string} value New value
- */
-OO.ui.SearchWidget.prototype.onQueryChange = function () {
-       // Reset
-       this.results.clearItems();
-};
-
-/**
- * Handle select widget enter key events.
- *
- * Chooses highlighted item.
- *
- * @private
- * @param {string} value New value
- */
-OO.ui.SearchWidget.prototype.onQueryEnter = function () {
-       var highlightedItem = this.results.getHighlightedItem();
-       if ( highlightedItem ) {
-               this.results.chooseItem( highlightedItem );
-       }
-};
-
-/**
- * Get the query input.
- *
- * @return {OO.ui.TextInputWidget} Query input
- */
-OO.ui.SearchWidget.prototype.getQuery = function () {
-       return this.query;
-};
-
-/**
- * Get the search results menu.
- *
- * @return {OO.ui.SelectWidget} Menu of search results
- */
-OO.ui.SearchWidget.prototype.getResults = function () {
-       return this.results;
-};
-
-/**
- * NumberInputWidgets combine a {@link OO.ui.TextInputWidget text input} (where a value
- * can be entered manually) and two {@link OO.ui.ButtonWidget button widgets}
- * (to adjust the value in increments) to allow the user to enter a number.
- *
- *     @example
- *     // Example: A NumberInputWidget.
- *     var numberInput = new OO.ui.NumberInputWidget( {
- *         label: 'NumberInputWidget',
- *         input: { value: 5, min: 1, max: 10 }
- *     } );
- *     $( 'body' ).append( numberInput.$element );
- *
- * @class
- * @extends OO.ui.Widget
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {Object} [input] Configuration options to pass to the {@link OO.ui.TextInputWidget text input widget}.
- * @cfg {Object} [minusButton] Configuration options to pass to the {@link OO.ui.ButtonWidget decrementing button widget}.
- * @cfg {Object} [plusButton] Configuration options to pass to the {@link OO.ui.ButtonWidget incrementing button widget}.
- * @cfg {boolean} [isInteger=false] Whether the field accepts only integer values.
- * @cfg {number} [min=-Infinity] Minimum allowed value
- * @cfg {number} [max=Infinity] Maximum allowed value
- * @cfg {number} [step=1] Delta when using the buttons or up/down arrow keys
- * @cfg {number|null} [pageStep] Delta when using the page-up/page-down keys. Defaults to 10 times #step.
- */
-OO.ui.NumberInputWidget = function OoUiNumberInputWidget( config ) {
-       // Configuration initialization
-       config = $.extend( {
-               isInteger: false,
-               min: -Infinity,
-               max: Infinity,
-               step: 1,
-               pageStep: null
-       }, config );
-
-       // Parent constructor
-       OO.ui.NumberInputWidget.parent.call( this, config );
-
-       // Properties
-       this.input = new OO.ui.TextInputWidget( $.extend(
-               {
-                       disabled: this.isDisabled()
-               },
-               config.input
-       ) );
-       this.minusButton = new OO.ui.ButtonWidget( $.extend(
-               {
-                       disabled: this.isDisabled(),
-                       tabIndex: -1
-               },
-               config.minusButton,
-               {
-                       classes: [ 'oo-ui-numberInputWidget-minusButton' ],
-                       label: '−'
-               }
-       ) );
-       this.plusButton = new OO.ui.ButtonWidget( $.extend(
-               {
-                       disabled: this.isDisabled(),
-                       tabIndex: -1
-               },
-               config.plusButton,
-               {
-                       classes: [ 'oo-ui-numberInputWidget-plusButton' ],
-                       label: '+'
-               }
-       ) );
-
-       // Events
-       this.input.connect( this, {
-               change: this.emit.bind( this, 'change' ),
-               enter: this.emit.bind( this, 'enter' )
-       } );
-       this.input.$input.on( {
-               keydown: this.onKeyDown.bind( this ),
-               'wheel mousewheel DOMMouseScroll': this.onWheel.bind( this )
-       } );
-       this.plusButton.connect( this, {
-               click: [ 'onButtonClick', +1 ]
-       } );
-       this.minusButton.connect( this, {
-               click: [ 'onButtonClick', -1 ]
-       } );
-
-       // Initialization
-       this.setIsInteger( !!config.isInteger );
-       this.setRange( config.min, config.max );
-       this.setStep( config.step, config.pageStep );
-
-       this.$field = $( '<div>' ).addClass( 'oo-ui-numberInputWidget-field' )
-               .append(
-                       this.minusButton.$element,
-                       this.input.$element,
-                       this.plusButton.$element
-               );
-       this.$element.addClass( 'oo-ui-numberInputWidget' ).append( this.$field );
-       this.input.setValidation( this.validateNumber.bind( this ) );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.NumberInputWidget, OO.ui.Widget );
-
-/* Events */
-
-/**
- * A `change` event is emitted when the value of the input changes.
- *
- * @event change
- */
-
-/**
- * An `enter` event is emitted when the user presses 'enter' inside the text box.
- *
- * @event enter
- */
-
-/* Methods */
-
-/**
- * Set whether only integers are allowed
- * @param {boolean} flag
- */
-OO.ui.NumberInputWidget.prototype.setIsInteger = function ( flag ) {
-       this.isInteger = !!flag;
-       this.input.setValidityFlag();
-};
-
-/**
- * Get whether only integers are allowed
- * @return {boolean} Flag value
- */
-OO.ui.NumberInputWidget.prototype.getIsInteger = function () {
-       return this.isInteger;
-};
-
-/**
- * Set the range of allowed values
- * @param {number} min Minimum allowed value
- * @param {number} max Maximum allowed value
- */
-OO.ui.NumberInputWidget.prototype.setRange = function ( min, max ) {
-       if ( min > max ) {
-               throw new Error( 'Minimum (' + min + ') must not be greater than maximum (' + max + ')' );
-       }
-       this.min = min;
-       this.max = max;
-       this.input.setValidityFlag();
-};
-
-/**
- * Get the current range
- * @return {number[]} Minimum and maximum values
- */
-OO.ui.NumberInputWidget.prototype.getRange = function () {
-       return [ this.min, this.max ];
-};
-
-/**
- * Set the stepping deltas
- * @param {number} step Normal step
- * @param {number|null} pageStep Page step. If null, 10 * step will be used.
- */
-OO.ui.NumberInputWidget.prototype.setStep = function ( step, pageStep ) {
-       if ( step <= 0 ) {
-               throw new Error( 'Step value must be positive' );
-       }
-       if ( pageStep === null ) {
-               pageStep = step * 10;
-       } else if ( pageStep <= 0 ) {
-               throw new Error( 'Page step value must be positive' );
-       }
-       this.step = step;
-       this.pageStep = pageStep;
-};
-
-/**
- * Get the current stepping values
- * @return {number[]} Step and page step
- */
-OO.ui.NumberInputWidget.prototype.getStep = function () {
-       return [ this.step, this.pageStep ];
-};
-
-/**
- * Get the current value of the widget
- * @return {string}
- */
-OO.ui.NumberInputWidget.prototype.getValue = function () {
-       return this.input.getValue();
-};
-
-/**
- * Get the current value of the widget as a number
- * @return {number} May be NaN, or an invalid number
- */
-OO.ui.NumberInputWidget.prototype.getNumericValue = function () {
-       return +this.input.getValue();
-};
-
-/**
- * Set the value of the widget
- * @param {string} value Invalid values are allowed
- */
-OO.ui.NumberInputWidget.prototype.setValue = function ( value ) {
-       this.input.setValue( value );
-};
-
-/**
- * Adjust the value of the widget
- * @param {number} delta Adjustment amount
- */
-OO.ui.NumberInputWidget.prototype.adjustValue = function ( delta ) {
-       var n, v = this.getNumericValue();
-
-       delta = +delta;
-       if ( isNaN( delta ) || !isFinite( delta ) ) {
-               throw new Error( 'Delta must be a finite number' );
-       }
-
-       if ( isNaN( v ) ) {
-               n = 0;
-       } else {
-               n = v + delta;
-               n = Math.max( Math.min( n, this.max ), this.min );
-               if ( this.isInteger ) {
-                       n = Math.round( n );
-               }
-       }
-
-       if ( n !== v ) {
-               this.setValue( n );
-       }
-};
-
-/**
- * Validate input
- * @private
- * @param {string} value Field value
- * @return {boolean}
- */
-OO.ui.NumberInputWidget.prototype.validateNumber = function ( value ) {
-       var n = +value;
-       if ( isNaN( n ) || !isFinite( n ) ) {
-               return false;
-       }
-
-       /*jshint bitwise: false */
-       if ( this.isInteger && ( n | 0 ) !== n ) {
-               return false;
-       }
-       /*jshint bitwise: true */
-
-       if ( n < this.min || n > this.max ) {
-               return false;
-       }
-
-       return true;
-};
-
-/**
- * Handle mouse click events.
- *
- * @private
- * @param {number} dir +1 or -1
- */
-OO.ui.NumberInputWidget.prototype.onButtonClick = function ( dir ) {
-       this.adjustValue( dir * this.step );
-};
-
-/**
- * Handle mouse wheel events.
- *
- * @private
- * @param {jQuery.Event} event
- */
-OO.ui.NumberInputWidget.prototype.onWheel = function ( event ) {
-       var delta = 0;
-
-       // Standard 'wheel' event
-       if ( event.originalEvent.deltaMode !== undefined ) {
-               this.sawWheelEvent = true;
-       }
-       if ( event.originalEvent.deltaY ) {
-               delta = -event.originalEvent.deltaY;
-       } else if ( event.originalEvent.deltaX ) {
-               delta = event.originalEvent.deltaX;
-       }
-
-       // Non-standard events
-       if ( !this.sawWheelEvent ) {
-               if ( event.originalEvent.wheelDeltaX ) {
-                       delta = -event.originalEvent.wheelDeltaX;
-               } else if ( event.originalEvent.wheelDeltaY ) {
-                       delta = event.originalEvent.wheelDeltaY;
-               } else if ( event.originalEvent.wheelDelta ) {
-                       delta = event.originalEvent.wheelDelta;
-               } else if ( event.originalEvent.detail ) {
-                       delta = -event.originalEvent.detail;
-               }
-       }
-
-       if ( delta ) {
-               delta = delta < 0 ? -1 : 1;
-               this.adjustValue( delta * this.step );
-       }
-
-       return false;
-};
-
-/**
- * Handle key down events.
- *
- * @private
- * @param {jQuery.Event} e Key down event
- */
-OO.ui.NumberInputWidget.prototype.onKeyDown = function ( e ) {
-       if ( !this.isDisabled() ) {
-               switch ( e.which ) {
-                       case OO.ui.Keys.UP:
-                               this.adjustValue( this.step );
-                               return false;
-                       case OO.ui.Keys.DOWN:
-                               this.adjustValue( -this.step );
-                               return false;
-                       case OO.ui.Keys.PAGEUP:
-                               this.adjustValue( this.pageStep );
-                               return false;
-                       case OO.ui.Keys.PAGEDOWN:
-                               this.adjustValue( -this.pageStep );
-                               return false;
-               }
-       }
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.NumberInputWidget.prototype.setDisabled = function ( disabled ) {
-       // Parent method
-       OO.ui.NumberInputWidget.parent.prototype.setDisabled.call( this, disabled );
-
-       if ( this.input ) {
-               this.input.setDisabled( this.isDisabled() );
-       }
-       if ( this.minusButton ) {
-               this.minusButton.setDisabled( this.isDisabled() );
-       }
-       if ( this.plusButton ) {
-               this.plusButton.setDisabled( this.isDisabled() );
-       }
-
-       return this;
-};
-
-}( OO ) );
-
-/*!
- * OOjs UI v0.15.2
- * https://www.mediawiki.org/wiki/OOjs_UI
- *
- * Copyright 2011–2016 OOjs UI Team and other contributors.
- * Released under the MIT license
- * http://oojs.mit-license.org
- *
- * Date: 2016-02-02T22:07:00Z
- */
-( function ( OO ) {
-
-'use strict';
-
-/**
- * Toolbars are complex interface components that permit users to easily access a variety
- * of {@link OO.ui.Tool tools} (e.g., formatting commands) and actions, which are additional commands that are
- * part of the toolbar, but not configured as tools.
- *
- * Individual tools are customized and then registered with a {@link OO.ui.ToolFactory tool factory}, which creates
- * the tools on demand. Each tool has a symbolic name (used when registering the tool), a title (e.g., ‘Insert
- * image’), and an icon.
- *
- * Individual tools are organized in {@link OO.ui.ToolGroup toolgroups}, which can be {@link OO.ui.MenuToolGroup menus}
- * of tools, {@link OO.ui.ListToolGroup lists} of tools, or a single {@link OO.ui.BarToolGroup bar} of tools.
- * The arrangement and order of the toolgroups is customized when the toolbar is set up. Tools can be presented in
- * any order, but each can only appear once in the toolbar.
- *
- * The toolbar can be synchronized with the state of the external "application", like a text
- * editor's editing area, marking tools as active/inactive (e.g. a 'bold' tool would be shown as
- * active when the text cursor was inside bolded text) or enabled/disabled (e.g. a table caption
- * tool would be disabled while the user is not editing a table). A state change is signalled by
- * emitting the {@link #event-updateState 'updateState' event}, which calls Tools'
- * {@link OO.ui.Tool#onUpdateState onUpdateState method}.
- *
- * The following is an example of a basic toolbar.
- *
- *     @example
- *     // Example of a toolbar
- *     // Create the toolbar
- *     var toolFactory = new OO.ui.ToolFactory();
- *     var toolGroupFactory = new OO.ui.ToolGroupFactory();
- *     var toolbar = new OO.ui.Toolbar( toolFactory, toolGroupFactory );
- *
- *     // We will be placing status text in this element when tools are used
- *     var $area = $( '<p>' ).text( 'Toolbar example' );
- *
- *     // Define the tools that we're going to place in our toolbar
- *
- *     // Create a class inheriting from OO.ui.Tool
- *     function SearchTool() {
- *         SearchTool.parent.apply( this, arguments );
- *     }
- *     OO.inheritClass( SearchTool, OO.ui.Tool );
- *     // Each tool must have a 'name' (used as an internal identifier, see later) and at least one
- *     // of 'icon' and 'title' (displayed icon and text).
- *     SearchTool.static.name = 'search';
- *     SearchTool.static.icon = 'search';
- *     SearchTool.static.title = 'Search...';
- *     // Defines the action that will happen when this tool is selected (clicked).
- *     SearchTool.prototype.onSelect = function () {
- *         $area.text( 'Search tool clicked!' );
- *         // Never display this tool as "active" (selected).
- *         this.setActive( false );
- *     };
- *     SearchTool.prototype.onUpdateState = function () {};
- *     // Make this tool available in our toolFactory and thus our toolbar
- *     toolFactory.register( SearchTool );
- *
- *     // Register two more tools, nothing interesting here
- *     function SettingsTool() {
- *         SettingsTool.parent.apply( this, arguments );
- *     }
- *     OO.inheritClass( SettingsTool, OO.ui.Tool );
- *     SettingsTool.static.name = 'settings';
- *     SettingsTool.static.icon = 'settings';
- *     SettingsTool.static.title = 'Change settings';
- *     SettingsTool.prototype.onSelect = function () {
- *         $area.text( 'Settings tool clicked!' );
- *         this.setActive( false );
- *     };
- *     SettingsTool.prototype.onUpdateState = function () {};
- *     toolFactory.register( SettingsTool );
- *
- *     // Register two more tools, nothing interesting here
- *     function StuffTool() {
- *         StuffTool.parent.apply( this, arguments );
- *     }
- *     OO.inheritClass( StuffTool, OO.ui.Tool );
- *     StuffTool.static.name = 'stuff';
- *     StuffTool.static.icon = 'ellipsis';
- *     StuffTool.static.title = 'More stuff';
- *     StuffTool.prototype.onSelect = function () {
- *         $area.text( 'More stuff tool clicked!' );
- *         this.setActive( false );
- *     };
- *     StuffTool.prototype.onUpdateState = function () {};
- *     toolFactory.register( StuffTool );
- *
- *     // This is a PopupTool. Rather than having a custom 'onSelect' action, it will display a
- *     // little popup window (a PopupWidget).
- *     function HelpTool( toolGroup, config ) {
- *         OO.ui.PopupTool.call( this, toolGroup, $.extend( { popup: {
- *             padded: true,
- *             label: 'Help',
- *             head: true
- *         } }, config ) );
- *         this.popup.$body.append( '<p>I am helpful!</p>' );
- *     }
- *     OO.inheritClass( HelpTool, OO.ui.PopupTool );
- *     HelpTool.static.name = 'help';
- *     HelpTool.static.icon = 'help';
- *     HelpTool.static.title = 'Help';
- *     toolFactory.register( HelpTool );
- *
- *     // Finally define which tools and in what order appear in the toolbar. Each tool may only be
- *     // used once (but not all defined tools must be used).
- *     toolbar.setup( [
- *         {
- *             // 'bar' tool groups display tools' icons only, side-by-side.
- *             type: 'bar',
- *             include: [ 'search', 'help' ]
- *         },
- *         {
- *             // 'list' tool groups display both the titles and icons, in a dropdown list.
- *             type: 'list',
- *             indicator: 'down',
- *             label: 'More',
- *             include: [ 'settings', 'stuff' ]
- *         }
- *         // Note how the tools themselves are toolgroup-agnostic - the same tool can be displayed
- *         // either in a 'list' or a 'bar'. There is a 'menu' tool group too, not showcased here,
- *         // since it's more complicated to use. (See the next example snippet on this page.)
- *     ] );
- *
- *     // Create some UI around the toolbar and place it in the document
- *     var frame = new OO.ui.PanelLayout( {
- *         expanded: false,
- *         framed: true
- *     } );
- *     var contentFrame = new OO.ui.PanelLayout( {
- *         expanded: false,
- *         padded: true
- *     } );
- *     frame.$element.append(
- *         toolbar.$element,
- *         contentFrame.$element.append( $area )
- *     );
- *     $( 'body' ).append( frame.$element );
- *
- *     // Here is where the toolbar is actually built. This must be done after inserting it into the
- *     // document.
- *     toolbar.initialize();
- *     toolbar.emit( 'updateState' );
- *
- * The following example extends the previous one to illustrate 'menu' toolgroups and the usage of
- * {@link #event-updateState 'updateState' event}.
- *
- *     @example
- *     // Create the toolbar
- *     var toolFactory = new OO.ui.ToolFactory();
- *     var toolGroupFactory = new OO.ui.ToolGroupFactory();
- *     var toolbar = new OO.ui.Toolbar( toolFactory, toolGroupFactory );
- *
- *     // We will be placing status text in this element when tools are used
- *     var $area = $( '<p>' ).text( 'Toolbar example' );
- *
- *     // Define the tools that we're going to place in our toolbar
- *
- *     // Create a class inheriting from OO.ui.Tool
- *     function SearchTool() {
- *         SearchTool.parent.apply( this, arguments );
- *     }
- *     OO.inheritClass( SearchTool, OO.ui.Tool );
- *     // Each tool must have a 'name' (used as an internal identifier, see later) and at least one
- *     // of 'icon' and 'title' (displayed icon and text).
- *     SearchTool.static.name = 'search';
- *     SearchTool.static.icon = 'search';
- *     SearchTool.static.title = 'Search...';
- *     // Defines the action that will happen when this tool is selected (clicked).
- *     SearchTool.prototype.onSelect = function () {
- *         $area.text( 'Search tool clicked!' );
- *         // Never display this tool as "active" (selected).
- *         this.setActive( false );
- *     };
- *     SearchTool.prototype.onUpdateState = function () {};
- *     // Make this tool available in our toolFactory and thus our toolbar
- *     toolFactory.register( SearchTool );
- *
- *     // Register two more tools, nothing interesting here
- *     function SettingsTool() {
- *         SettingsTool.parent.apply( this, arguments );
- *         this.reallyActive = false;
- *     }
- *     OO.inheritClass( SettingsTool, OO.ui.Tool );
- *     SettingsTool.static.name = 'settings';
- *     SettingsTool.static.icon = 'settings';
- *     SettingsTool.static.title = 'Change settings';
- *     SettingsTool.prototype.onSelect = function () {
- *         $area.text( 'Settings tool clicked!' );
- *         // Toggle the active state on each click
- *         this.reallyActive = !this.reallyActive;
- *         this.setActive( this.reallyActive );
- *         // To update the menu label
- *         this.toolbar.emit( 'updateState' );
- *     };
- *     SettingsTool.prototype.onUpdateState = function () {};
- *     toolFactory.register( SettingsTool );
- *
- *     // Register two more tools, nothing interesting here
- *     function StuffTool() {
- *         StuffTool.parent.apply( this, arguments );
- *         this.reallyActive = false;
- *     }
- *     OO.inheritClass( StuffTool, OO.ui.Tool );
- *     StuffTool.static.name = 'stuff';
- *     StuffTool.static.icon = 'ellipsis';
- *     StuffTool.static.title = 'More stuff';
- *     StuffTool.prototype.onSelect = function () {
- *         $area.text( 'More stuff tool clicked!' );
- *         // Toggle the active state on each click
- *         this.reallyActive = !this.reallyActive;
- *         this.setActive( this.reallyActive );
- *         // To update the menu label
- *         this.toolbar.emit( 'updateState' );
- *     };
- *     StuffTool.prototype.onUpdateState = function () {};
- *     toolFactory.register( StuffTool );
- *
- *     // This is a PopupTool. Rather than having a custom 'onSelect' action, it will display a
- *     // little popup window (a PopupWidget). 'onUpdateState' is also already implemented.
- *     function HelpTool( toolGroup, config ) {
- *         OO.ui.PopupTool.call( this, toolGroup, $.extend( { popup: {
- *             padded: true,
- *             label: 'Help',
- *             head: true
- *         } }, config ) );
- *         this.popup.$body.append( '<p>I am helpful!</p>' );
- *     }
- *     OO.inheritClass( HelpTool, OO.ui.PopupTool );
- *     HelpTool.static.name = 'help';
- *     HelpTool.static.icon = 'help';
- *     HelpTool.static.title = 'Help';
- *     toolFactory.register( HelpTool );
- *
- *     // Finally define which tools and in what order appear in the toolbar. Each tool may only be
- *     // used once (but not all defined tools must be used).
- *     toolbar.setup( [
- *         {
- *             // 'bar' tool groups display tools' icons only, side-by-side.
- *             type: 'bar',
- *             include: [ 'search', 'help' ]
- *         },
- *         {
- *             // 'menu' tool groups display both the titles and icons, in a dropdown menu.
- *             // Menu label indicates which items are selected.
- *             type: 'menu',
- *             indicator: 'down',
- *             include: [ 'settings', 'stuff' ]
- *         }
- *     ] );
- *
- *     // Create some UI around the toolbar and place it in the document
- *     var frame = new OO.ui.PanelLayout( {
- *         expanded: false,
- *         framed: true
- *     } );
- *     var contentFrame = new OO.ui.PanelLayout( {
- *         expanded: false,
- *         padded: true
- *     } );
- *     frame.$element.append(
- *         toolbar.$element,
- *         contentFrame.$element.append( $area )
- *     );
- *     $( 'body' ).append( frame.$element );
- *
- *     // Here is where the toolbar is actually built. This must be done after inserting it into the
- *     // document.
- *     toolbar.initialize();
- *     toolbar.emit( 'updateState' );
- *
- * @class
- * @extends OO.ui.Element
- * @mixins OO.EventEmitter
- * @mixins OO.ui.mixin.GroupElement
- *
- * @constructor
- * @param {OO.ui.ToolFactory} toolFactory Factory for creating tools
- * @param {OO.ui.ToolGroupFactory} toolGroupFactory Factory for creating toolgroups
- * @param {Object} [config] Configuration options
- * @cfg {boolean} [actions] Add an actions section to the toolbar. Actions are commands that are included
- *  in the toolbar, but are not configured as tools. By default, actions are displayed on the right side of
- *  the toolbar.
- * @cfg {boolean} [shadow] Add a shadow below the toolbar.
- */
-OO.ui.Toolbar = function OoUiToolbar( toolFactory, toolGroupFactory, config ) {
-       // Allow passing positional parameters inside the config object
-       if ( OO.isPlainObject( toolFactory ) && config === undefined ) {
-               config = toolFactory;
-               toolFactory = config.toolFactory;
-               toolGroupFactory = config.toolGroupFactory;
-       }
-
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.Toolbar.parent.call( this, config );
-
-       // Mixin constructors
-       OO.EventEmitter.call( this );
-       OO.ui.mixin.GroupElement.call( this, config );
-
-       // Properties
-       this.toolFactory = toolFactory;
-       this.toolGroupFactory = toolGroupFactory;
-       this.groups = [];
-       this.tools = {};
-       this.$bar = $( '<div>' );
-       this.$actions = $( '<div>' );
-       this.initialized = false;
-       this.onWindowResizeHandler = this.onWindowResize.bind( this );
-
-       // Events
-       this.$element
-               .add( this.$bar ).add( this.$group ).add( this.$actions )
-               .on( 'mousedown keydown', this.onPointerDown.bind( this ) );
-
-       // Initialization
-       this.$group.addClass( 'oo-ui-toolbar-tools' );
-       if ( config.actions ) {
-               this.$bar.append( this.$actions.addClass( 'oo-ui-toolbar-actions' ) );
-       }
-       this.$bar
-               .addClass( 'oo-ui-toolbar-bar' )
-               .append( this.$group, '<div style="clear:both"></div>' );
-       if ( config.shadow ) {
-               this.$bar.append( '<div class="oo-ui-toolbar-shadow"></div>' );
-       }
-       this.$element.addClass( 'oo-ui-toolbar' ).append( this.$bar );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.Toolbar, OO.ui.Element );
-OO.mixinClass( OO.ui.Toolbar, OO.EventEmitter );
-OO.mixinClass( OO.ui.Toolbar, OO.ui.mixin.GroupElement );
-
-/* Events */
-
-/**
- * @event updateState
- *
- * An 'updateState' event must be emitted on the Toolbar (by calling `toolbar.emit( 'updateState' )`)
- * every time the state of the application using the toolbar changes, and an update to the state of
- * tools is required.
- *
- * @param {Mixed...} data Application-defined parameters
- */
-
-/* Methods */
-
-/**
- * Get the tool factory.
- *
- * @return {OO.ui.ToolFactory} Tool factory
- */
-OO.ui.Toolbar.prototype.getToolFactory = function () {
-       return this.toolFactory;
-};
-
-/**
- * Get the toolgroup factory.
- *
- * @return {OO.Factory} Toolgroup factory
- */
-OO.ui.Toolbar.prototype.getToolGroupFactory = function () {
-       return this.toolGroupFactory;
-};
-
-/**
- * Handles mouse down events.
- *
- * @private
- * @param {jQuery.Event} e Mouse down event
- */
-OO.ui.Toolbar.prototype.onPointerDown = function ( e ) {
-       var $closestWidgetToEvent = $( e.target ).closest( '.oo-ui-widget' ),
-               $closestWidgetToToolbar = this.$element.closest( '.oo-ui-widget' );
-       if ( !$closestWidgetToEvent.length || $closestWidgetToEvent[ 0 ] === $closestWidgetToToolbar[ 0 ] ) {
-               return false;
-       }
-};
-
-/**
- * Handle window resize event.
- *
- * @private
- * @param {jQuery.Event} e Window resize event
- */
-OO.ui.Toolbar.prototype.onWindowResize = function () {
-       this.$element.toggleClass(
-               'oo-ui-toolbar-narrow',
-               this.$bar.width() <= this.narrowThreshold
-       );
-};
-
-/**
- * Sets up handles and preloads required information for the toolbar to work.
- * This must be called after it is attached to a visible document and before doing anything else.
- */
-OO.ui.Toolbar.prototype.initialize = function () {
-       if ( !this.initialized ) {
-               this.initialized = true;
-               this.narrowThreshold = this.$group.width() + this.$actions.width();
-               $( this.getElementWindow() ).on( 'resize', this.onWindowResizeHandler );
-               this.onWindowResize();
-       }
-};
-
-/**
- * Set up the toolbar.
- *
- * The toolbar is set up with a list of toolgroup configurations that specify the type of
- * toolgroup ({@link OO.ui.BarToolGroup bar}, {@link OO.ui.MenuToolGroup menu}, or {@link OO.ui.ListToolGroup list})
- * to add and which tools to include, exclude, promote, or demote within that toolgroup. Please
- * see {@link OO.ui.ToolGroup toolgroups} for more information about including tools in toolgroups.
- *
- * @param {Object.<string,Array>} groups List of toolgroup configurations
- * @param {Array|string} [groups.include] Tools to include in the toolgroup
- * @param {Array|string} [groups.exclude] Tools to exclude from the toolgroup
- * @param {Array|string} [groups.promote] Tools to promote to the beginning of the toolgroup
- * @param {Array|string} [groups.demote] Tools to demote to the end of the toolgroup
- */
-OO.ui.Toolbar.prototype.setup = function ( groups ) {
-       var i, len, type, group,
-               items = [],
-               defaultType = 'bar';
-
-       // Cleanup previous groups
-       this.reset();
-
-       // Build out new groups
-       for ( i = 0, len = groups.length; i < len; i++ ) {
-               group = groups[ i ];
-               if ( group.include === '*' ) {
-                       // Apply defaults to catch-all groups
-                       if ( group.type === undefined ) {
-                               group.type = 'list';
-                       }
-                       if ( group.label === undefined ) {
-                               group.label = OO.ui.msg( 'ooui-toolbar-more' );
-                       }
-               }
-               // Check type has been registered
-               type = this.getToolGroupFactory().lookup( group.type ) ? group.type : defaultType;
-               items.push(
-                       this.getToolGroupFactory().create( type, this, group )
-               );
-       }
-       this.addItems( items );
-};
-
-/**
- * Remove all tools and toolgroups from the toolbar.
- */
-OO.ui.Toolbar.prototype.reset = function () {
-       var i, len;
-
-       this.groups = [];
-       this.tools = {};
-       for ( i = 0, len = this.items.length; i < len; i++ ) {
-               this.items[ i ].destroy();
-       }
-       this.clearItems();
-};
-
-/**
- * Destroy the toolbar.
- *
- * Destroying the toolbar removes all event handlers and DOM elements that constitute the toolbar. Call
- * this method whenever you are done using a toolbar.
- */
-OO.ui.Toolbar.prototype.destroy = function () {
-       $( this.getElementWindow() ).off( 'resize', this.onWindowResizeHandler );
-       this.reset();
-       this.$element.remove();
-};
-
-/**
- * Check if the tool is available.
- *
- * Available tools are ones that have not yet been added to the toolbar.
- *
- * @param {string} name Symbolic name of tool
- * @return {boolean} Tool is available
- */
-OO.ui.Toolbar.prototype.isToolAvailable = function ( name ) {
-       return !this.tools[ name ];
-};
-
-/**
- * Prevent tool from being used again.
- *
- * @param {OO.ui.Tool} tool Tool to reserve
- */
-OO.ui.Toolbar.prototype.reserveTool = function ( tool ) {
-       this.tools[ tool.getName() ] = tool;
-};
-
-/**
- * Allow tool to be used again.
- *
- * @param {OO.ui.Tool} tool Tool to release
- */
-OO.ui.Toolbar.prototype.releaseTool = function ( tool ) {
-       delete this.tools[ tool.getName() ];
-};
-
-/**
- * Get accelerator label for tool.
- *
- * The OOjs UI library does not contain an accelerator system, but this is the hook for one. To
- * use an accelerator system, subclass the toolbar and override this method, which is meant to return a label
- * that describes the accelerator keys for the tool passed (by symbolic name) to the method.
- *
- * @param {string} name Symbolic name of tool
- * @return {string|undefined} Tool accelerator label if available
- */
-OO.ui.Toolbar.prototype.getToolAccelerator = function () {
-       return undefined;
-};
-
-/**
- * Tools, together with {@link OO.ui.ToolGroup toolgroups}, constitute {@link OO.ui.Toolbar toolbars}.
- * Each tool is configured with a static name, title, and icon and is customized with the command to carry
- * out when the tool is selected. Tools must also be registered with a {@link OO.ui.ToolFactory tool factory},
- * which creates the tools on demand.
- *
- * Every Tool subclass must implement two methods:
- *
- * - {@link #onUpdateState}
- * - {@link #onSelect}
- *
- * Tools are added to toolgroups ({@link OO.ui.ListToolGroup ListToolGroup},
- * {@link OO.ui.BarToolGroup BarToolGroup}, or {@link OO.ui.MenuToolGroup MenuToolGroup}), which determine how
- * the tool is displayed in the toolbar. See {@link OO.ui.Toolbar toolbars} for an example.
- *
- * For more information, please see the [OOjs UI documentation on MediaWiki][1].
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
- *
- * @abstract
- * @class
- * @extends OO.ui.Widget
- * @mixins OO.ui.mixin.IconElement
- * @mixins OO.ui.mixin.FlaggedElement
- * @mixins OO.ui.mixin.TabIndexedElement
- *
- * @constructor
- * @param {OO.ui.ToolGroup} toolGroup
- * @param {Object} [config] Configuration options
- * @cfg {string|Function} [title] Title text or a function that returns text. If this config is omitted, the value of
- *  the {@link #static-title static title} property is used.
- *
- *  The title is used in different ways depending on the type of toolgroup that contains the tool. The
- *  title is used as a tooltip if the tool is part of a {@link OO.ui.BarToolGroup bar} toolgroup, or as the label text if the tool is
- *  part of a {@link OO.ui.ListToolGroup list} or {@link OO.ui.MenuToolGroup menu} toolgroup.
- *
- *  For bar toolgroups, a description of the accelerator key is appended to the title if an accelerator key
- *  is associated with an action by the same name as the tool and accelerator functionality has been added to the application.
- *  To add accelerator key functionality, you must subclass OO.ui.Toolbar and override the {@link OO.ui.Toolbar#getToolAccelerator getToolAccelerator} method.
- */
-OO.ui.Tool = function OoUiTool( toolGroup, config ) {
-       // Allow passing positional parameters inside the config object
-       if ( OO.isPlainObject( toolGroup ) && config === undefined ) {
-               config = toolGroup;
-               toolGroup = config.toolGroup;
-       }
-
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.Tool.parent.call( this, config );
-
-       // Properties
-       this.toolGroup = toolGroup;
-       this.toolbar = this.toolGroup.getToolbar();
-       this.active = false;
-       this.$title = $( '<span>' );
-       this.$accel = $( '<span>' );
-       this.$link = $( '<a>' );
-       this.title = null;
-
-       // Mixin constructors
-       OO.ui.mixin.IconElement.call( this, config );
-       OO.ui.mixin.FlaggedElement.call( this, config );
-       OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$link } ) );
-
-       // Events
-       this.toolbar.connect( this, { updateState: 'onUpdateState' } );
-
-       // Initialization
-       this.$title.addClass( 'oo-ui-tool-title' );
-       this.$accel
-               .addClass( 'oo-ui-tool-accel' )
-               .prop( {
-                       // This may need to be changed if the key names are ever localized,
-                       // but for now they are essentially written in English
-                       dir: 'ltr',
-                       lang: 'en'
-               } );
-       this.$link
-               .addClass( 'oo-ui-tool-link' )
-               .append( this.$icon, this.$title, this.$accel )
-               .attr( 'role', 'button' );
-       this.$element
-               .data( 'oo-ui-tool', this )
-               .addClass(
-                       'oo-ui-tool ' + 'oo-ui-tool-name-' +
-                       this.constructor.static.name.replace( /^([^\/]+)\/([^\/]+).*$/, '$1-$2' )
-               )
-               .toggleClass( 'oo-ui-tool-with-label', this.constructor.static.displayBothIconAndLabel )
-               .append( this.$link );
-       this.setTitle( config.title || this.constructor.static.title );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.Tool, OO.ui.Widget );
-OO.mixinClass( OO.ui.Tool, OO.ui.mixin.IconElement );
-OO.mixinClass( OO.ui.Tool, OO.ui.mixin.FlaggedElement );
-OO.mixinClass( OO.ui.Tool, OO.ui.mixin.TabIndexedElement );
-
-/* Static Properties */
-
-/**
- * @static
- * @inheritdoc
- */
-OO.ui.Tool.static.tagName = 'span';
-
-/**
- * Symbolic name of tool.
- *
- * The symbolic name is used internally to register the tool with a {@link OO.ui.ToolFactory ToolFactory}. It can
- * also be used when adding tools to toolgroups.
- *
- * @abstract
- * @static
- * @inheritable
- * @property {string}
- */
-OO.ui.Tool.static.name = '';
-
-/**
- * Symbolic name of the group.
- *
- * The group name is used to associate tools with each other so that they can be selected later by
- * a {@link OO.ui.ToolGroup toolgroup}.
- *
- * @abstract
- * @static
- * @inheritable
- * @property {string}
- */
-OO.ui.Tool.static.group = '';
-
-/**
- * Tool title text or a function that returns title text. The value of the static property is overridden if the #title config option is used.
- *
- * @abstract
- * @static
- * @inheritable
- * @property {string|Function}
- */
-OO.ui.Tool.static.title = '';
-
-/**
- * Display both icon and label when the tool is used in a {@link OO.ui.BarToolGroup bar} toolgroup.
- * Normally only the icon is displayed, or only the label if no icon is given.
- *
- * @static
- * @inheritable
- * @property {boolean}
- */
-OO.ui.Tool.static.displayBothIconAndLabel = false;
-
-/**
- * Add tool to catch-all groups automatically.
- *
- * A catch-all group, which contains all tools that do not currently belong to a toolgroup,
- * can be included in a toolgroup using the wildcard selector, an asterisk (*).
- *
- * @static
- * @inheritable
- * @property {boolean}
- */
-OO.ui.Tool.static.autoAddToCatchall = true;
-
-/**
- * Add tool to named groups automatically.
- *
- * By default, tools that are configured with a static ‘group’ property are added
- * to that group and will be selected when the symbolic name of the group is specified (e.g., when
- * toolgroups include tools by group name).
- *
- * @static
- * @property {boolean}
- * @inheritable
- */
-OO.ui.Tool.static.autoAddToGroup = true;
-
-/**
- * Check if this tool is compatible with given data.
- *
- * This is a stub that can be overridden to provide support for filtering tools based on an
- * arbitrary piece of information  (e.g., where the cursor is in a document). The implementation
- * must also call this method so that the compatibility check can be performed.
- *
- * @static
- * @inheritable
- * @param {Mixed} data Data to check
- * @return {boolean} Tool can be used with data
- */
-OO.ui.Tool.static.isCompatibleWith = function () {
-       return false;
-};
-
-/* Methods */
-
-/**
- * Handle the toolbar state being updated. This method is called when the
- * {@link OO.ui.Toolbar#event-updateState 'updateState' event} is emitted on the
- * {@link OO.ui.Toolbar Toolbar} that uses this tool, and should set the state of this tool
- * depending on application state (usually by calling #setDisabled to enable or disable the tool,
- * or #setActive to mark is as currently in-use or not).
- *
- * This is an abstract method that must be overridden in a concrete subclass.
- *
- * @method
- * @protected
- * @abstract
- */
-OO.ui.Tool.prototype.onUpdateState = null;
-
-/**
- * Handle the tool being selected. This method is called when the user triggers this tool,
- * usually by clicking on its label/icon.
- *
- * This is an abstract method that must be overridden in a concrete subclass.
- *
- * @method
- * @protected
- * @abstract
- */
-OO.ui.Tool.prototype.onSelect = null;
-
-/**
- * Check if the tool is active.
- *
- * Tools become active when their #onSelect or #onUpdateState handlers change them to appear pressed
- * with the #setActive method. Additional CSS is applied to the tool to reflect the active state.
- *
- * @return {boolean} Tool is active
- */
-OO.ui.Tool.prototype.isActive = function () {
-       return this.active;
-};
-
-/**
- * Make the tool appear active or inactive.
- *
- * This method should be called within #onSelect or #onUpdateState event handlers to make the tool
- * appear pressed or not.
- *
- * @param {boolean} state Make tool appear active
- */
-OO.ui.Tool.prototype.setActive = function ( state ) {
-       this.active = !!state;
-       if ( this.active ) {
-               this.$element.addClass( 'oo-ui-tool-active' );
-       } else {
-               this.$element.removeClass( 'oo-ui-tool-active' );
-       }
-};
-
-/**
- * Set the tool #title.
- *
- * @param {string|Function} title Title text or a function that returns text
- * @chainable
- */
-OO.ui.Tool.prototype.setTitle = function ( title ) {
-       this.title = OO.ui.resolveMsg( title );
-       this.updateTitle();
-       return this;
-};
-
-/**
- * Get the tool #title.
- *
- * @return {string} Title text
- */
-OO.ui.Tool.prototype.getTitle = function () {
-       return this.title;
-};
-
-/**
- * Get the tool's symbolic name.
- *
- * @return {string} Symbolic name of tool
- */
-OO.ui.Tool.prototype.getName = function () {
-       return this.constructor.static.name;
-};
-
-/**
- * Update the title.
- */
-OO.ui.Tool.prototype.updateTitle = function () {
-       var titleTooltips = this.toolGroup.constructor.static.titleTooltips,
-               accelTooltips = this.toolGroup.constructor.static.accelTooltips,
-               accel = this.toolbar.getToolAccelerator( this.constructor.static.name ),
-               tooltipParts = [];
-
-       this.$title.text( this.title );
-       this.$accel.text( accel );
-
-       if ( titleTooltips && typeof this.title === 'string' && this.title.length ) {
-               tooltipParts.push( this.title );
-       }
-       if ( accelTooltips && typeof accel === 'string' && accel.length ) {
-               tooltipParts.push( accel );
-       }
-       if ( tooltipParts.length ) {
-               this.$link.attr( 'title', tooltipParts.join( ' ' ) );
-       } else {
-               this.$link.removeAttr( 'title' );
-       }
-};
-
-/**
- * Destroy tool.
- *
- * Destroying the tool removes all event handlers and the tool’s DOM elements.
- * Call this method whenever you are done using a tool.
- */
-OO.ui.Tool.prototype.destroy = function () {
-       this.toolbar.disconnect( this );
-       this.$element.remove();
-};
-
-/**
- * ToolGroups are collections of {@link OO.ui.Tool tools} that are used in a {@link OO.ui.Toolbar toolbar}.
- * The type of toolgroup ({@link OO.ui.ListToolGroup list}, {@link OO.ui.BarToolGroup bar}, or {@link OO.ui.MenuToolGroup menu})
- * to which a tool belongs determines how the tool is arranged and displayed in the toolbar. Toolgroups
- * themselves are created on demand with a {@link OO.ui.ToolGroupFactory toolgroup factory}.
- *
- * Toolgroups can contain individual tools, groups of tools, or all available tools, as specified
- * using the `include` config option. See OO.ui.ToolFactory#extract on documentation of the format.
- * The options `exclude`, `promote`, and `demote` support the same formats.
- *
- * See {@link OO.ui.Toolbar toolbars} for a full example. For more information about toolbars in general,
- * please see the [OOjs UI documentation on MediaWiki][1].
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
- *
- * @abstract
- * @class
- * @extends OO.ui.Widget
- * @mixins OO.ui.mixin.GroupElement
- *
- * @constructor
- * @param {OO.ui.Toolbar} toolbar
- * @param {Object} [config] Configuration options
- * @cfg {Array|string} [include] List of tools to include in the toolgroup, see above.
- * @cfg {Array|string} [exclude] List of tools to exclude from the toolgroup, see above.
- * @cfg {Array|string} [promote] List of tools to promote to the beginning of the toolgroup, see above.
- * @cfg {Array|string} [demote] List of tools to demote to the end of the toolgroup, see above.
- *  This setting is particularly useful when tools have been added to the toolgroup
- *  en masse (e.g., via the catch-all selector).
- */
-OO.ui.ToolGroup = function OoUiToolGroup( toolbar, config ) {
-       // Allow passing positional parameters inside the config object
-       if ( OO.isPlainObject( toolbar ) && config === undefined ) {
-               config = toolbar;
-               toolbar = config.toolbar;
-       }
-
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.ToolGroup.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.GroupElement.call( this, config );
-
-       // Properties
-       this.toolbar = toolbar;
-       this.tools = {};
-       this.pressed = null;
-       this.autoDisabled = false;
-       this.include = config.include || [];
-       this.exclude = config.exclude || [];
-       this.promote = config.promote || [];
-       this.demote = config.demote || [];
-       this.onCapturedMouseKeyUpHandler = this.onCapturedMouseKeyUp.bind( this );
-
-       // Events
-       this.$element.on( {
-               mousedown: this.onMouseKeyDown.bind( this ),
-               mouseup: this.onMouseKeyUp.bind( this ),
-               keydown: this.onMouseKeyDown.bind( this ),
-               keyup: this.onMouseKeyUp.bind( this ),
-               focus: this.onMouseOverFocus.bind( this ),
-               blur: this.onMouseOutBlur.bind( this ),
-               mouseover: this.onMouseOverFocus.bind( this ),
-               mouseout: this.onMouseOutBlur.bind( this )
-       } );
-       this.toolbar.getToolFactory().connect( this, { register: 'onToolFactoryRegister' } );
-       this.aggregate( { disable: 'itemDisable' } );
-       this.connect( this, { itemDisable: 'updateDisabled' } );
-
-       // Initialization
-       this.$group.addClass( 'oo-ui-toolGroup-tools' );
-       this.$element
-               .addClass( 'oo-ui-toolGroup' )
-               .append( this.$group );
-       this.populate();
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.ToolGroup, OO.ui.Widget );
-OO.mixinClass( OO.ui.ToolGroup, OO.ui.mixin.GroupElement );
-
-/* Events */
-
-/**
- * @event update
- */
-
-/* Static Properties */
-
-/**
- * Show labels in tooltips.
- *
- * @static
- * @inheritable
- * @property {boolean}
- */
-OO.ui.ToolGroup.static.titleTooltips = false;
-
-/**
- * Show acceleration labels in tooltips.
- *
- * Note: The OOjs UI library does not include an accelerator system, but does contain
- * a hook for one. To use an accelerator system, subclass the {@link OO.ui.Toolbar toolbar} and
- * override the {@link OO.ui.Toolbar#getToolAccelerator getToolAccelerator} method, which is
- * meant to return a label that describes the accelerator keys for a given tool (e.g., 'Ctrl + M').
- *
- * @static
- * @inheritable
- * @property {boolean}
- */
-OO.ui.ToolGroup.static.accelTooltips = false;
-
-/**
- * Automatically disable the toolgroup when all tools are disabled
- *
- * @static
- * @inheritable
- * @property {boolean}
- */
-OO.ui.ToolGroup.static.autoDisable = true;
-
-/* Methods */
-
-/**
- * @inheritdoc
- */
-OO.ui.ToolGroup.prototype.isDisabled = function () {
-       return this.autoDisabled || OO.ui.ToolGroup.parent.prototype.isDisabled.apply( this, arguments );
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.ToolGroup.prototype.updateDisabled = function () {
-       var i, item, allDisabled = true;
-
-       if ( this.constructor.static.autoDisable ) {
-               for ( i = this.items.length - 1; i >= 0; i-- ) {
-                       item = this.items[ i ];
-                       if ( !item.isDisabled() ) {
-                               allDisabled = false;
-                               break;
-                       }
-               }
-               this.autoDisabled = allDisabled;
-       }
-       OO.ui.ToolGroup.parent.prototype.updateDisabled.apply( this, arguments );
-};
-
-/**
- * Handle mouse down and key down events.
- *
- * @protected
- * @param {jQuery.Event} e Mouse down or key down event
- */
-OO.ui.ToolGroup.prototype.onMouseKeyDown = function ( e ) {
-       if (
-               !this.isDisabled() &&
-               ( e.which === OO.ui.MouseButtons.LEFT || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
-       ) {
-               this.pressed = this.getTargetTool( e );
-               if ( this.pressed ) {
-                       this.pressed.setActive( true );
-                       this.getElementDocument().addEventListener( 'mouseup', this.onCapturedMouseKeyUpHandler, true );
-                       this.getElementDocument().addEventListener( 'keyup', this.onCapturedMouseKeyUpHandler, true );
-               }
-               return false;
-       }
-};
-
-/**
- * Handle captured mouse up and key up events.
- *
- * @protected
- * @param {Event} e Mouse up or key up event
- */
-OO.ui.ToolGroup.prototype.onCapturedMouseKeyUp = function ( e ) {
-       this.getElementDocument().removeEventListener( 'mouseup', this.onCapturedMouseKeyUpHandler, true );
-       this.getElementDocument().removeEventListener( 'keyup', this.onCapturedMouseKeyUpHandler, true );
-       // onMouseKeyUp may be called a second time, depending on where the mouse is when the button is
-       // released, but since `this.pressed` will no longer be true, the second call will be ignored.
-       this.onMouseKeyUp( e );
-};
-
-/**
- * Handle mouse up and key up events.
- *
- * @protected
- * @param {jQuery.Event} e Mouse up or key up event
- */
-OO.ui.ToolGroup.prototype.onMouseKeyUp = function ( e ) {
-       var tool = this.getTargetTool( e );
-
-       if (
-               !this.isDisabled() && this.pressed && this.pressed === tool &&
-               ( e.which === OO.ui.MouseButtons.LEFT || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
-       ) {
-               this.pressed.onSelect();
-               this.pressed = null;
-               return false;
-       }
-
-       this.pressed = null;
-};
-
-/**
- * Handle mouse over and focus events.
- *
- * @protected
- * @param {jQuery.Event} e Mouse over or focus event
- */
-OO.ui.ToolGroup.prototype.onMouseOverFocus = function ( e ) {
-       var tool = this.getTargetTool( e );
-
-       if ( this.pressed && this.pressed === tool ) {
-               this.pressed.setActive( true );
-       }
-};
-
-/**
- * Handle mouse out and blur events.
- *
- * @protected
- * @param {jQuery.Event} e Mouse out or blur event
- */
-OO.ui.ToolGroup.prototype.onMouseOutBlur = function ( e ) {
-       var tool = this.getTargetTool( e );
-
-       if ( this.pressed && this.pressed === tool ) {
-               this.pressed.setActive( false );
-       }
-};
-
-/**
- * Get the closest tool to a jQuery.Event.
- *
- * Only tool links are considered, which prevents other elements in the tool such as popups from
- * triggering tool group interactions.
- *
- * @private
- * @param {jQuery.Event} e
- * @return {OO.ui.Tool|null} Tool, `null` if none was found
- */
-OO.ui.ToolGroup.prototype.getTargetTool = function ( e ) {
-       var tool,
-               $item = $( e.target ).closest( '.oo-ui-tool-link' );
-
-       if ( $item.length ) {
-               tool = $item.parent().data( 'oo-ui-tool' );
-       }
-
-       return tool && !tool.isDisabled() ? tool : null;
-};
-
-/**
- * Handle tool registry register events.
- *
- * If a tool is registered after the group is created, we must repopulate the list to account for:
- *
- * - a tool being added that may be included
- * - a tool already included being overridden
- *
- * @protected
- * @param {string} name Symbolic name of tool
- */
-OO.ui.ToolGroup.prototype.onToolFactoryRegister = function () {
-       this.populate();
-};
-
-/**
- * Get the toolbar that contains the toolgroup.
- *
- * @return {OO.ui.Toolbar} Toolbar that contains the toolgroup
- */
-OO.ui.ToolGroup.prototype.getToolbar = function () {
-       return this.toolbar;
-};
-
-/**
- * Add and remove tools based on configuration.
- */
-OO.ui.ToolGroup.prototype.populate = function () {
-       var i, len, name, tool,
-               toolFactory = this.toolbar.getToolFactory(),
-               names = {},
-               add = [],
-               remove = [],
-               list = this.toolbar.getToolFactory().getTools(
-                       this.include, this.exclude, this.promote, this.demote
-               );
-
-       // Build a list of needed tools
-       for ( i = 0, len = list.length; i < len; 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 ] )
-               ) {
-                       // Hack to prevent infinite recursion via ToolGroupTool. We need to reserve the tool before
-                       // creating it, but we can't call reserveTool() yet because we haven't created the tool.
-                       this.toolbar.tools[ name ] = true;
-                       tool = this.tools[ name ];
-                       if ( !tool ) {
-                               // Auto-initialize tools on first use
-                               this.tools[ name ] = tool = toolFactory.create( name, this );
-                               tool.updateTitle();
-                       }
-                       this.toolbar.reserveTool( tool );
-                       add.push( tool );
-                       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 ( remove.length ) {
-               this.removeItems( remove );
-       }
-       // Update emptiness state
-       if ( add.length ) {
-               this.$element.removeClass( 'oo-ui-toolGroup-empty' );
-       } else {
-               this.$element.addClass( 'oo-ui-toolGroup-empty' );
-       }
-       // Re-add tools (moving existing ones to new locations)
-       this.addItems( add );
-       // Disabled state may depend on items
-       this.updateDisabled();
-};
-
-/**
- * Destroy toolgroup.
- */
-OO.ui.ToolGroup.prototype.destroy = function () {
-       var name;
-
-       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.$element.remove();
-};
-
-/**
- * A ToolFactory creates tools on demand. All tools ({@link OO.ui.Tool Tools}, {@link OO.ui.PopupTool PopupTools},
- * and {@link OO.ui.ToolGroupTool ToolGroupTools}) must be registered with a tool factory. Tools are
- * registered by their symbolic name. See {@link OO.ui.Toolbar toolbars} for an example.
- *
- * For more information about toolbars in general, please see the [OOjs UI documentation on MediaWiki][1].
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
- *
- * @class
- * @extends OO.Factory
- * @constructor
- */
-OO.ui.ToolFactory = function OoUiToolFactory() {
-       // Parent constructor
-       OO.ui.ToolFactory.parent.call( this );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.ToolFactory, OO.Factory );
-
-/* Methods */
-
-/**
- * Get tools from the factory
- *
- * @param {Array|string} [include] Included tools, see #extract for format
- * @param {Array|string} [exclude] Excluded tools, see #extract for format
- * @param {Array|string} [promote] Promoted tools, see #extract for format
- * @param {Array|string} [demote] Demoted tools, see #extract for format
- * @return {string[]} List of tools
- */
-OO.ui.ToolFactory.prototype.getTools = function ( include, exclude, promote, demote ) {
-       var i, len, included, promoted, demoted,
-               auto = [],
-               used = {};
-
-       // Collect included and not excluded tools
-       included = OO.simpleArrayDifference( this.extract( include ), this.extract( exclude ) );
-
-       // Promotion
-       promoted = this.extract( promote, used );
-       demoted = this.extract( demote, used );
-
-       // Auto
-       for ( i = 0, len = included.length; i < len; i++ ) {
-               if ( !used[ included[ i ] ] ) {
-                       auto.push( included[ i ] );
-               }
-       }
-
-       return promoted.concat( auto ).concat( demoted );
-};
-
-/**
- * Get a flat list of names from a list of names or groups.
- *
- * Normally, `collection` is an array of tool specifications. Tools can be specified in the
- * following ways:
- *
- * - To include an individual tool, use the symbolic name: `{ name: 'tool-name' }` or `'tool-name'`.
- * - To include all tools in a group, use the group name: `{ group: 'group-name' }`. (To assign the
- *   tool to a group, use OO.ui.Tool.static.group.)
- *
- * Alternatively, to include all tools that are not yet assigned to any other toolgroup, use the
- * catch-all selector `'*'`.
- *
- * If `used` is passed, tool names that appear as properties in this object will be considered
- * already assigned, and will not be returned even if specified otherwise. The tool names extracted
- * by this function call will be added as new properties in the object.
- *
- * @private
- * @param {Array|string} collection List of tools, see above
- * @param {Object} [used] Object containing information about used tools, see above
- * @return {string[]} List of extracted tool names
- */
-OO.ui.ToolFactory.prototype.extract = function ( collection, used ) {
-       var i, len, item, name, tool,
-               names = [];
-
-       if ( collection === '*' ) {
-               for ( name in this.registry ) {
-                       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 ] )
-                       ) {
-                               names.push( name );
-                               if ( used ) {
-                                       used[ name ] = true;
-                               }
-                       }
-               }
-       } else if ( Array.isArray( collection ) ) {
-               for ( i = 0, len = collection.length; i < len; i++ ) {
-                       item = collection[ i ];
-                       // Allow plain strings as shorthand for named tools
-                       if ( typeof item === 'string' ) {
-                               item = { name: item };
-                       }
-                       if ( OO.isPlainObject( item ) ) {
-                               if ( item.group ) {
-                                       for ( name in this.registry ) {
-                                               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 ] )
-                                               ) {
-                                                       names.push( name );
-                                                       if ( used ) {
-                                                               used[ name ] = true;
-                                                       }
-                                               }
-                                       }
-                               // Include tools with matching name and exclude already used tools
-                               } else if ( item.name && ( !used || !used[ item.name ] ) ) {
-                                       names.push( item.name );
-                                       if ( used ) {
-                                               used[ item.name ] = true;
-                                       }
-                               }
-                       }
-               }
-       }
-       return names;
-};
-
-/**
- * ToolGroupFactories create {@link OO.ui.ToolGroup toolgroups} on demand. The toolgroup classes must
- * specify a symbolic name and be registered with the factory. The following classes are registered by
- * default:
- *
- * - {@link OO.ui.BarToolGroup BarToolGroups} (‘bar’)
- * - {@link OO.ui.MenuToolGroup MenuToolGroups} (‘menu’)
- * - {@link OO.ui.ListToolGroup ListToolGroups} (‘list’)
- *
- * See {@link OO.ui.Toolbar toolbars} for an example.
- *
- * For more information about toolbars in general, please see the [OOjs UI documentation on MediaWiki][1].
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
- * @class
- * @extends OO.Factory
- * @constructor
- */
-OO.ui.ToolGroupFactory = function OoUiToolGroupFactory() {
-       var i, l, defaultClasses;
-       // Parent constructor
-       OO.Factory.call( this );
-
-       defaultClasses = this.constructor.static.getDefaultClasses();
-
-       // Register default toolgroups
-       for ( i = 0, l = defaultClasses.length; i < l; i++ ) {
-               this.register( defaultClasses[ i ] );
-       }
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.ToolGroupFactory, OO.Factory );
-
-/* Static Methods */
-
-/**
- * Get a default set of classes to be registered on construction.
- *
- * @return {Function[]} Default classes
- */
-OO.ui.ToolGroupFactory.static.getDefaultClasses = function () {
-       return [
-               OO.ui.BarToolGroup,
-               OO.ui.ListToolGroup,
-               OO.ui.MenuToolGroup
-       ];
-};
-
-/**
- * Popup tools open a popup window when they are selected from the {@link OO.ui.Toolbar toolbar}. Each popup tool is configured
- * with a static name, title, and icon, as well with as any popup configurations. Unlike other tools, popup tools do not require that developers specify
- * an #onSelect or #onUpdateState method, as these methods have been implemented already.
- *
- *     // Example of a popup tool. When selected, a popup tool displays
- *     // a popup window.
- *     function HelpTool( toolGroup, config ) {
- *        OO.ui.PopupTool.call( this, toolGroup, $.extend( { popup: {
- *            padded: true,
- *            label: 'Help',
- *            head: true
- *        } }, config ) );
- *        this.popup.$body.append( '<p>I am helpful!</p>' );
- *     };
- *     OO.inheritClass( HelpTool, OO.ui.PopupTool );
- *     HelpTool.static.name = 'help';
- *     HelpTool.static.icon = 'help';
- *     HelpTool.static.title = 'Help';
- *     toolFactory.register( HelpTool );
- *
- * For an example of a toolbar that contains a popup tool, see {@link OO.ui.Toolbar toolbars}. For more information about
- * toolbars in genreral, please see the [OOjs UI documentation on MediaWiki][1].
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
- *
- * @abstract
- * @class
- * @extends OO.ui.Tool
- * @mixins OO.ui.mixin.PopupElement
- *
- * @constructor
- * @param {OO.ui.ToolGroup} toolGroup
- * @param {Object} [config] Configuration options
- */
-OO.ui.PopupTool = function OoUiPopupTool( toolGroup, config ) {
-       // Allow passing positional parameters inside the config object
-       if ( OO.isPlainObject( toolGroup ) && config === undefined ) {
-               config = toolGroup;
-               toolGroup = config.toolGroup;
-       }
-
-       // Parent constructor
-       OO.ui.PopupTool.parent.call( this, toolGroup, config );
-
-       // Mixin constructors
-       OO.ui.mixin.PopupElement.call( this, config );
-
-       // Initialization
-       this.$element
-               .addClass( 'oo-ui-popupTool' )
-               .append( this.popup.$element );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.PopupTool, OO.ui.Tool );
-OO.mixinClass( OO.ui.PopupTool, OO.ui.mixin.PopupElement );
-
-/* Methods */
-
-/**
- * Handle the tool being selected.
- *
- * @inheritdoc
- */
-OO.ui.PopupTool.prototype.onSelect = function () {
-       if ( !this.isDisabled() ) {
-               this.popup.toggle();
-       }
-       this.setActive( false );
-       return false;
-};
-
-/**
- * Handle the toolbar state being updated.
- *
- * @inheritdoc
- */
-OO.ui.PopupTool.prototype.onUpdateState = function () {
-       this.setActive( false );
-};
-
-/**
- * A ToolGroupTool is a special sort of tool that can contain other {@link OO.ui.Tool tools}
- * and {@link OO.ui.ToolGroup toolgroups}. The ToolGroupTool was specifically designed to be used
- * inside a {@link OO.ui.BarToolGroup bar} toolgroup to provide access to additional tools from
- * the bar item. Included tools will be displayed in a dropdown {@link OO.ui.ListToolGroup list}
- * when the ToolGroupTool is selected.
- *
- *     // Example: ToolGroupTool with two nested tools, 'setting1' and 'setting2', defined elsewhere.
- *
- *     function SettingsTool() {
- *         SettingsTool.parent.apply( this, arguments );
- *     };
- *     OO.inheritClass( SettingsTool, OO.ui.ToolGroupTool );
- *     SettingsTool.static.name = 'settings';
- *     SettingsTool.static.title = 'Change settings';
- *     SettingsTool.static.groupConfig = {
- *         icon: 'settings',
- *         label: 'ToolGroupTool',
- *         include: [  'setting1', 'setting2'  ]
- *     };
- *     toolFactory.register( SettingsTool );
- *
- * For more information, please see the [OOjs UI documentation on MediaWiki][1].
- *
- * Please note that this implementation is subject to change per [T74159] [2].
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars#ToolGroupTool
- * [2]: https://phabricator.wikimedia.org/T74159
- *
- * @abstract
- * @class
- * @extends OO.ui.Tool
- *
- * @constructor
- * @param {OO.ui.ToolGroup} toolGroup
- * @param {Object} [config] Configuration options
- */
-OO.ui.ToolGroupTool = function OoUiToolGroupTool( toolGroup, config ) {
-       // Allow passing positional parameters inside the config object
-       if ( OO.isPlainObject( toolGroup ) && config === undefined ) {
-               config = toolGroup;
-               toolGroup = config.toolGroup;
-       }
-
-       // Parent constructor
-       OO.ui.ToolGroupTool.parent.call( this, toolGroup, config );
-
-       // Properties
-       this.innerToolGroup = this.createGroup( this.constructor.static.groupConfig );
-
-       // Events
-       this.innerToolGroup.connect( this, { disable: 'onToolGroupDisable' } );
-
-       // Initialization
-       this.$link.remove();
-       this.$element
-               .addClass( 'oo-ui-toolGroupTool' )
-               .append( this.innerToolGroup.$element );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.ToolGroupTool, OO.ui.Tool );
-
-/* Static Properties */
-
-/**
- * Toolgroup configuration.
- *
- * The toolgroup configuration consists of the tools to include, as well as an icon and label
- * to use for the bar item. Tools can be included by symbolic name, group, or with the
- * wildcard selector. Please see {@link OO.ui.ToolGroup toolgroup} for more information.
- *
- * @property {Object.<string,Array>}
- */
-OO.ui.ToolGroupTool.static.groupConfig = {};
-
-/* Methods */
-
-/**
- * Handle the tool being selected.
- *
- * @inheritdoc
- */
-OO.ui.ToolGroupTool.prototype.onSelect = function () {
-       this.innerToolGroup.setActive( !this.innerToolGroup.active );
-       return false;
-};
-
-/**
- * Synchronize disabledness state of the tool with the inner toolgroup.
- *
- * @private
- * @param {boolean} disabled Element is disabled
- */
-OO.ui.ToolGroupTool.prototype.onToolGroupDisable = function ( disabled ) {
-       this.setDisabled( disabled );
-};
-
-/**
- * Handle the toolbar state being updated.
- *
- * @inheritdoc
- */
-OO.ui.ToolGroupTool.prototype.onUpdateState = function () {
-       this.setActive( false );
-};
-
-/**
- * Build a {@link OO.ui.ToolGroup toolgroup} from the specified configuration.
- *
- * @param {Object.<string,Array>} group Toolgroup configuration. Please see {@link OO.ui.ToolGroup toolgroup} for
- *  more information.
- * @return {OO.ui.ListToolGroup}
- */
-OO.ui.ToolGroupTool.prototype.createGroup = function ( group ) {
-       if ( group.include === '*' ) {
-               // Apply defaults to catch-all groups
-               if ( group.label === undefined ) {
-                       group.label = OO.ui.msg( 'ooui-toolbar-more' );
-               }
-       }
-
-       return this.toolbar.getToolGroupFactory().create( 'list', this.toolbar, group );
-};
-
-/**
- * BarToolGroups are one of three types of {@link OO.ui.ToolGroup toolgroups} that are used to
- * create {@link OO.ui.Toolbar toolbars} (the other types of groups are {@link OO.ui.MenuToolGroup MenuToolGroup}
- * and {@link OO.ui.ListToolGroup ListToolGroup}). The {@link OO.ui.Tool tools} in a BarToolGroup are
- * displayed by icon in a single row. The title of the tool is displayed when users move the mouse over
- * the tool.
- *
- * BarToolGroups are created by a {@link OO.ui.ToolGroupFactory tool group factory} when the toolbar is
- * set up.
- *
- *     @example
- *     // Example of a BarToolGroup with two tools
- *     var toolFactory = new OO.ui.ToolFactory();
- *     var toolGroupFactory = new OO.ui.ToolGroupFactory();
- *     var toolbar = new OO.ui.Toolbar( toolFactory, toolGroupFactory );
- *
- *     // We will be placing status text in this element when tools are used
- *     var $area = $( '<p>' ).text( 'Example of a BarToolGroup with two tools.' );
- *
- *     // Define the tools that we're going to place in our toolbar
- *
- *     // Create a class inheriting from OO.ui.Tool
- *     function SearchTool() {
- *         SearchTool.parent.apply( this, arguments );
- *     }
- *     OO.inheritClass( SearchTool, OO.ui.Tool );
- *     // Each tool must have a 'name' (used as an internal identifier, see later) and at least one
- *     // of 'icon' and 'title' (displayed icon and text).
- *     SearchTool.static.name = 'search';
- *     SearchTool.static.icon = 'search';
- *     SearchTool.static.title = 'Search...';
- *     // Defines the action that will happen when this tool is selected (clicked).
- *     SearchTool.prototype.onSelect = function () {
- *         $area.text( 'Search tool clicked!' );
- *         // Never display this tool as "active" (selected).
- *         this.setActive( false );
- *     };
- *     SearchTool.prototype.onUpdateState = function () {};
- *     // Make this tool available in our toolFactory and thus our toolbar
- *     toolFactory.register( SearchTool );
- *
- *     // This is a PopupTool. Rather than having a custom 'onSelect' action, it will display a
- *     // little popup window (a PopupWidget).
- *     function HelpTool( toolGroup, config ) {
- *         OO.ui.PopupTool.call( this, toolGroup, $.extend( { popup: {
- *             padded: true,
- *             label: 'Help',
- *             head: true
- *         } }, config ) );
- *         this.popup.$body.append( '<p>I am helpful!</p>' );
- *     }
- *     OO.inheritClass( HelpTool, OO.ui.PopupTool );
- *     HelpTool.static.name = 'help';
- *     HelpTool.static.icon = 'help';
- *     HelpTool.static.title = 'Help';
- *     toolFactory.register( HelpTool );
- *
- *     // Finally define which tools and in what order appear in the toolbar. Each tool may only be
- *     // used once (but not all defined tools must be used).
- *     toolbar.setup( [
- *         {
- *             // 'bar' tool groups display tools by icon only
- *             type: 'bar',
- *             include: [ 'search', 'help' ]
- *         }
- *     ] );
- *
- *     // Create some UI around the toolbar and place it in the document
- *     var frame = new OO.ui.PanelLayout( {
- *         expanded: false,
- *         framed: true
- *     } );
- *     var contentFrame = new OO.ui.PanelLayout( {
- *         expanded: false,
- *         padded: true
- *     } );
- *     frame.$element.append(
- *         toolbar.$element,
- *         contentFrame.$element.append( $area )
- *     );
- *     $( 'body' ).append( frame.$element );
- *
- *     // Here is where the toolbar is actually built. This must be done after inserting it into the
- *     // document.
- *     toolbar.initialize();
- *
- * For more information about how to add tools to a bar tool group, please see {@link OO.ui.ToolGroup toolgroup}.
- * For more information about toolbars in general, please see the [OOjs UI documentation on MediaWiki][1].
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
- *
- * @class
- * @extends OO.ui.ToolGroup
- *
- * @constructor
- * @param {OO.ui.Toolbar} toolbar
- * @param {Object} [config] Configuration options
- */
-OO.ui.BarToolGroup = function OoUiBarToolGroup( toolbar, config ) {
-       // Allow passing positional parameters inside the config object
-       if ( OO.isPlainObject( toolbar ) && config === undefined ) {
-               config = toolbar;
-               toolbar = config.toolbar;
-       }
-
-       // Parent constructor
-       OO.ui.BarToolGroup.parent.call( this, toolbar, config );
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-barToolGroup' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.BarToolGroup, OO.ui.ToolGroup );
-
-/* Static Properties */
-
-OO.ui.BarToolGroup.static.titleTooltips = true;
-
-OO.ui.BarToolGroup.static.accelTooltips = true;
-
-OO.ui.BarToolGroup.static.name = 'bar';
-
-/**
- * PopupToolGroup is an abstract base class used by both {@link OO.ui.MenuToolGroup MenuToolGroup}
- * and {@link OO.ui.ListToolGroup ListToolGroup} to provide a popup--an overlaid menu or list of tools with an
- * optional icon and label. This class can be used for other base classes that also use this functionality.
- *
- * @abstract
- * @class
- * @extends OO.ui.ToolGroup
- * @mixins OO.ui.mixin.IconElement
- * @mixins OO.ui.mixin.IndicatorElement
- * @mixins OO.ui.mixin.LabelElement
- * @mixins OO.ui.mixin.TitledElement
- * @mixins OO.ui.mixin.ClippableElement
- * @mixins OO.ui.mixin.TabIndexedElement
- *
- * @constructor
- * @param {OO.ui.Toolbar} toolbar
- * @param {Object} [config] Configuration options
- * @cfg {string} [header] Text to display at the top of the popup
- */
-OO.ui.PopupToolGroup = function OoUiPopupToolGroup( toolbar, config ) {
-       // Allow passing positional parameters inside the config object
-       if ( OO.isPlainObject( toolbar ) && config === undefined ) {
-               config = toolbar;
-               toolbar = config.toolbar;
-       }
-
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.PopupToolGroup.parent.call( this, toolbar, config );
-
-       // Properties
-       this.active = false;
-       this.dragging = false;
-       this.onBlurHandler = this.onBlur.bind( this );
-       this.$handle = $( '<span>' );
-
-       // Mixin constructors
-       OO.ui.mixin.IconElement.call( this, config );
-       OO.ui.mixin.IndicatorElement.call( this, config );
-       OO.ui.mixin.LabelElement.call( this, config );
-       OO.ui.mixin.TitledElement.call( this, config );
-       OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) );
-       OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: this.$handle } ) );
-
-       // Events
-       this.$handle.on( {
-               keydown: this.onHandleMouseKeyDown.bind( this ),
-               keyup: this.onHandleMouseKeyUp.bind( this ),
-               mousedown: this.onHandleMouseKeyDown.bind( this ),
-               mouseup: this.onHandleMouseKeyUp.bind( this )
-       } );
-
-       // Initialization
-       this.$handle
-               .addClass( 'oo-ui-popupToolGroup-handle' )
-               .append( this.$icon, this.$label, this.$indicator );
-       // If the pop-up should have a header, add it to the top of the toolGroup.
-       // Note: If this feature is useful for other widgets, we could abstract it into an
-       // OO.ui.HeaderedElement mixin constructor.
-       if ( config.header !== undefined ) {
-               this.$group
-                       .prepend( $( '<span>' )
-                               .addClass( 'oo-ui-popupToolGroup-header' )
-                               .text( config.header )
-                       );
-       }
-       this.$element
-               .addClass( 'oo-ui-popupToolGroup' )
-               .prepend( this.$handle );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.PopupToolGroup, OO.ui.ToolGroup );
-OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.IconElement );
-OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.IndicatorElement );
-OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.LabelElement );
-OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.TitledElement );
-OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.ClippableElement );
-OO.mixinClass( OO.ui.PopupToolGroup, OO.ui.mixin.TabIndexedElement );
-
-/* Methods */
-
-/**
- * @inheritdoc
- */
-OO.ui.PopupToolGroup.prototype.setDisabled = function () {
-       // Parent method
-       OO.ui.PopupToolGroup.parent.prototype.setDisabled.apply( this, arguments );
-
-       if ( this.isDisabled() && this.isElementAttached() ) {
-               this.setActive( false );
-       }
-};
-
-/**
- * Handle focus being lost.
- *
- * The event is actually generated from a mouseup/keyup, so it is not a normal blur event object.
- *
- * @protected
- * @param {jQuery.Event} e Mouse up or key up event
- */
-OO.ui.PopupToolGroup.prototype.onBlur = function ( e ) {
-       // Only deactivate when clicking outside the dropdown element
-       if ( $( e.target ).closest( '.oo-ui-popupToolGroup' )[ 0 ] !== this.$element[ 0 ] ) {
-               this.setActive( false );
-       }
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.PopupToolGroup.prototype.onMouseKeyUp = function ( e ) {
-       // Only close toolgroup when a tool was actually selected
-       if (
-               !this.isDisabled() && this.pressed && this.pressed === this.getTargetTool( e ) &&
-               ( e.which === OO.ui.MouseButtons.LEFT || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
-       ) {
-               this.setActive( false );
-       }
-       return OO.ui.PopupToolGroup.parent.prototype.onMouseKeyUp.call( this, e );
-};
-
-/**
- * Handle mouse up and key up events.
- *
- * @protected
- * @param {jQuery.Event} e Mouse up or key up event
- */
-OO.ui.PopupToolGroup.prototype.onHandleMouseKeyUp = function ( e ) {
-       if (
-               !this.isDisabled() &&
-               ( e.which === OO.ui.MouseButtons.LEFT || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
-       ) {
-               return false;
-       }
-};
-
-/**
- * Handle mouse down and key down events.
- *
- * @protected
- * @param {jQuery.Event} e Mouse down or key down event
- */
-OO.ui.PopupToolGroup.prototype.onHandleMouseKeyDown = function ( e ) {
-       if (
-               !this.isDisabled() &&
-               ( e.which === OO.ui.MouseButtons.LEFT || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
-       ) {
-               this.setActive( !this.active );
-               return false;
-       }
-};
-
-/**
- * Switch into 'active' mode.
- *
- * When active, the popup is visible. A mouseup event anywhere in the document will trigger
- * deactivation.
- */
-OO.ui.PopupToolGroup.prototype.setActive = function ( value ) {
-       var containerWidth, containerLeft;
-       value = !!value;
-       if ( this.active !== value ) {
-               this.active = value;
-               if ( value ) {
-                       this.getElementDocument().addEventListener( 'mouseup', this.onBlurHandler, true );
-                       this.getElementDocument().addEventListener( 'keyup', this.onBlurHandler, true );
-
-                       this.$clippable.css( 'left', '' );
-                       // Try anchoring the popup to the left first
-                       this.$element.addClass( 'oo-ui-popupToolGroup-active oo-ui-popupToolGroup-left' );
-                       this.toggleClipping( true );
-                       if ( this.isClippedHorizontally() ) {
-                               // Anchoring to the left caused the popup to clip, so anchor it to the right instead
-                               this.toggleClipping( false );
-                               this.$element
-                                       .removeClass( 'oo-ui-popupToolGroup-left' )
-                                       .addClass( 'oo-ui-popupToolGroup-right' );
-                               this.toggleClipping( true );
-                       }
-                       if ( this.isClippedHorizontally() ) {
-                               // Anchoring to the right also caused the popup to clip, so just make it fill the container
-                               containerWidth = this.$clippableScrollableContainer.width();
-                               containerLeft = this.$clippableScrollableContainer.offset().left;
-
-                               this.toggleClipping( false );
-                               this.$element.removeClass( 'oo-ui-popupToolGroup-right' );
-
-                               this.$clippable.css( {
-                                       left: -( this.$element.offset().left - containerLeft ),
-                                       width: containerWidth
-                               } );
-                       }
-               } else {
-                       this.getElementDocument().removeEventListener( 'mouseup', this.onBlurHandler, true );
-                       this.getElementDocument().removeEventListener( 'keyup', this.onBlurHandler, true );
-                       this.$element.removeClass(
-                               'oo-ui-popupToolGroup-active oo-ui-popupToolGroup-left  oo-ui-popupToolGroup-right'
-                       );
-                       this.toggleClipping( false );
-               }
-       }
-};
-
-/**
- * ListToolGroups are one of three types of {@link OO.ui.ToolGroup toolgroups} that are used to
- * create {@link OO.ui.Toolbar toolbars} (the other types of groups are {@link OO.ui.MenuToolGroup MenuToolGroup}
- * and {@link OO.ui.BarToolGroup BarToolGroup}). The {@link OO.ui.Tool tools} in a ListToolGroup are displayed
- * by label in a dropdown menu. The title of the tool is used as the label text. The menu itself can be configured
- * with a label, icon, indicator, header, and title.
- *
- * ListToolGroups can be configured to be expanded and collapsed. Collapsed lists will have a ‘More’ option that
- * users can select to see the full list of tools. If a collapsed toolgroup is expanded, a ‘Fewer’ option permits
- * users to collapse the list again.
- *
- * ListToolGroups are created by a {@link OO.ui.ToolGroupFactory toolgroup factory} when the toolbar is set up. The factory
- * requires the ListToolGroup's symbolic name, 'list', which is specified along with the other configurations. For more
- * information about how to add tools to a ListToolGroup, please see {@link OO.ui.ToolGroup toolgroup}.
- *
- *     @example
- *     // Example of a ListToolGroup
- *     var toolFactory = new OO.ui.ToolFactory();
- *     var toolGroupFactory = new OO.ui.ToolGroupFactory();
- *     var toolbar = new OO.ui.Toolbar( toolFactory, toolGroupFactory );
- *
- *     // Configure and register two tools
- *     function SettingsTool() {
- *         SettingsTool.parent.apply( this, arguments );
- *     }
- *     OO.inheritClass( SettingsTool, OO.ui.Tool );
- *     SettingsTool.static.name = 'settings';
- *     SettingsTool.static.icon = 'settings';
- *     SettingsTool.static.title = 'Change settings';
- *     SettingsTool.prototype.onSelect = function () {
- *         this.setActive( false );
- *     };
- *     SettingsTool.prototype.onUpdateState = function () {};
- *     toolFactory.register( SettingsTool );
- *     // Register two more tools, nothing interesting here
- *     function StuffTool() {
- *         StuffTool.parent.apply( this, arguments );
- *     }
- *     OO.inheritClass( StuffTool, OO.ui.Tool );
- *     StuffTool.static.name = 'stuff';
- *     StuffTool.static.icon = 'search';
- *     StuffTool.static.title = 'Change the world';
- *     StuffTool.prototype.onSelect = function () {
- *         this.setActive( false );
- *     };
- *     StuffTool.prototype.onUpdateState = function () {};
- *     toolFactory.register( StuffTool );
- *     toolbar.setup( [
- *         {
- *             // Configurations for list toolgroup.
- *             type: 'list',
- *             label: 'ListToolGroup',
- *             indicator: 'down',
- *             icon: 'ellipsis',
- *             title: 'This is the title, displayed when user moves the mouse over the list toolgroup',
- *             header: 'This is the header',
- *             include: [ 'settings', 'stuff' ],
- *             allowCollapse: ['stuff']
- *         }
- *     ] );
- *
- *     // Create some UI around the toolbar and place it in the document
- *     var frame = new OO.ui.PanelLayout( {
- *         expanded: false,
- *         framed: true
- *     } );
- *     frame.$element.append(
- *         toolbar.$element
- *     );
- *     $( 'body' ).append( frame.$element );
- *     // Build the toolbar. This must be done after the toolbar has been appended to the document.
- *     toolbar.initialize();
- *
- * For more information about toolbars in general, please see the [OOjs UI documentation on MediaWiki][1].
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
- *
- * @class
- * @extends OO.ui.PopupToolGroup
- *
- * @constructor
- * @param {OO.ui.Toolbar} toolbar
- * @param {Object} [config] Configuration options
- * @cfg {Array} [allowCollapse] Allow the specified tools to be collapsed. By default, collapsible tools
- *  will only be displayed if users click the ‘More’ option displayed at the bottom of the list. If
- *  the list is expanded, a ‘Fewer’ option permits users to collapse the list again. Any tools that
- *  are included in the toolgroup, but are not designated as collapsible, will always be displayed.
- *  To open a collapsible list in its expanded state, set #expanded to 'true'.
- * @cfg {Array} [forceExpand] Expand the specified tools. All other tools will be designated as collapsible.
- *  Unless #expanded is set to true, the collapsible tools will be collapsed when the list is first opened.
- * @cfg {boolean} [expanded=false] Expand collapsible tools. This config is only relevant if tools have
- *  been designated as collapsible. When expanded is set to true, all tools in the group will be displayed
- *  when the list is first opened. Users can collapse the list with a ‘Fewer’ option at the bottom.
- */
-OO.ui.ListToolGroup = function OoUiListToolGroup( toolbar, config ) {
-       // Allow passing positional parameters inside the config object
-       if ( OO.isPlainObject( toolbar ) && config === undefined ) {
-               config = toolbar;
-               toolbar = config.toolbar;
-       }
-
-       // Configuration initialization
-       config = config || {};
-
-       // Properties (must be set before parent constructor, which calls #populate)
-       this.allowCollapse = config.allowCollapse;
-       this.forceExpand = config.forceExpand;
-       this.expanded = config.expanded !== undefined ? config.expanded : false;
-       this.collapsibleTools = [];
-
-       // Parent constructor
-       OO.ui.ListToolGroup.parent.call( this, toolbar, config );
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-listToolGroup' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.ListToolGroup, OO.ui.PopupToolGroup );
-
-/* Static Properties */
-
-OO.ui.ListToolGroup.static.name = 'list';
-
-/* Methods */
-
-/**
- * @inheritdoc
- */
-OO.ui.ListToolGroup.prototype.populate = function () {
-       var i, len, allowCollapse = [];
-
-       OO.ui.ListToolGroup.parent.prototype.populate.call( this );
-
-       // Update the list of collapsible tools
-       if ( this.allowCollapse !== undefined ) {
-               allowCollapse = this.allowCollapse;
-       } else if ( this.forceExpand !== undefined ) {
-               allowCollapse = OO.simpleArrayDifference( Object.keys( this.tools ), this.forceExpand );
-       }
-
-       this.collapsibleTools = [];
-       for ( i = 0, len = allowCollapse.length; i < len; i++ ) {
-               if ( this.tools[ allowCollapse[ i ] ] !== undefined ) {
-                       this.collapsibleTools.push( this.tools[ allowCollapse[ i ] ] );
-               }
-       }
-
-       // Keep at the end, even when tools are added
-       this.$group.append( this.getExpandCollapseTool().$element );
-
-       this.getExpandCollapseTool().toggle( this.collapsibleTools.length !== 0 );
-       this.updateCollapsibleState();
-};
-
-OO.ui.ListToolGroup.prototype.getExpandCollapseTool = function () {
-       var ExpandCollapseTool;
-       if ( this.expandCollapseTool === undefined ) {
-               ExpandCollapseTool = function () {
-                       ExpandCollapseTool.parent.apply( this, arguments );
-               };
-
-               OO.inheritClass( ExpandCollapseTool, OO.ui.Tool );
-
-               ExpandCollapseTool.prototype.onSelect = function () {
-                       this.toolGroup.expanded = !this.toolGroup.expanded;
-                       this.toolGroup.updateCollapsibleState();
-                       this.setActive( false );
-               };
-               ExpandCollapseTool.prototype.onUpdateState = function () {
-                       // Do nothing. Tool interface requires an implementation of this function.
-               };
-
-               ExpandCollapseTool.static.name = 'more-fewer';
-
-               this.expandCollapseTool = new ExpandCollapseTool( this );
-       }
-       return this.expandCollapseTool;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.ListToolGroup.prototype.onMouseKeyUp = function ( e ) {
-       // Do not close the popup when the user wants to show more/fewer tools
-       if (
-               $( e.target ).closest( '.oo-ui-tool-name-more-fewer' ).length &&
-               ( e.which === OO.ui.MouseButtons.LEFT || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
-       ) {
-               // HACK: Prevent the popup list from being hidden. Skip the PopupToolGroup implementation (which
-               // hides the popup list when a tool is selected) and call ToolGroup's implementation directly.
-               return OO.ui.ListToolGroup.parent.parent.prototype.onMouseKeyUp.call( this, e );
-       } else {
-               return OO.ui.ListToolGroup.parent.prototype.onMouseKeyUp.call( this, e );
-       }
-};
-
-OO.ui.ListToolGroup.prototype.updateCollapsibleState = function () {
-       var i, len;
-
-       this.getExpandCollapseTool()
-               .setIcon( this.expanded ? 'collapse' : 'expand' )
-               .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 );
-       }
-};
-
-/**
- * MenuToolGroups are one of three types of {@link OO.ui.ToolGroup toolgroups} that are used to
- * create {@link OO.ui.Toolbar toolbars} (the other types of groups are {@link OO.ui.BarToolGroup BarToolGroup}
- * and {@link OO.ui.ListToolGroup ListToolGroup}). MenuToolGroups contain selectable {@link OO.ui.Tool tools},
- * which are displayed by label in a dropdown menu. The tool's title is used as the label text, and the
- * menu label is updated to reflect which tool or tools are currently selected. If no tools are selected,
- * the menu label is empty. The menu can be configured with an indicator, icon, title, and/or header.
- *
- * MenuToolGroups are created by a {@link OO.ui.ToolGroupFactory tool group factory} when the toolbar
- * is set up.
- *
- *     @example
- *     // Example of a MenuToolGroup
- *     var toolFactory = new OO.ui.ToolFactory();
- *     var toolGroupFactory = new OO.ui.ToolGroupFactory();
- *     var toolbar = new OO.ui.Toolbar( toolFactory, toolGroupFactory );
- *
- *     // We will be placing status text in this element when tools are used
- *     var $area = $( '<p>' ).text( 'An example of a MenuToolGroup. Select a tool from the dropdown menu.' );
- *
- *     // Define the tools that we're going to place in our toolbar
- *
- *     function SettingsTool() {
- *         SettingsTool.parent.apply( this, arguments );
- *         this.reallyActive = false;
- *     }
- *     OO.inheritClass( SettingsTool, OO.ui.Tool );
- *     SettingsTool.static.name = 'settings';
- *     SettingsTool.static.icon = 'settings';
- *     SettingsTool.static.title = 'Change settings';
- *     SettingsTool.prototype.onSelect = function () {
- *         $area.text( 'Settings tool clicked!' );
- *         // Toggle the active state on each click
- *         this.reallyActive = !this.reallyActive;
- *         this.setActive( this.reallyActive );
- *         // To update the menu label
- *         this.toolbar.emit( 'updateState' );
- *     };
- *     SettingsTool.prototype.onUpdateState = function () {};
- *     toolFactory.register( SettingsTool );
- *
- *     function StuffTool() {
- *         StuffTool.parent.apply( this, arguments );
- *         this.reallyActive = false;
- *     }
- *     OO.inheritClass( StuffTool, OO.ui.Tool );
- *     StuffTool.static.name = 'stuff';
- *     StuffTool.static.icon = 'ellipsis';
- *     StuffTool.static.title = 'More stuff';
- *     StuffTool.prototype.onSelect = function () {
- *         $area.text( 'More stuff tool clicked!' );
- *         // Toggle the active state on each click
- *         this.reallyActive = !this.reallyActive;
- *         this.setActive( this.reallyActive );
- *         // To update the menu label
- *         this.toolbar.emit( 'updateState' );
- *     };
- *     StuffTool.prototype.onUpdateState = function () {};
- *     toolFactory.register( StuffTool );
- *
- *     // Finally define which tools and in what order appear in the toolbar. Each tool may only be
- *     // used once (but not all defined tools must be used).
- *     toolbar.setup( [
- *         {
- *             type: 'menu',
- *             header: 'This is the (optional) header',
- *             title: 'This is the (optional) title',
- *             indicator: 'down',
- *             include: [ 'settings', 'stuff' ]
- *         }
- *     ] );
- *
- *     // Create some UI around the toolbar and place it in the document
- *     var frame = new OO.ui.PanelLayout( {
- *         expanded: false,
- *         framed: true
- *     } );
- *     var contentFrame = new OO.ui.PanelLayout( {
- *         expanded: false,
- *         padded: true
- *     } );
- *     frame.$element.append(
- *         toolbar.$element,
- *         contentFrame.$element.append( $area )
- *     );
- *     $( 'body' ).append( frame.$element );
- *
- *     // Here is where the toolbar is actually built. This must be done after inserting it into the
- *     // document.
- *     toolbar.initialize();
- *     toolbar.emit( 'updateState' );
- *
- * For more information about how to add tools to a MenuToolGroup, please see {@link OO.ui.ToolGroup toolgroup}.
- * For more information about toolbars in general, please see the [OOjs UI documentation on MediaWiki] [1].
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Toolbars
- *
- * @class
- * @extends OO.ui.PopupToolGroup
- *
- * @constructor
- * @param {OO.ui.Toolbar} toolbar
- * @param {Object} [config] Configuration options
- */
-OO.ui.MenuToolGroup = function OoUiMenuToolGroup( toolbar, config ) {
-       // Allow passing positional parameters inside the config object
-       if ( OO.isPlainObject( toolbar ) && config === undefined ) {
-               config = toolbar;
-               toolbar = config.toolbar;
-       }
-
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.MenuToolGroup.parent.call( this, toolbar, config );
-
-       // Events
-       this.toolbar.connect( this, { updateState: 'onUpdateState' } );
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-menuToolGroup' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.MenuToolGroup, OO.ui.PopupToolGroup );
-
-/* Static Properties */
-
-OO.ui.MenuToolGroup.static.name = 'menu';
-
-/* Methods */
-
-/**
- * Handle the toolbar state being updated.
- *
- * When the state changes, the title of each active item in the menu will be joined together and
- * used as a label for the group. The label will be empty if none of the items are active.
- *
- * @private
- */
-OO.ui.MenuToolGroup.prototype.onUpdateState = function () {
-       var name,
-               labelTexts = [];
-
-       for ( name in this.tools ) {
-               if ( this.tools[ name ].isActive() ) {
-                       labelTexts.push( this.tools[ name ].getTitle() );
-               }
-       }
-
-       this.setLabel( labelTexts.join( ', ' ) || ' ' );
-};
-
-}( OO ) );
-
-/*!
- * OOjs UI v0.15.2
- * https://www.mediawiki.org/wiki/OOjs_UI
- *
- * Copyright 2011–2016 OOjs UI Team and other contributors.
- * Released under the MIT license
- * http://oojs.mit-license.org
- *
- * Date: 2016-02-02T22:07:00Z
- */
-( function ( OO ) {
-
-'use strict';
-
-/**
- * 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.
- *
- * Both actions and action sets are primarily used with {@link OO.ui.Dialog Dialogs}.
- * 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
- * @mixins OO.ui.mixin.PendingElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {string} [action] Symbolic name of the action (e.g., ‘continue’ or ‘cancel’).
- * @cfg {string[]} [modes] Symbolic names of the modes (e.g., ‘edit’ or ‘read’) in which the action
- *  should be made available. See the action set's {@link OO.ui.ActionSet#setMode setMode} method
- *  for more information about setting modes.
- * @cfg {boolean} [framed=false] Render the action button with a frame
- */
-OO.ui.ActionWidget = function OoUiActionWidget( config ) {
-       // Configuration initialization
-       config = $.extend( { framed: false }, config );
-
-       // Parent constructor
-       OO.ui.ActionWidget.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.PendingElement.call( this, config );
-
-       // Properties
-       this.action = config.action || '';
-       this.modes = config.modes || [];
-       this.width = 0;
-       this.height = 0;
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-actionWidget' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.ActionWidget, OO.ui.ButtonWidget );
-OO.mixinClass( OO.ui.ActionWidget, OO.ui.mixin.PendingElement );
-
-/* Events */
-
-/**
- * A resize event is emitted when the size of the widget changes.
- *
- * @event resize
- */
-
-/* Methods */
-
-/**
- * Check if the action is configured to be available in the specified `mode`.
- *
- * @param {string} mode Name of mode
- * @return {boolean} The action is configured with the mode
- */
-OO.ui.ActionWidget.prototype.hasMode = function ( mode ) {
-       return this.modes.indexOf( mode ) !== -1;
-};
-
-/**
- * Get the symbolic name of the action (e.g., ‘continue’ or ‘cancel’).
- *
- * @return {string}
- */
-OO.ui.ActionWidget.prototype.getAction = function () {
-       return this.action;
-};
-
-/**
- * Get the symbolic name of the mode or modes for which the action is configured to be available.
- *
- * The current mode is set with the action set's {@link OO.ui.ActionSet#setMode setMode} method.
- * Only actions that are configured to be avaiable in the current mode will be visible. All other actions
- * are hidden.
- *
- * @return {string[]}
- */
-OO.ui.ActionWidget.prototype.getModes = function () {
-       return this.modes.slice();
-};
-
-/**
- * Emit a resize event if the size has changed.
- *
- * @private
- * @chainable
- */
-OO.ui.ActionWidget.prototype.propagateResize = function () {
-       var width, height;
-
-       if ( this.isElementAttached() ) {
-               width = this.$element.width();
-               height = this.$element.height();
-
-               if ( width !== this.width || height !== this.height ) {
-                       this.width = width;
-                       this.height = height;
-                       this.emit( 'resize' );
-               }
-       }
-
-       return this;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.ActionWidget.prototype.setIcon = function () {
-       // Mixin method
-       OO.ui.mixin.IconElement.prototype.setIcon.apply( this, arguments );
-       this.propagateResize();
-
-       return this;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.ActionWidget.prototype.setLabel = function () {
-       // Mixin method
-       OO.ui.mixin.LabelElement.prototype.setLabel.apply( this, arguments );
-       this.propagateResize();
-
-       return this;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.ActionWidget.prototype.setFlags = function () {
-       // Mixin method
-       OO.ui.mixin.FlaggedElement.prototype.setFlags.apply( this, arguments );
-       this.propagateResize();
-
-       return this;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.ActionWidget.prototype.clearFlags = function () {
-       // Mixin method
-       OO.ui.mixin.FlaggedElement.prototype.clearFlags.apply( this, arguments );
-       this.propagateResize();
-
-       return this;
-};
-
-/**
- * Toggle the visibility of the action button.
- *
- * @param {boolean} [show] Show button, omit to toggle visibility
- * @chainable
- */
-OO.ui.ActionWidget.prototype.toggle = function () {
-       // Parent method
-       OO.ui.ActionWidget.parent.prototype.toggle.apply( this, arguments );
-       this.propagateResize();
-
-       return this;
-};
-
-/**
- * ActionSets manage the behavior of the {@link OO.ui.ActionWidget action widgets} that comprise them.
- * Actions can be made available for specific contexts (modes) and circumstances
- * (abilities). Action sets are primarily used with {@link OO.ui.Dialog Dialogs}.
- *
- * ActionSets contain two types of actions:
- *
- * - Special: Special actions are the first visible actions with special flags, such as 'safe' and 'primary', the default special flags. Additional special flags can be configured in subclasses with the static #specialFlags property.
- * - Other: Other actions include all non-special visible actions.
- *
- * Please see the [OOjs UI documentation on MediaWiki][1] for more information.
- *
- *     @example
- *     // Example: An action set used in a process dialog
- *     function MyProcessDialog( config ) {
- *         MyProcessDialog.parent.call( this, config );
- *     }
- *     OO.inheritClass( MyProcessDialog, OO.ui.ProcessDialog );
- *     MyProcessDialog.static.title = 'An action set in a process dialog';
- *     // An action set that uses modes ('edit' and 'help' mode, in this example).
- *     MyProcessDialog.static.actions = [
- *         { action: 'continue', modes: 'edit', label: 'Continue', flags: [ 'primary', 'constructive' ] },
- *         { action: 'help', modes: 'edit', label: 'Help' },
- *         { modes: 'edit', label: 'Cancel', flags: 'safe' },
- *         { action: 'back', modes: 'help', label: 'Back', flags: 'safe' }
- *     ];
- *
- *     MyProcessDialog.prototype.initialize = function () {
- *         MyProcessDialog.parent.prototype.initialize.apply( this, arguments );
- *         this.panel1 = new OO.ui.PanelLayout( { padded: true, expanded: false } );
- *         this.panel1.$element.append( '<p>This dialog uses an action set (continue, help, cancel, back) configured with modes. This is edit mode. Click \'help\' to see help mode.</p>' );
- *         this.panel2 = new OO.ui.PanelLayout( { padded: true, expanded: false } );
- *         this.panel2.$element.append( '<p>This is help mode. Only the \'back\' action widget is configured to be visible here. Click \'back\' to return to \'edit\' mode.</p>' );
- *         this.stackLayout = new OO.ui.StackLayout( {
- *             items: [ this.panel1, this.panel2 ]
- *         } );
- *         this.$body.append( this.stackLayout.$element );
- *     };
- *     MyProcessDialog.prototype.getSetupProcess = function ( data ) {
- *         return MyProcessDialog.parent.prototype.getSetupProcess.call( this, data )
- *             .next( function () {
- *                 this.actions.setMode( 'edit' );
- *             }, this );
- *     };
- *     MyProcessDialog.prototype.getActionProcess = function ( action ) {
- *         if ( action === 'help' ) {
- *             this.actions.setMode( 'help' );
- *             this.stackLayout.setItem( this.panel2 );
- *         } else if ( action === 'back' ) {
- *             this.actions.setMode( 'edit' );
- *             this.stackLayout.setItem( this.panel1 );
- *         } else if ( action === 'continue' ) {
- *             var dialog = this;
- *             return new OO.ui.Process( function () {
- *                 dialog.close();
- *             } );
- *         }
- *         return MyProcessDialog.parent.prototype.getActionProcess.call( this, action );
- *     };
- *     MyProcessDialog.prototype.getBodyHeight = function () {
- *         return this.panel1.$element.outerHeight( true );
- *     };
- *     var windowManager = new OO.ui.WindowManager();
- *     $( 'body' ).append( windowManager.$element );
- *     var dialog = new MyProcessDialog( {
- *         size: 'medium'
- *     } );
- *     windowManager.addWindows( [ dialog ] );
- *     windowManager.openWindow( dialog );
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Process_Dialogs#Action_sets
- *
- * @abstract
- * @class
- * @mixins OO.EventEmitter
- *
- * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.ActionSet = function OoUiActionSet( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Mixin constructors
-       OO.EventEmitter.call( this );
-
-       // Properties
-       this.list = [];
-       this.categories = {
-               actions: 'getAction',
-               flags: 'getFlags',
-               modes: 'getModes'
-       };
-       this.categorized = {};
-       this.special = {};
-       this.others = [];
-       this.organized = false;
-       this.changing = false;
-       this.changed = false;
-};
-
-/* Setup */
-
-OO.mixinClass( OO.ui.ActionSet, OO.EventEmitter );
-
-/* Static Properties */
-
-/**
- * Symbolic name of the flags used to identify special actions. Special actions are displayed in the
- *  header of a {@link OO.ui.ProcessDialog process dialog}.
- *  See the [OOjs UI documentation on MediaWiki][2] for more information and examples.
- *
- *  [2]:https://www.mediawiki.org/wiki/OOjs_UI/Windows/Process_Dialogs
- *
- * @abstract
- * @static
- * @inheritable
- * @property {string}
- */
-OO.ui.ActionSet.static.specialFlags = [ 'safe', 'primary' ];
-
-/* Events */
-
-/**
- * @event click
- *
- * A 'click' event is emitted when an action is clicked.
- *
- * @param {OO.ui.ActionWidget} action Action that was clicked
- */
-
-/**
- * @event resize
- *
- * A 'resize' event is emitted when an action widget is resized.
- *
- * @param {OO.ui.ActionWidget} action Action that was resized
- */
-
-/**
- * @event add
- *
- * An 'add' event is emitted when actions are {@link #method-add added} to the action set.
- *
- * @param {OO.ui.ActionWidget[]} added Actions added
- */
-
-/**
- * @event remove
- *
- * A 'remove' event is emitted when actions are {@link #method-remove removed}
- *  or {@link #clear cleared}.
- *
- * @param {OO.ui.ActionWidget[]} added Actions removed
- */
-
-/**
- * @event change
- *
- * A 'change' event is emitted when actions are {@link #method-add added}, {@link #clear cleared},
- * or {@link #method-remove removed} from the action set or when the {@link #setMode mode} is changed.
- *
- */
-
-/* Methods */
-
-/**
- * Handle action change events.
- *
- * @private
- * @fires change
- */
-OO.ui.ActionSet.prototype.onActionChange = function () {
-       this.organized = false;
-       if ( this.changing ) {
-               this.changed = true;
-       } else {
-               this.emit( 'change' );
-       }
-};
-
-/**
- * Check if an action is one of the special actions.
- *
- * @param {OO.ui.ActionWidget} action Action to check
- * @return {boolean} Action is special
- */
-OO.ui.ActionSet.prototype.isSpecial = function ( action ) {
-       var flag;
-
-       for ( flag in this.special ) {
-               if ( action === this.special[ flag ] ) {
-                       return true;
-               }
-       }
-
-       return false;
-};
-
-/**
- * Get action widgets based on the specified filter: ‘actions’, ‘flags’, ‘modes’, ‘visible’,
- *  or ‘disabled’.
- *
- * @param {Object} [filters] Filters to use, omit to get all actions
- * @param {string|string[]} [filters.actions] Actions that action widgets must have
- * @param {string|string[]} [filters.flags] Flags that action widgets must have (e.g., 'safe')
- * @param {string|string[]} [filters.modes] Modes that action widgets must have
- * @param {boolean} [filters.visible] Action widgets must be visible
- * @param {boolean} [filters.disabled] Action widgets must be disabled
- * @return {OO.ui.ActionWidget[]} Action widgets matching all criteria
- */
-OO.ui.ActionSet.prototype.get = function ( filters ) {
-       var i, len, list, category, actions, index, match, matches;
-
-       if ( filters ) {
-               this.organize();
-
-               // Collect category candidates
-               matches = [];
-               for ( category in this.categorized ) {
-                       list = filters[ category ];
-                       if ( list ) {
-                               if ( !Array.isArray( list ) ) {
-                                       list = [ list ];
-                               }
-                               for ( i = 0, len = list.length; i < len; i++ ) {
-                                       actions = this.categorized[ category ][ list[ i ] ];
-                                       if ( Array.isArray( actions ) ) {
-                                               matches.push.apply( matches, actions );
-                                       }
-                               }
-                       }
-               }
-               // Remove by boolean filters
-               for ( i = 0, len = matches.length; i < len; i++ ) {
-                       match = matches[ i ];
-                       if (
-                               ( filters.visible !== undefined && match.isVisible() !== filters.visible ) ||
-                               ( filters.disabled !== undefined && match.isDisabled() !== filters.disabled )
-                       ) {
-                               matches.splice( i, 1 );
-                               len--;
-                               i--;
-                       }
-               }
-               // Remove duplicates
-               for ( i = 0, len = matches.length; i < len; i++ ) {
-                       match = matches[ i ];
-                       index = matches.lastIndexOf( match );
-                       while ( index !== i ) {
-                               matches.splice( index, 1 );
-                               len--;
-                               index = matches.lastIndexOf( match );
-                       }
-               }
-               return matches;
-       }
-       return this.list.slice();
-};
-
-/**
- * Get 'special' actions.
- *
- * Special actions are the first visible action widgets with special flags, such as 'safe' and 'primary'.
- * Special flags can be configured in subclasses by changing the static #specialFlags property.
- *
- * @return {OO.ui.ActionWidget[]|null} 'Special' action widgets.
- */
-OO.ui.ActionSet.prototype.getSpecial = function () {
-       this.organize();
-       return $.extend( {}, this.special );
-};
-
-/**
- * Get 'other' actions.
- *
- * Other actions include all non-special visible action widgets.
- *
- * @return {OO.ui.ActionWidget[]} 'Other' action widgets
- */
-OO.ui.ActionSet.prototype.getOthers = function () {
-       this.organize();
-       return this.others.slice();
-};
-
-/**
- * Set the mode  (e.g., ‘edit’ or ‘view’). Only {@link OO.ui.ActionWidget#modes actions} configured
- * to be available in the specified mode will be made visible. All other actions will be hidden.
- *
- * @param {string} mode The mode. Only actions configured to be available in the specified
- *  mode will be made visible.
- * @chainable
- * @fires toggle
- * @fires change
- */
-OO.ui.ActionSet.prototype.setMode = function ( mode ) {
-       var i, len, action;
-
-       this.changing = true;
-       for ( i = 0, len = this.list.length; i < len; i++ ) {
-               action = this.list[ i ];
-               action.toggle( action.hasMode( mode ) );
-       }
-
-       this.organized = false;
-       this.changing = false;
-       this.emit( 'change' );
-
-       return this;
-};
-
-/**
- * Set the abilities of the specified actions.
- *
- * Action widgets that are configured with the specified actions will be enabled
- * or disabled based on the boolean values specified in the `actions`
- * parameter.
- *
- * @param {Object.<string,boolean>} actions A list keyed by action name with boolean
- *  values that indicate whether or not the action should be enabled.
- * @chainable
- */
-OO.ui.ActionSet.prototype.setAbilities = function ( actions ) {
-       var i, len, action, item;
-
-       for ( i = 0, len = this.list.length; i < len; i++ ) {
-               item = this.list[ i ];
-               action = item.getAction();
-               if ( actions[ action ] !== undefined ) {
-                       item.setDisabled( !actions[ action ] );
-               }
-       }
-
-       return this;
-};
-
-/**
- * Executes a function once per action.
- *
- * When making changes to multiple actions, use this method instead of iterating over the actions
- * manually to defer emitting a #change event until after all actions have been changed.
- *
- * @param {Object|null} actions Filters to use to determine which actions to iterate over; see #get
- * @param {Function} callback Callback to run for each action; callback is invoked with three
- *   arguments: the action, the action's index, the list of actions being iterated over
- * @chainable
- */
-OO.ui.ActionSet.prototype.forEach = function ( filter, callback ) {
-       this.changed = false;
-       this.changing = true;
-       this.get( filter ).forEach( callback );
-       this.changing = false;
-       if ( this.changed ) {
-               this.emit( 'change' );
-       }
-
-       return this;
-};
-
-/**
- * Add action widgets to the action set.
- *
- * @param {OO.ui.ActionWidget[]} actions Action widgets to add
- * @chainable
- * @fires add
- * @fires change
- */
-OO.ui.ActionSet.prototype.add = function ( actions ) {
-       var i, len, action;
-
-       this.changing = true;
-       for ( i = 0, len = actions.length; i < len; i++ ) {
-               action = actions[ i ];
-               action.connect( this, {
-                       click: [ 'emit', 'click', action ],
-                       resize: [ 'emit', 'resize', action ],
-                       toggle: [ 'onActionChange' ]
-               } );
-               this.list.push( action );
-       }
-       this.organized = false;
-       this.emit( 'add', actions );
-       this.changing = false;
-       this.emit( 'change' );
-
-       return this;
-};
-
-/**
- * Remove action widgets from the set.
- *
- * To remove all actions, you may wish to use the #clear method instead.
- *
- * @param {OO.ui.ActionWidget[]} actions Action widgets to remove
- * @chainable
- * @fires remove
- * @fires change
- */
-OO.ui.ActionSet.prototype.remove = function ( actions ) {
-       var i, len, index, action;
-
-       this.changing = true;
-       for ( i = 0, len = actions.length; i < len; i++ ) {
-               action = actions[ i ];
-               index = this.list.indexOf( action );
-               if ( index !== -1 ) {
-                       action.disconnect( this );
-                       this.list.splice( index, 1 );
-               }
-       }
-       this.organized = false;
-       this.emit( 'remove', actions );
-       this.changing = false;
-       this.emit( 'change' );
-
-       return this;
-};
-
-/**
- * Remove all action widets from the set.
- *
- * To remove only specified actions, use the {@link #method-remove remove} method instead.
- *
- * @chainable
- * @fires remove
- * @fires change
- */
-OO.ui.ActionSet.prototype.clear = function () {
-       var i, len, action,
-               removed = this.list.slice();
-
-       this.changing = true;
-       for ( i = 0, len = this.list.length; i < len; i++ ) {
-               action = this.list[ i ];
-               action.disconnect( this );
-       }
-
-       this.list = [];
-
-       this.organized = false;
-       this.emit( 'remove', removed );
-       this.changing = false;
-       this.emit( 'change' );
-
-       return this;
-};
-
-/**
- * Organize actions.
- *
- * This is called whenever organized information is requested. It will only reorganize the actions
- * if something has changed since the last time it ran.
- *
- * @private
- * @chainable
- */
-OO.ui.ActionSet.prototype.organize = function () {
-       var i, iLen, j, jLen, flag, action, category, list, item, special,
-               specialFlags = this.constructor.static.specialFlags;
-
-       if ( !this.organized ) {
-               this.categorized = {};
-               this.special = {};
-               this.others = [];
-               for ( i = 0, iLen = this.list.length; i < iLen; i++ ) {
-                       action = this.list[ i ];
-                       if ( action.isVisible() ) {
-                               // Populate categories
-                               for ( category in this.categories ) {
-                                       if ( !this.categorized[ category ] ) {
-                                               this.categorized[ category ] = {};
-                                       }
-                                       list = action[ this.categories[ category ] ]();
-                                       if ( !Array.isArray( list ) ) {
-                                               list = [ list ];
-                                       }
-                                       for ( j = 0, jLen = list.length; j < jLen; j++ ) {
-                                               item = list[ j ];
-                                               if ( !this.categorized[ category ][ item ] ) {
-                                                       this.categorized[ category ][ item ] = [];
-                                               }
-                                               this.categorized[ category ][ item ].push( action );
-                                       }
-                               }
-                               // Populate special/others
-                               special = false;
-                               for ( j = 0, jLen = specialFlags.length; j < jLen; j++ ) {
-                                       flag = specialFlags[ j ];
-                                       if ( !this.special[ flag ] && action.hasFlag( flag ) ) {
-                                               this.special[ flag ] = action;
-                                               special = true;
-                                               break;
-                                       }
-                               }
-                               if ( !special ) {
-                                       this.others.push( action );
-                               }
-                       }
-               }
-               this.organized = true;
-       }
-
-       return this;
-};
-
-/**
- * Errors contain a required message (either a string or jQuery selection) that is used to describe what went wrong
- * in a {@link OO.ui.Process process}. The error's #recoverable and #warning configurations are used to customize the
- * appearance and functionality of the error interface.
- *
- * The basic error interface contains a formatted error message as well as two buttons: 'Dismiss' and 'Try again' (i.e., the error
- * is 'recoverable' by default). If the error is not recoverable, the 'Try again' button will not be rendered and the widget
- * that initiated the failed process will be disabled.
- *
- * If the error is a warning, the error interface will include a 'Dismiss' and a 'Continue' button, which will try the
- * process again.
- *
- * For an example of error interfaces, please see the [OOjs UI documentation on MediaWiki][1].
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Process_Dialogs#Processes_and_errors
- *
- * @class
- *
- * @constructor
- * @param {string|jQuery} message Description of error
- * @param {Object} [config] Configuration options
- * @cfg {boolean} [recoverable=true] Error is recoverable.
- *  By default, errors are recoverable, and users can try the process again.
- * @cfg {boolean} [warning=false] Error is a warning.
- *  If the error is a warning, the error interface will include a
- *  'Dismiss' and a 'Continue' button. It is the responsibility of the developer to ensure that the warning
- *  is not triggered a second time if the user chooses to continue.
- */
-OO.ui.Error = function OoUiError( message, config ) {
-       // Allow passing positional parameters inside the config object
-       if ( OO.isPlainObject( message ) && config === undefined ) {
-               config = message;
-               message = config.message;
-       }
-
-       // Configuration initialization
-       config = config || {};
-
-       // Properties
-       this.message = message instanceof jQuery ? message : String( message );
-       this.recoverable = config.recoverable === undefined || !!config.recoverable;
-       this.warning = !!config.warning;
-};
-
-/* Setup */
-
-OO.initClass( OO.ui.Error );
-
-/* Methods */
-
-/**
- * Check if the error is recoverable.
- *
- * If the error is recoverable, users are able to try the process again.
- *
- * @return {boolean} Error is recoverable
- */
-OO.ui.Error.prototype.isRecoverable = function () {
-       return this.recoverable;
-};
-
-/**
- * Check if the error is a warning.
- *
- * If the error is a warning, the error interface will include a 'Dismiss' and a 'Continue' button.
- *
- * @return {boolean} Error is warning
- */
-OO.ui.Error.prototype.isWarning = function () {
-       return this.warning;
-};
-
-/**
- * Get error message as DOM nodes.
- *
- * @return {jQuery} Error message in DOM nodes
- */
-OO.ui.Error.prototype.getMessage = function () {
-       return this.message instanceof jQuery ?
-               this.message.clone() :
-               $( '<div>' ).text( this.message ).contents();
-};
-
-/**
- * Get the error message text.
- *
- * @return {string} Error message
- */
-OO.ui.Error.prototype.getMessageText = function () {
-       return this.message instanceof jQuery ? this.message.text() : this.message;
-};
-
-/**
- * A Process is a list of steps that are called in sequence. The step can be a number, a jQuery promise,
- * or a function:
- *
- * - **number**: the process will wait for the specified number of milliseconds before proceeding.
- * - **promise**: the process will continue to the next step when the promise is successfully resolved
- *  or stop if the promise is rejected.
- * - **function**: the process will execute the function. The process will stop if the function returns
- *  either a boolean `false` or a promise that is rejected; if the function returns a number, the process
- *  will wait for that number of milliseconds before proceeding.
- *
- * If the process fails, an {@link OO.ui.Error error} is generated. Depending on how the error is
- * configured, users can dismiss the error and try the process again, or not. If a process is stopped,
- * its remaining steps will not be performed.
- *
- * @class
- *
- * @constructor
- * @param {number|jQuery.Promise|Function} step Number of miliseconds to wait before proceeding, promise
- *  that must be resolved before proceeding, or a function to execute. See #createStep for more information. see #createStep for more information
- * @param {Object} [context=null] Execution context of the function. The context is ignored if the step is
- *  a number or promise.
- * @return {Object} Step object, with `callback` and `context` properties
- */
-OO.ui.Process = function ( step, context ) {
-       // Properties
-       this.steps = [];
-
-       // Initialization
-       if ( step !== undefined ) {
-               this.next( step, context );
-       }
-};
-
-/* Setup */
-
-OO.initClass( OO.ui.Process );
-
-/* Methods */
-
-/**
- * Start the process.
- *
- * @return {jQuery.Promise} Promise that is resolved when all steps have successfully completed.
- *  If any of the steps return a promise that is rejected or a boolean false, this promise is rejected
- *  and any remaining steps are not performed.
- */
-OO.ui.Process.prototype.execute = function () {
-       var i, len, promise;
-
-       /**
-        * Continue execution.
-        *
-        * @ignore
-        * @param {Array} step A function and the context it should be called in
-        * @return {Function} Function that continues the process
-        */
-       function proceed( step ) {
-               return function () {
-                       // Execute step in the correct context
-                       var deferred,
-                               result = step.callback.call( step.context );
-
-                       if ( result === false ) {
-                               // Use rejected promise for boolean false results
-                               return $.Deferred().reject( [] ).promise();
-                       }
-                       if ( typeof result === 'number' ) {
-                               if ( result < 0 ) {
-                                       throw new Error( 'Cannot go back in time: flux capacitor is out of service' );
-                               }
-                               // Use a delayed promise for numbers, expecting them to be in milliseconds
-                               deferred = $.Deferred();
-                               setTimeout( deferred.resolve, result );
-                               return deferred.promise();
-                       }
-                       if ( result instanceof OO.ui.Error ) {
-                               // Use rejected promise for error
-                               return $.Deferred().reject( [ result ] ).promise();
-                       }
-                       if ( Array.isArray( result ) && result.length && result[ 0 ] instanceof OO.ui.Error ) {
-                               // Use rejected promise for list of errors
-                               return $.Deferred().reject( result ).promise();
-                       }
-                       // Duck-type the object to see if it can produce a promise
-                       if ( result && $.isFunction( result.promise ) ) {
-                               // Use a promise generated from the result
-                               return result.promise();
-                       }
-                       // Use resolved promise for other results
-                       return $.Deferred().resolve().promise();
-               };
-       }
-
-       if ( this.steps.length ) {
-               // Generate a chain reaction of promises
-               promise = proceed( this.steps[ 0 ] )();
-               for ( i = 1, len = this.steps.length; i < len; i++ ) {
-                       promise = promise.then( proceed( this.steps[ i ] ) );
-               }
-       } else {
-               promise = $.Deferred().resolve().promise();
-       }
-
-       return promise;
-};
-
-/**
- * Create a process step.
- *
- * @private
- * @param {number|jQuery.Promise|Function} step
- *
- * - Number of milliseconds to wait before proceeding
- * - Promise that must be resolved before proceeding
- * - Function to execute
- *   - If the function returns a boolean false the process will stop
- *   - If the function returns a promise, the process will continue to the next
- *     step when the promise is resolved or stop if the promise is rejected
- *   - If the function returns a number, the process will wait for that number of
- *     milliseconds before proceeding
- * @param {Object} [context=null] Execution context of the function. The context is
- *  ignored if the step is a number or promise.
- * @return {Object} Step object, with `callback` and `context` properties
- */
-OO.ui.Process.prototype.createStep = function ( step, context ) {
-       if ( typeof step === 'number' || $.isFunction( step.promise ) ) {
-               return {
-                       callback: function () {
-                               return step;
-                       },
-                       context: null
-               };
-       }
-       if ( $.isFunction( step ) ) {
-               return {
-                       callback: step,
-                       context: context
-               };
-       }
-       throw new Error( 'Cannot create process step: number, promise or function expected' );
-};
-
-/**
- * Add step to the beginning of the process.
- *
- * @inheritdoc #createStep
- * @return {OO.ui.Process} this
- * @chainable
- */
-OO.ui.Process.prototype.first = function ( step, context ) {
-       this.steps.unshift( this.createStep( step, context ) );
-       return this;
-};
-
-/**
- * Add step to the end of the process.
- *
- * @inheritdoc #createStep
- * @return {OO.ui.Process} this
- * @chainable
- */
-OO.ui.Process.prototype.next = function ( step, context ) {
-       this.steps.push( this.createStep( step, context ) );
-       return this;
-};
-
-/**
- * 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
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {OO.Factory} [factory] Window factory to use for automatic instantiation
- *  Note that window classes that are instantiated with a factory must have
- *  a {@link OO.ui.Dialog#static-name static name} property that specifies a symbolic name.
- * @cfg {boolean} [modal=true] Prevent interaction outside the dialog
- */
-OO.ui.WindowManager = function OoUiWindowManager( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.WindowManager.parent.call( this, config );
-
-       // Mixin constructors
-       OO.EventEmitter.call( this );
-
-       // Properties
-       this.factory = config.factory;
-       this.modal = config.modal === undefined || !!config.modal;
-       this.windows = {};
-       this.opening = null;
-       this.opened = null;
-       this.closing = null;
-       this.preparingToOpen = null;
-       this.preparingToClose = null;
-       this.currentWindow = null;
-       this.globalEvents = false;
-       this.$ariaHidden = null;
-       this.onWindowResizeTimeout = null;
-       this.onWindowResizeHandler = this.onWindowResize.bind( this );
-       this.afterWindowResizeHandler = this.afterWindowResize.bind( this );
-
-       // Initialization
-       this.$element
-               .addClass( 'oo-ui-windowManager' )
-               .toggleClass( 'oo-ui-windowManager-modal', this.modal );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.WindowManager, OO.ui.Element );
-OO.mixinClass( OO.ui.WindowManager, OO.EventEmitter );
-
-/* Events */
-
-/**
- * An 'opening' event is emitted when the window begins to be opened.
- *
- * @event opening
- * @param {OO.ui.Window} win Window that's being opened
- * @param {jQuery.Promise} opening An `opening` promise resolved with a value when the window is opened successfully.
- *  When the `opening` promise is resolved, the first argument of the value is an 'opened' promise, the second argument
- *  is the opening data. The `opening` promise emits `setup` and `ready` notifications when those processes are complete.
- * @param {Object} data Window opening data
- */
-
-/**
- * A 'closing' event is emitted when the window begins to be closed.
- *
- * @event closing
- * @param {OO.ui.Window} win Window that's being closed
- * @param {jQuery.Promise} closing A `closing` promise is resolved with a value when the window
- *  is closed successfully. The promise emits `hold` and `teardown` notifications when those
- *  processes are complete. When the `closing` promise is resolved, the first argument of its value
- *  is the closing data.
- * @param {Object} data Window closing data
- */
-
-/**
- * A 'resize' event is emitted when a window is resized.
- *
- * @event resize
- * @param {OO.ui.Window} win Window that was resized
- */
-
-/* Static Properties */
-
-/**
- * Map of the symbolic name of each window size and its CSS properties.
- *
- * @static
- * @inheritable
- * @property {Object}
- */
-OO.ui.WindowManager.static.sizes = {
-       small: {
-               width: 300
-       },
-       medium: {
-               width: 500
-       },
-       large: {
-               width: 700
-       },
-       larger: {
-               width: 900
-       },
-       full: {
-               // These can be non-numeric because they are never used in calculations
-               width: '100%',
-               height: '100%'
-       }
-};
-
-/**
- * Symbolic name of the default window size.
- *
- * The default size is used if the window's requested size is not recognized.
- *
- * @static
- * @inheritable
- * @property {string}
- */
-OO.ui.WindowManager.static.defaultSize = 'medium';
-
-/* Methods */
-
-/**
- * Handle window resize events.
- *
- * @private
- * @param {jQuery.Event} e Window resize event
- */
-OO.ui.WindowManager.prototype.onWindowResize = function () {
-       clearTimeout( this.onWindowResizeTimeout );
-       this.onWindowResizeTimeout = setTimeout( this.afterWindowResizeHandler, 200 );
-};
-
-/**
- * Handle window resize events.
- *
- * @private
- * @param {jQuery.Event} e Window resize event
- */
-OO.ui.WindowManager.prototype.afterWindowResize = function () {
-       if ( this.currentWindow ) {
-               this.updateWindowSize( this.currentWindow );
-       }
-};
-
-/**
- * Check if window is opening.
- *
- * @return {boolean} Window is opening
- */
-OO.ui.WindowManager.prototype.isOpening = function ( win ) {
-       return win === this.currentWindow && !!this.opening && this.opening.state() === 'pending';
-};
-
-/**
- * Check if window is closing.
- *
- * @return {boolean} Window is closing
- */
-OO.ui.WindowManager.prototype.isClosing = function ( win ) {
-       return win === this.currentWindow && !!this.closing && this.closing.state() === 'pending';
-};
-
-/**
- * Check if window is opened.
- *
- * @return {boolean} Window is opened
- */
-OO.ui.WindowManager.prototype.isOpened = function ( win ) {
-       return win === this.currentWindow && !!this.opened && this.opened.state() === 'pending';
-};
-
-/**
- * Check if a window is being managed.
- *
- * @param {OO.ui.Window} win Window to check
- * @return {boolean} Window is being managed
- */
-OO.ui.WindowManager.prototype.hasWindow = function ( win ) {
-       var name;
-
-       for ( name in this.windows ) {
-               if ( this.windows[ name ] === win ) {
-                       return true;
-               }
-       }
-
-       return false;
-};
-
-/**
- * Get the number of milliseconds to wait after opening begins before executing the ‘setup’ process.
- *
- * @param {OO.ui.Window} win Window being opened
- * @param {Object} [data] Window opening data
- * @return {number} Milliseconds to wait
- */
-OO.ui.WindowManager.prototype.getSetupDelay = function () {
-       return 0;
-};
-
-/**
- * Get the number of milliseconds to wait after setup has finished before executing the ‘ready’ process.
- *
- * @param {OO.ui.Window} win Window being opened
- * @param {Object} [data] Window opening data
- * @return {number} Milliseconds to wait
- */
-OO.ui.WindowManager.prototype.getReadyDelay = function () {
-       return 0;
-};
-
-/**
- * Get the number of milliseconds to wait after closing has begun before executing the 'hold' process.
- *
- * @param {OO.ui.Window} win Window being closed
- * @param {Object} [data] Window closing data
- * @return {number} Milliseconds to wait
- */
-OO.ui.WindowManager.prototype.getHoldDelay = function () {
-       return 0;
-};
-
-/**
- * Get the number of milliseconds to wait after the ‘hold’ process has finished before
- * executing the ‘teardown’ process.
- *
- * @param {OO.ui.Window} win Window being closed
- * @param {Object} [data] Window closing data
- * @return {number} Milliseconds to wait
- */
-OO.ui.WindowManager.prototype.getTeardownDelay = function () {
-       return this.modal ? 250 : 0;
-};
-
-/**
- * Get a window by its symbolic name.
- *
- * If the window is not yet instantiated and its symbolic name is recognized by a factory, it will be
- * instantiated and added to the window manager automatically. Please see the [OOjs UI documentation on MediaWiki][3]
- * for more information about using factories.
- * [3]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Window_managers
- *
- * @param {string} name Symbolic name of the window
- * @return {jQuery.Promise} Promise resolved with matching window, or rejected with an OO.ui.Error
- * @throws {Error} An error is thrown if the symbolic name is not recognized by the factory.
- * @throws {Error} An error is thrown if the named window is not recognized as a managed window.
- */
-OO.ui.WindowManager.prototype.getWindow = function ( name ) {
-       var deferred = $.Deferred(),
-               win = this.windows[ name ];
-
-       if ( !( win instanceof OO.ui.Window ) ) {
-               if ( this.factory ) {
-                       if ( !this.factory.lookup( name ) ) {
-                               deferred.reject( new OO.ui.Error(
-                                       'Cannot auto-instantiate window: symbolic name is unrecognized by the factory'
-                               ) );
-                       } else {
-                               win = this.factory.create( name );
-                               this.addWindows( [ win ] );
-                               deferred.resolve( win );
-                       }
-               } else {
-                       deferred.reject( new OO.ui.Error(
-                               'Cannot get unmanaged window: symbolic name unrecognized as a managed window'
-                       ) );
-               }
-       } else {
-               deferred.resolve( win );
-       }
-
-       return deferred.promise();
-};
-
-/**
- * Get current window.
- *
- * @return {OO.ui.Window|null} Currently opening/opened/closing window
- */
-OO.ui.WindowManager.prototype.getCurrentWindow = function () {
-       return this.currentWindow;
-};
-
-/**
- * Open a window.
- *
- * @param {OO.ui.Window|string} win Window object or symbolic name of window to open
- * @param {Object} [data] Window opening data
- * @return {jQuery.Promise} An `opening` promise resolved when the window is done opening.
- *  See {@link #event-opening 'opening' event}  for more information about `opening` promises.
- * @fires opening
- */
-OO.ui.WindowManager.prototype.openWindow = function ( win, data ) {
-       var manager = this,
-               opening = $.Deferred();
-
-       // Argument handling
-       if ( typeof win === 'string' ) {
-               return this.getWindow( win ).then( function ( win ) {
-                       return manager.openWindow( win, data );
-               } );
-       }
-
-       // Error handling
-       if ( !this.hasWindow( win ) ) {
-               opening.reject( new OO.ui.Error(
-                       'Cannot open window: window is not attached to manager'
-               ) );
-       } else if ( this.preparingToOpen || this.opening || this.opened ) {
-               opening.reject( new OO.ui.Error(
-                       'Cannot open window: another window is opening or open'
-               ) );
-       }
-
-       // Window opening
-       if ( opening.state() !== 'rejected' ) {
-               // 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 ) {
-                               manager.toggleGlobalEvents( true );
-                               manager.toggleAriaIsolation( true );
-                       }
-                       manager.currentWindow = win;
-                       manager.opening = opening;
-                       manager.preparingToOpen = null;
-                       manager.emit( 'opening', win, opening, data );
-                       setTimeout( function () {
-                               win.setup( data ).then( function () {
-                                       manager.updateWindowSize( win );
-                                       manager.opening.notify( { state: 'setup' } );
-                                       setTimeout( function () {
-                                               win.ready( data ).then( function () {
-                                                       manager.opening.notify( { state: 'ready' } );
-                                                       manager.opening = null;
-                                                       manager.opened = $.Deferred();
-                                                       opening.resolve( manager.opened.promise(), data );
-                                               }, function () {
-                                                       manager.opening = null;
-                                                       manager.opened = $.Deferred();
-                                                       opening.reject();
-                                                       manager.closeWindow( win );
-                                               } );
-                                       }, manager.getReadyDelay() );
-                               }, function () {
-                                       manager.opening = null;
-                                       manager.opened = $.Deferred();
-                                       opening.reject();
-                                       manager.closeWindow( win );
-                               } );
-                       }, manager.getSetupDelay() );
-               } );
-       }
-
-       return opening.promise();
-};
-
-/**
- * Close a window.
- *
- * @param {OO.ui.Window|string} win Window object or symbolic name of window to close
- * @param {Object} [data] Window closing data
- * @return {jQuery.Promise} A `closing` promise resolved when the window is done closing.
- *  See {@link #event-closing 'closing' event} for more information about closing promises.
- * @throws {Error} An error is thrown if the window is not managed by the window manager.
- * @fires closing
- */
-OO.ui.WindowManager.prototype.closeWindow = function ( win, data ) {
-       var manager = this,
-               closing = $.Deferred(),
-               opened;
-
-       // Argument handling
-       if ( typeof win === 'string' ) {
-               win = this.windows[ win ];
-       } else if ( !this.hasWindow( win ) ) {
-               win = null;
-       }
-
-       // Error handling
-       if ( !win ) {
-               closing.reject( new OO.ui.Error(
-                       'Cannot close window: window is not attached to manager'
-               ) );
-       } else if ( win !== this.currentWindow ) {
-               closing.reject( new OO.ui.Error(
-                       'Cannot close window: window already closed with different data'
-               ) );
-       } else if ( this.preparingToClose || this.closing ) {
-               closing.reject( new OO.ui.Error(
-                       'Cannot close window: window already closing with different data'
-               ) );
-       }
-
-       // Window closing
-       if ( closing.state() !== 'rejected' ) {
-               // 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.always( function () {
-                       manager.closing = closing;
-                       manager.preparingToClose = null;
-                       manager.emit( 'closing', win, closing, data );
-                       opened = manager.opened;
-                       manager.opened = null;
-                       opened.resolve( closing.promise(), data );
-                       setTimeout( function () {
-                               win.hold( data ).then( function () {
-                                       closing.notify( { state: 'hold' } );
-                                       setTimeout( function () {
-                                               win.teardown( data ).then( function () {
-                                                       closing.notify( { state: 'teardown' } );
-                                                       if ( manager.modal ) {
-                                                               manager.toggleGlobalEvents( false );
-                                                               manager.toggleAriaIsolation( false );
-                                                       }
-                                                       manager.closing = null;
-                                                       manager.currentWindow = null;
-                                                       closing.resolve( data );
-                                               } );
-                                       }, manager.getTeardownDelay() );
-                               } );
-                       }, manager.getHoldDelay() );
-               } );
-       }
-
-       return closing.promise();
-};
-
-/**
- * Add windows to the window manager.
- *
- * Windows can be added by reference, symbolic name, or explicitly defined symbolic names.
- * See the [OOjs ui documentation on MediaWiki] [2] for examples.
- * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Window_managers
- *
- * @param {Object.<string,OO.ui.Window>|OO.ui.Window[]} windows An array of window objects specified
- *  by reference, symbolic name, or explicitly defined symbolic names.
- * @throws {Error} An error is thrown if a window is added by symbolic name, but has neither an
- *  explicit nor a statically configured symbolic name.
- */
-OO.ui.WindowManager.prototype.addWindows = function ( windows ) {
-       var i, len, win, name, list;
-
-       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;
-                       if ( typeof name !== 'string' ) {
-                               throw new Error( 'Cannot add window' );
-                       }
-                       list[ name ] = windows[ i ];
-               }
-       } else if ( OO.isPlainObject( windows ) ) {
-               list = windows;
-       }
-
-       // Add windows
-       for ( name in list ) {
-               win = list[ name ];
-               this.windows[ name ] = win.toggle( false );
-               this.$element.append( win.$element );
-               win.setManager( this );
-       }
-};
-
-/**
- * Remove the specified windows from the windows manager.
- *
- * Windows will be closed before they are removed. If you wish to remove all windows, you may wish to use
- * the #clearWindows method instead. If you no longer need the window manager and want to ensure that it no
- * longer listens to events, use the #destroy method.
- *
- * @param {string[]} names Symbolic names of windows to remove
- * @return {jQuery.Promise} Promise resolved when window is closed and removed
- * @throws {Error} An error is thrown if the named windows are not managed by the window manager.
- */
-OO.ui.WindowManager.prototype.removeWindows = function ( names ) {
-       var i, len, win, name, cleanupWindow,
-               manager = this,
-               promises = [],
-               cleanup = function ( name, win ) {
-                       delete manager.windows[ name ];
-                       win.$element.detach();
-               };
-
-       for ( i = 0, len = names.length; i < len; i++ ) {
-               name = names[ i ];
-               win = this.windows[ name ];
-               if ( !win ) {
-                       throw new Error( 'Cannot remove window' );
-               }
-               cleanupWindow = cleanup.bind( null, name, win );
-               promises.push( this.closeWindow( name ).then( cleanupWindow, cleanupWindow ) );
-       }
-
-       return $.when.apply( $, promises );
-};
-
-/**
- * Remove all windows from the window manager.
- *
- * Windows will be closed before they are removed. Note that the window manager, though not in use, will still
- * listen to events. If the window manager will not be used again, you may wish to use the #destroy method instead.
- * To remove just a subset of windows, use the #removeWindows method.
- *
- * @return {jQuery.Promise} Promise resolved when all windows are closed and removed
- */
-OO.ui.WindowManager.prototype.clearWindows = function () {
-       return this.removeWindows( Object.keys( this.windows ) );
-};
-
-/**
- * Set dialog size. In general, this method should not be called directly.
- *
- * Fullscreen mode will be used if the dialog is too wide to fit in the screen.
- *
- * @chainable
- */
-OO.ui.WindowManager.prototype.updateWindowSize = function ( win ) {
-       var isFullscreen;
-
-       // Bypass for non-current, and thus invisible, windows
-       if ( win !== this.currentWindow ) {
-               return;
-       }
-
-       isFullscreen = win.getSize() === 'full';
-
-       this.$element.toggleClass( 'oo-ui-windowManager-fullscreen', isFullscreen );
-       this.$element.toggleClass( 'oo-ui-windowManager-floating', !isFullscreen );
-       win.setDimensions( win.getSizeProperties() );
-
-       this.emit( 'resize', win );
-
-       return this;
-};
-
-/**
- * Bind or unbind global events for scrolling.
- *
- * @private
- * @param {boolean} [on] Bind global events
- * @chainable
- */
-OO.ui.WindowManager.prototype.toggleGlobalEvents = function ( on ) {
-       var scrollWidth, bodyMargin,
-               $body = $( this.getElementDocument().body ),
-               // We could have multiple window managers open so only modify
-               // the body css at the bottom of the stack
-               stackDepth = $body.data( 'windowManagerGlobalEvents' ) || 0 ;
-
-       on = on === undefined ? !!this.globalEvents : !!on;
-
-       if ( on ) {
-               if ( !this.globalEvents ) {
-                       $( this.getElementWindow() ).on( {
-                               // Start listening for top-level window dimension changes
-                               'orientationchange resize': this.onWindowResizeHandler
-                       } );
-                       if ( stackDepth === 0 ) {
-                               scrollWidth = window.innerWidth - document.documentElement.clientWidth;
-                               bodyMargin = parseFloat( $body.css( 'margin-right' ) ) || 0;
-                               $body.css( {
-                                       overflow: 'hidden',
-                                       'margin-right': bodyMargin + scrollWidth
-                               } );
-                       }
-                       stackDepth++;
-                       this.globalEvents = true;
-               }
-       } else if ( this.globalEvents ) {
-               $( this.getElementWindow() ).off( {
-                       // Stop listening for top-level window dimension changes
-                       'orientationchange resize': this.onWindowResizeHandler
-               } );
-               stackDepth--;
-               if ( stackDepth === 0 ) {
-                       $body.css( {
-                               overflow: '',
-                               'margin-right': ''
-                       } );
-               }
-               this.globalEvents = false;
-       }
-       $body.data( 'windowManagerGlobalEvents', stackDepth );
-
-       return this;
-};
-
-/**
- * Toggle screen reader visibility of content other than the window manager.
- *
- * @private
- * @param {boolean} [isolate] Make only the window manager visible to screen readers
- * @chainable
- */
-OO.ui.WindowManager.prototype.toggleAriaIsolation = function ( isolate ) {
-       isolate = isolate === undefined ? !this.$ariaHidden : !!isolate;
-
-       if ( isolate ) {
-               if ( !this.$ariaHidden ) {
-                       // Hide everything other than the window manager from screen readers
-                       this.$ariaHidden = $( 'body' )
-                               .children()
-                               .not( this.$element.parentsUntil( 'body' ).last() )
-                               .attr( 'aria-hidden', '' );
-               }
-       } else if ( this.$ariaHidden ) {
-               // Restore screen reader visibility
-               this.$ariaHidden.removeAttr( 'aria-hidden' );
-               this.$ariaHidden = null;
-       }
-
-       return this;
-};
-
-/**
- * Destroy the window manager.
- *
- * Destroying the window manager ensures that it will no longer listen to events. If you would like to
- * continue using the window manager, but wish to remove all windows from it, use the #clearWindows method
- * instead.
- */
-OO.ui.WindowManager.prototype.destroy = function () {
-       this.toggleGlobalEvents( false );
-       this.toggleAriaIsolation( false );
-       this.clearWindows();
-       this.$element.remove();
-};
-
-/**
- * A window is a container for elements that are in a child frame. They are used with
- * a window manager (OO.ui.WindowManager), which is used to open and close the window and control
- * its presentation. The size of a window is specified using a symbolic name (e.g., ‘small’, ‘medium’,
- * ‘large’), which is interpreted by the window manager. If the requested size is not recognized,
- * the window manager will choose a sensible fallback.
- *
- * The lifecycle of a window has three primary stages (opening, opened, and closing) in which
- * different processes are executed:
- *
- * **opening**: The opening stage begins when the window manager's {@link OO.ui.WindowManager#openWindow
- * openWindow} or the window's {@link #open open} methods are used, and the window manager begins to open
- * the window.
- *
- * - {@link #getSetupProcess} method is called and its result executed
- * - {@link #getReadyProcess} method is called and its result executed
- *
- * **opened**: The window is now open
- *
- * **closing**: The closing stage begins when the window manager's
- * {@link OO.ui.WindowManager#closeWindow closeWindow}
- * or the window's {@link #close} methods are used, and the window manager begins to close the window.
- *
- * - {@link #getHoldProcess} method is called and its result executed
- * - {@link #getTeardownProcess} method is called and its result executed. The window is now closed
- *
- * Each of the window's processes (setup, ready, hold, and teardown) can be extended in subclasses
- * by overriding the window's #getSetupProcess, #getReadyProcess, #getHoldProcess and #getTeardownProcess
- * methods. Note that each {@link OO.ui.Process process} is executed in series, so asynchronous
- * processing can complete. Always assume window processes are executed asynchronously.
- *
- * For more information, please see the [OOjs UI documentation on MediaWiki] [1].
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Windows
- *
- * @abstract
- * @class
- * @extends OO.ui.Element
- * @mixins OO.EventEmitter
- *
- * @constructor
- * @param {Object} [config] Configuration options
- * @cfg {string} [size] Symbolic name of the dialog size: `small`, `medium`, `large`, `larger` or
- *  `full`.  If omitted, the value of the {@link #static-size static size} property will be used.
- */
-OO.ui.Window = function OoUiWindow( config ) {
-       // Configuration initialization
-       config = config || {};
-
-       // Parent constructor
-       OO.ui.Window.parent.call( this, config );
-
-       // Mixin constructors
-       OO.EventEmitter.call( this );
-
-       // Properties
-       this.manager = null;
-       this.size = config.size || this.constructor.static.size;
-       this.$frame = $( '<div>' );
-       this.$overlay = $( '<div>' );
-       this.$content = $( '<div>' );
-
-       this.$focusTrapBefore = $( '<div>' ).prop( 'tabIndex', 0 );
-       this.$focusTrapAfter = $( '<div>' ).prop( 'tabIndex', 0 );
-       this.$focusTraps = this.$focusTrapBefore.add( this.$focusTrapAfter );
-
-       // Initialization
-       this.$overlay.addClass( 'oo-ui-window-overlay' );
-       this.$content
-               .addClass( 'oo-ui-window-content' )
-               .attr( 'tabindex', 0 );
-       this.$frame
-               .addClass( 'oo-ui-window-frame' )
-               .append( this.$focusTrapBefore, this.$content, this.$focusTrapAfter );
-
-       this.$element
-               .addClass( 'oo-ui-window' )
-               .append( this.$frame, this.$overlay );
-
-       // Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
-       // that reference properties not initialized at that time of parent class construction
-       // TODO: Find a better way to handle post-constructor setup
-       this.visible = false;
-       this.$element.addClass( 'oo-ui-element-hidden' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.Window, OO.ui.Element );
-OO.mixinClass( OO.ui.Window, OO.EventEmitter );
-
-/* Static Properties */
-
-/**
- * Symbolic name of the window size: `small`, `medium`, `large`, `larger` or `full`.
- *
- * The static size is used if no #size is configured during construction.
- *
- * @static
- * @inheritable
- * @property {string}
- */
-OO.ui.Window.static.size = 'medium';
-
-/* Methods */
-
-/**
- * Handle mouse down events.
- *
- * @private
- * @param {jQuery.Event} e Mouse down event
- */
-OO.ui.Window.prototype.onMouseDown = function ( e ) {
-       // Prevent clicking on the click-block from stealing focus
-       if ( e.target === this.$element[ 0 ] ) {
-               return false;
-       }
-};
-
-/**
- * Check if the window has been initialized.
- *
- * Initialization occurs when a window is added to a manager.
- *
- * @return {boolean} Window has been initialized
- */
-OO.ui.Window.prototype.isInitialized = function () {
-       return !!this.manager;
-};
-
-/**
- * Check if the window is visible.
- *
- * @return {boolean} Window is visible
- */
-OO.ui.Window.prototype.isVisible = function () {
-       return this.visible;
-};
-
-/**
- * Check if the window is opening.
- *
- * This method is a wrapper around the window manager's {@link OO.ui.WindowManager#isOpening isOpening}
- * method.
- *
- * @return {boolean} Window is opening
- */
-OO.ui.Window.prototype.isOpening = function () {
-       return this.manager.isOpening( this );
-};
-
-/**
- * Check if the window is closing.
- *
- * This method is a wrapper around the window manager's {@link OO.ui.WindowManager#isClosing isClosing} method.
- *
- * @return {boolean} Window is closing
- */
-OO.ui.Window.prototype.isClosing = function () {
-       return this.manager.isClosing( this );
-};
-
-/**
- * Check if the window is opened.
- *
- * This method is a wrapper around the window manager's {@link OO.ui.WindowManager#isOpened isOpened} method.
- *
- * @return {boolean} Window is opened
- */
-OO.ui.Window.prototype.isOpened = function () {
-       return this.manager.isOpened( this );
-};
-
-/**
- * Get the window manager.
- *
- * All windows must be attached to a window manager, which is used to open
- * and close the window and control its presentation.
- *
- * @return {OO.ui.WindowManager} Manager of window
- */
-OO.ui.Window.prototype.getManager = function () {
-       return this.manager;
-};
-
-/**
- * Get the symbolic name of the window size (e.g., `small` or `medium`).
- *
- * @return {string} Symbolic name of the size: `small`, `medium`, `large`, `larger`, `full`
- */
-OO.ui.Window.prototype.getSize = function () {
-       var viewport = OO.ui.Element.static.getDimensions( this.getElementWindow() ),
-               sizes = this.manager.constructor.static.sizes,
-               size = this.size;
-
-       if ( !sizes[ size ] ) {
-               size = this.manager.constructor.static.defaultSize;
-       }
-       if ( size !== 'full' && viewport.rect.right - viewport.rect.left < sizes[ size ].width ) {
-               size = 'full';
-       }
-
-       return size;
-};
-
-/**
- * Get the size properties associated with the current window size
- *
- * @return {Object} Size properties
- */
-OO.ui.Window.prototype.getSizeProperties = function () {
-       return this.manager.constructor.static.sizes[ this.getSize() ];
-};
-
-/**
- * Disable transitions on window's frame for the duration of the callback function, then enable them
- * back.
- *
- * @private
- * @param {Function} callback Function to call while transitions are disabled
- */
-OO.ui.Window.prototype.withoutSizeTransitions = function ( callback ) {
-       // Temporarily resize the frame so getBodyHeight() can use scrollHeight measurements.
-       // Disable transitions first, otherwise we'll get values from when the window was animating.
-       var oldTransition,
-               styleObj = this.$frame[ 0 ].style;
-       oldTransition = styleObj.transition || styleObj.OTransition || styleObj.MsTransition ||
-               styleObj.MozTransition || styleObj.WebkitTransition;
-       styleObj.transition = styleObj.OTransition = styleObj.MsTransition =
-               styleObj.MozTransition = styleObj.WebkitTransition = 'none';
-       callback();
-       // Force reflow to make sure the style changes done inside callback really are not transitioned
-       this.$frame.height();
-       styleObj.transition = styleObj.OTransition = styleObj.MsTransition =
-               styleObj.MozTransition = styleObj.WebkitTransition = oldTransition;
-};
-
-/**
- * Get the height of the full window contents (i.e., the window head, body and foot together).
- *
- * What consistitutes the head, body, and foot varies depending on the window type.
- * A {@link OO.ui.MessageDialog message dialog} displays a title and message in its body,
- * and any actions in the foot. A {@link OO.ui.ProcessDialog process dialog} displays a title
- * and special actions in the head, and dialog content in the body.
- *
- * To get just the height of the dialog body, use the #getBodyHeight method.
- *
- * @return {number} The height of the window contents (the dialog head, body and foot) in pixels
- */
-OO.ui.Window.prototype.getContentHeight = function () {
-       var bodyHeight,
-               win = this,
-               bodyStyleObj = this.$body[ 0 ].style,
-               frameStyleObj = this.$frame[ 0 ].style;
-
-       // Temporarily resize the frame so getBodyHeight() can use scrollHeight measurements.
-       // Disable transitions first, otherwise we'll get values from when the window was animating.
-       this.withoutSizeTransitions( function () {
-               var oldHeight = frameStyleObj.height,
-                       oldPosition = bodyStyleObj.position;
-               frameStyleObj.height = '1px';
-               // Force body to resize to new width
-               bodyStyleObj.position = 'relative';
-               bodyHeight = win.getBodyHeight();
-               frameStyleObj.height = oldHeight;
-               bodyStyleObj.position = oldPosition;
-       } );
-
-       return (
-               // Add buffer for border
-               ( this.$frame.outerHeight() - this.$frame.innerHeight() ) +
-               // Use combined heights of children
-               ( this.$head.outerHeight( true ) + bodyHeight + this.$foot.outerHeight( true ) )
-       );
-};
-
-/**
- * Get the height of the window body.
- *
- * To get the height of the full window contents (the window body, head, and foot together),
- * use #getContentHeight.
- *
- * When this function is called, the window will temporarily have been resized
- * to height=1px, so .scrollHeight measurements can be taken accurately.
- *
- * @return {number} Height of the window body in pixels
- */
-OO.ui.Window.prototype.getBodyHeight = function () {
-       return this.$body[ 0 ].scrollHeight;
-};
-
-/**
- * Get the directionality of the frame (right-to-left or left-to-right).
- *
- * @return {string} Directionality: `'ltr'` or `'rtl'`
- */
-OO.ui.Window.prototype.getDir = function () {
-       return OO.ui.Element.static.getDir( this.$content ) || 'ltr';
-};
-
-/**
- * Get the 'setup' process.
- *
- * The setup process is used to set up a window for use in a particular context,
- * based on the `data` argument. This method is called during the opening phase of the window’s
- * lifecycle.
- *
- * Override this method to add additional steps to the ‘setup’ process the parent method provides
- * using the {@link OO.ui.Process#first first} and {@link OO.ui.Process#next next} methods
- * of OO.ui.Process.
- *
- * To add window content that persists between openings, you may wish to use the #initialize method
- * instead.
- *
- * @param {Object} [data] Window opening data
- * @return {OO.ui.Process} Setup process
- */
-OO.ui.Window.prototype.getSetupProcess = function () {
-       return new OO.ui.Process();
-};
-
-/**
- * Get the ‘ready’ process.
- *
- * The ready process is used to ready a window for use in a particular
- * context, based on the `data` argument. This method is called during the opening phase of
- * the window’s lifecycle, after the window has been {@link #getSetupProcess setup}.
- *
- * Override this method to add additional steps to the ‘ready’ process the parent method
- * provides using the {@link OO.ui.Process#first first} and {@link OO.ui.Process#next next}
- * methods of OO.ui.Process.
- *
- * @param {Object} [data] Window opening data
- * @return {OO.ui.Process} Ready process
- */
-OO.ui.Window.prototype.getReadyProcess = function () {
-       return new OO.ui.Process();
-};
-
-/**
- * Get the 'hold' process.
- *
- * The hold proccess is used to keep a window from being used in a particular context,
- * based on the `data` argument. This method is called during the closing phase of the window’s
- * lifecycle.
- *
- * Override this method to add additional steps to the 'hold' process the parent method provides
- * using the {@link OO.ui.Process#first first} and {@link OO.ui.Process#next next} methods
- * of OO.ui.Process.
- *
- * @param {Object} [data] Window closing data
- * @return {OO.ui.Process} Hold process
- */
-OO.ui.Window.prototype.getHoldProcess = function () {
-       return new OO.ui.Process();
-};
-
-/**
- * Get the ‘teardown’ process.
- *
- * The teardown process is used to teardown a window after use. During teardown,
- * user interactions within the window are conveyed and the window is closed, based on the `data`
- * argument. This method is called during the closing phase of the window’s lifecycle.
- *
- * Override this method to add additional steps to the ‘teardown’ process the parent method provides
- * using the {@link OO.ui.Process#first first} and {@link OO.ui.Process#next next} methods
- * of OO.ui.Process.
- *
- * @param {Object} [data] Window closing data
- * @return {OO.ui.Process} Teardown process
- */
-OO.ui.Window.prototype.getTeardownProcess = function () {
-       return new OO.ui.Process();
-};
-
-/**
- * Set the window manager.
- *
- * This will cause the window to initialize. Calling it more than once will cause an error.
- *
- * @param {OO.ui.WindowManager} manager Manager for this window
- * @throws {Error} An error is thrown if the method is called more than once
- * @chainable
- */
-OO.ui.Window.prototype.setManager = function ( manager ) {
-       if ( this.manager ) {
-               throw new Error( 'Cannot set window manager, window already has a manager' );
-       }
-
-       this.manager = manager;
-       this.initialize();
-
-       return this;
-};
-
-/**
- * Set the window size by symbolic name (e.g., 'small' or 'medium')
- *
- * @param {string} size Symbolic name of size: `small`, `medium`, `large`, `larger` or
- *  `full`
- * @chainable
- */
-OO.ui.Window.prototype.setSize = function ( size ) {
-       this.size = size;
-       this.updateSize();
-       return this;
-};
-
-/**
- * Update the window size.
- *
- * @throws {Error} An error is thrown if the window is not attached to a window manager
- * @chainable
- */
-OO.ui.Window.prototype.updateSize = function () {
-       if ( !this.manager ) {
-               throw new Error( 'Cannot update window size, must be attached to a manager' );
-       }
-
-       this.manager.updateWindowSize( this );
-
-       return this;
-};
-
-/**
- * Set window dimensions. This method is called by the {@link OO.ui.WindowManager window manager}
- * when the window is opening. In general, setDimensions should not be called directly.
- *
- * To set the size of the window, use the #setSize method.
- *
- * @param {Object} dim CSS dimension properties
- * @param {string|number} [dim.width] Width
- * @param {string|number} [dim.minWidth] Minimum width
- * @param {string|number} [dim.maxWidth] Maximum width
- * @param {string|number} [dim.width] Height, omit to set based on height of contents
- * @param {string|number} [dim.minWidth] Minimum height
- * @param {string|number} [dim.maxWidth] Maximum height
- * @chainable
- */
-OO.ui.Window.prototype.setDimensions = function ( dim ) {
-       var height,
-               win = this,
-               styleObj = this.$frame[ 0 ].style;
-
-       // Calculate the height we need to set using the correct width
-       if ( dim.height === undefined ) {
-               this.withoutSizeTransitions( function () {
-                       var oldWidth = styleObj.width;
-                       win.$frame.css( 'width', dim.width || '' );
-                       height = win.getContentHeight();
-                       styleObj.width = oldWidth;
-               } );
-       } else {
-               height = dim.height;
-       }
-
-       this.$frame.css( {
-               width: dim.width || '',
-               minWidth: dim.minWidth || '',
-               maxWidth: dim.maxWidth || '',
-               height: height || '',
-               minHeight: dim.minHeight || '',
-               maxHeight: dim.maxHeight || ''
-       } );
-
-       return this;
-};
-
-/**
- * Initialize window contents.
- *
- * Before the window is opened for the first time, #initialize is called so that content that
- * persists between openings can be added to the window.
- *
- * To set up a window with new content each time the window opens, use #getSetupProcess.
- *
- * @throws {Error} An error is thrown if the window is not attached to a window manager
- * @chainable
- */
-OO.ui.Window.prototype.initialize = function () {
-       if ( !this.manager ) {
-               throw new Error( 'Cannot initialize window, must be attached to a manager' );
-       }
-
-       // Properties
-       this.$head = $( '<div>' );
-       this.$body = $( '<div>' );
-       this.$foot = $( '<div>' );
-       this.$document = $( this.getElementDocument() );
-
-       // Events
-       this.$element.on( 'mousedown', this.onMouseDown.bind( this ) );
-
-       // Initialization
-       this.$head.addClass( 'oo-ui-window-head' );
-       this.$body.addClass( 'oo-ui-window-body' );
-       this.$foot.addClass( 'oo-ui-window-foot' );
-       this.$content.append( this.$head, this.$body, this.$foot );
-
-       return this;
-};
-
-/**
- * Called when someone tries to focus the hidden element at the end of the dialog.
- * Sends focus back to the start of the dialog.
- *
- * @param {jQuery.Event} event Focus event
- */
-OO.ui.Window.prototype.onFocusTrapFocused = function ( event ) {
-       if ( this.$focusTrapBefore.is( event.target ) ) {
-               OO.ui.findFocusable( this.$content, true ).focus();
-       } else {
-               // this.$content is the part of the focus cycle, and is the first focusable element
-               this.$content.focus();
-       }
-};
-
-/**
- * Open the window.
- *
- * This method is a wrapper around a call to the window manager’s {@link OO.ui.WindowManager#openWindow openWindow}
- * method, which returns a promise resolved when the window is done opening.
- *
- * To customize the window each time it opens, use #getSetupProcess or #getReadyProcess.
- *
- * @param {Object} [data] Window opening data
- * @return {jQuery.Promise} Promise resolved with a value when the window is opened, or rejected
- *  if the window fails to open. When the promise is resolved successfully, the first argument of the
- *  value is a new promise, which is resolved when the window begins closing.
- * @throws {Error} An error is thrown if the window is not attached to a window manager
- */
-OO.ui.Window.prototype.open = function ( data ) {
-       if ( !this.manager ) {
-               throw new Error( 'Cannot open window, must be attached to a manager' );
-       }
-
-       return this.manager.openWindow( this, data );
-};
-
-/**
- * Close the window.
- *
- * This method is a wrapper around a call to the window
- * manager’s {@link OO.ui.WindowManager#closeWindow closeWindow} method,
- * which returns a closing promise resolved when the window is done closing.
- *
- * The window's #getHoldProcess and #getTeardownProcess methods are called during the closing
- * phase of the window’s lifecycle and can be used to specify closing behavior each time
- * the window closes.
- *
- * @param {Object} [data] Window closing data
- * @return {jQuery.Promise} Promise resolved when window is closed
- * @throws {Error} An error is thrown if the window is not attached to a window manager
- */
-OO.ui.Window.prototype.close = function ( data ) {
-       if ( !this.manager ) {
-               throw new Error( 'Cannot close window, must be attached to a manager' );
-       }
-
-       return this.manager.closeWindow( this, data );
-};
-
-/**
- * Setup window.
- *
- * This is called by OO.ui.WindowManager during window opening, and should not be called directly
- * by other systems.
- *
- * @param {Object} [data] Window opening data
- * @return {jQuery.Promise} Promise resolved when window is setup
- */
-OO.ui.Window.prototype.setup = function ( data ) {
-       var win = this;
-
-       this.toggle( true );
-
-       this.focusTrapHandler = OO.ui.bind( this.onFocusTrapFocused, this );
-       this.$focusTraps.on( 'focus', this.focusTrapHandler );
-
-       return this.getSetupProcess( data ).execute().then( function () {
-               // Force redraw by asking the browser to measure the elements' widths
-               win.$element.addClass( 'oo-ui-window-active oo-ui-window-setup' ).width();
-               win.$content.addClass( 'oo-ui-window-content-setup' ).width();
-       } );
-};
-
-/**
- * Ready window.
- *
- * This is called by OO.ui.WindowManager during window opening, and should not be called directly
- * by other systems.
- *
- * @param {Object} [data] Window opening data
- * @return {jQuery.Promise} Promise resolved when window is ready
- */
-OO.ui.Window.prototype.ready = function ( data ) {
-       var win = this;
-
-       this.$content.focus();
-       return this.getReadyProcess( data ).execute().then( function () {
-               // Force redraw by asking the browser to measure the elements' widths
-               win.$element.addClass( 'oo-ui-window-ready' ).width();
-               win.$content.addClass( 'oo-ui-window-content-ready' ).width();
-       } );
-};
-
-/**
- * Hold window.
- *
- * This is called by OO.ui.WindowManager during window closing, and should not be called directly
- * by other systems.
- *
- * @param {Object} [data] Window closing data
- * @return {jQuery.Promise} Promise resolved when window is held
- */
-OO.ui.Window.prototype.hold = function ( data ) {
-       var win = this;
-
-       return this.getHoldProcess( data ).execute().then( function () {
-               // Get the focused element within the window's content
-               var $focus = win.$content.find( OO.ui.Element.static.getDocument( win.$content ).activeElement );
-
-               // Blur the focused element
-               if ( $focus.length ) {
-                       $focus[ 0 ].blur();
-               }
-
-               // Force redraw by asking the browser to measure the elements' widths
-               win.$element.removeClass( 'oo-ui-window-ready' ).width();
-               win.$content.removeClass( 'oo-ui-window-content-ready' ).width();
-       } );
-};
-
-/**
- * Teardown window.
- *
- * This is called by OO.ui.WindowManager during window closing, and should not be called directly
- * by other systems.
- *
- * @param {Object} [data] Window closing data
- * @return {jQuery.Promise} Promise resolved when window is torn down
- */
-OO.ui.Window.prototype.teardown = function ( data ) {
-       var win = this;
-
-       return this.getTeardownProcess( data ).execute().then( 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.$focusTraps.off( 'focus', win.focusTrapHandler );
-               win.toggle( false );
-       } );
-};
-
-/**
- * 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.parent.call( this, config );
- *     }
- *     OO.inheritClass( MyDialog, OO.ui.Dialog );
- *     MyDialog.prototype.initialize = function () {
- *         MyDialog.parent.prototype.initialize.call( this );
- *         this.content = new OO.ui.PanelLayout( { padded: true, expanded: false } );
- *         this.content.$element.append( '<p>A simple dialog window. Press \'Esc\' to close.</p>' );
- *         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
- * @extends OO.ui.Window
- * @mixins OO.ui.mixin.PendingElement
- *
- * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.Dialog = function OoUiDialog( config ) {
-       // Parent constructor
-       OO.ui.Dialog.parent.call( this, config );
-
-       // Mixin constructors
-       OO.ui.mixin.PendingElement.call( this );
-
-       // Properties
-       this.actions = new OO.ui.ActionSet();
-       this.attachedActions = [];
-       this.currentAction = null;
-       this.onDialogKeyDownHandler = this.onDialogKeyDown.bind( this );
-
-       // Events
-       this.actions.connect( this, {
-               click: 'onActionClick',
-               resize: 'onActionResize',
-               change: 'onActionsChange'
-       } );
-
-       // Initialization
-       this.$element
-               .addClass( 'oo-ui-dialog' )
-               .attr( 'role', 'dialog' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.Dialog, OO.ui.Window );
-OO.mixinClass( OO.ui.Dialog, OO.ui.mixin.PendingElement );
-
-/* Static Properties */
-
-/**
- * Symbolic name of dialog.
- *
- * The dialog class must have a symbolic name in order to be registered with OO.Factory.
- * Please see the [OOjs UI documentation on MediaWiki] [3] for more information.
- *
- * [3]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Window_managers
- *
- * @abstract
- * @static
- * @inheritable
- * @property {string}
- */
-OO.ui.Dialog.static.name = '';
-
-/**
- * The dialog title.
- *
- * The title can be specified as a plaintext string, a {@link OO.ui.mixin.LabelElement Label} node, or a function
- * that will produce a Label node or string. The title can also be specified with data passed to the
- * constructor (see #getSetupProcess). In this case, the static value will be overridden.
- *
- * @abstract
- * @static
- * @inheritable
- * @property {jQuery|string|Function}
- */
-OO.ui.Dialog.static.title = '';
-
-/**
- * An array of configured {@link OO.ui.ActionWidget action widgets}.
- *
- * Actions can also be specified with data passed to the constructor (see #getSetupProcess). In this case, the static
- * value will be overridden.
- *
- * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Process_Dialogs#Action_sets
- *
- * @static
- * @inheritable
- * @property {Object[]}
- */
-OO.ui.Dialog.static.actions = [];
-
-/**
- * Close the dialog when the 'Esc' key is pressed.
- *
- * @static
- * @abstract
- * @inheritable
- * @property {boolean}
- */
-OO.ui.Dialog.static.escapable = true;
-
-/* Methods */
-
-/**
- * Handle frame document key down events.
- *
- * @private
- * @param {jQuery.Event} e Key down event
- */
-OO.ui.Dialog.prototype.onDialogKeyDown = function ( e ) {
-       if ( e.which === OO.ui.Keys.ESCAPE ) {
-               this.executeAction( '' );
-               e.preventDefault();
-               e.stopPropagation();
-       }
-};
-
-/**
- * Handle action resized events.
- *
- * @private
- * @param {OO.ui.ActionWidget} action Action that was resized
- */
-OO.ui.Dialog.prototype.onActionResize = function () {
-       // Override in subclass
-};
-
-/**
- * Handle action click events.
- *
- * @private
- * @param {OO.ui.ActionWidget} action Action that was clicked
- */
-OO.ui.Dialog.prototype.onActionClick = function ( action ) {
-       if ( !this.isPending() ) {
-               this.executeAction( action.getAction() );
-       }
-};
-
-/**
- * Handle actions change event.
- *
- * @private
- */
-OO.ui.Dialog.prototype.onActionsChange = function () {
-       this.detachActions();
-       if ( !this.isClosing() ) {
-               this.attachActions();
-       }
-};
-
-/**
- * Get the set of actions used by the dialog.
- *
- * @return {OO.ui.ActionSet}
- */
-OO.ui.Dialog.prototype.getActions = function () {
-       return this.actions;
-};
-
-/**
- * Get a process for taking action.
- *
- * When you override this method, you can create a new OO.ui.Process and return it, or add additional
- * accept steps to the process the parent method provides using the {@link OO.ui.Process#first 'first'}
- * and {@link OO.ui.Process#next 'next'} methods of OO.ui.Process.
- *
- * @param {string} [action] Symbolic name of action
- * @return {OO.ui.Process} Action process
- */
-OO.ui.Dialog.prototype.getActionProcess = function ( action ) {
-       return new OO.ui.Process()
-               .next( function () {
-                       if ( !action ) {
-                               // An empty action always closes the dialog without data, which should always be
-                               // safe and make no changes
-                               this.close();
-                       }
-               }, this );
-};
-
-/**
- * @inheritdoc
- *
- * @param {Object} [data] Dialog opening data
- * @param {jQuery|string|Function|null} [data.title] Dialog title, omit to use
- *  the {@link #static-title static title}
- * @param {Object[]} [data.actions] List of configuration options for each
- *   {@link OO.ui.ActionWidget action widget}, omit to use {@link #static-actions static actions}.
- */
-OO.ui.Dialog.prototype.getSetupProcess = function ( data ) {
-       data = data || {};
-
-       // Parent method
-       return OO.ui.Dialog.parent.prototype.getSetupProcess.call( this, data )
-               .next( function () {
-                       var config = this.constructor.static,
-                               actions = data.actions !== undefined ? data.actions : config.actions;
-
-                       this.title.setLabel(
-                               data.title !== undefined ? data.title : this.constructor.static.title
-                       );
-                       this.actions.add( this.getActionWidgets( actions ) );
-
-                       if ( this.constructor.static.escapable ) {
-                               this.$element.on( 'keydown', this.onDialogKeyDownHandler );
-                       }
-               }, this );
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.Dialog.prototype.getTeardownProcess = function ( data ) {
-       // Parent method
-       return OO.ui.Dialog.parent.prototype.getTeardownProcess.call( this, data )
-               .first( function () {
-                       if ( this.constructor.static.escapable ) {
-                               this.$element.off( 'keydown', this.onDialogKeyDownHandler );
-                       }
-
-                       this.actions.clear();
-                       this.currentAction = null;
-               }, this );
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.Dialog.prototype.initialize = function () {
-       var titleId;
-
-       // Parent method
-       OO.ui.Dialog.parent.prototype.initialize.call( this );
-
-       titleId = OO.ui.generateElementId();
-
-       // Properties
-       this.title = new OO.ui.LabelWidget( {
-               id: titleId
-       } );
-
-       // Initialization
-       this.$content.addClass( 'oo-ui-dialog-content' );
-       this.$element.attr( 'aria-labelledby', titleId );
-       this.setPendingElement( this.$head );
-};
-
-/**
- * Get action widgets from a list of configs
- *
- * @param {Object[]} actions Action widget configs
- * @return {OO.ui.ActionWidget[]} Action widgets
- */
-OO.ui.Dialog.prototype.getActionWidgets = function ( actions ) {
-       var i, len, widgets = [];
-       for ( i = 0, len = actions.length; i < len; i++ ) {
-               widgets.push(
-                       new OO.ui.ActionWidget( actions[ i ] )
-               );
-       }
-       return widgets;
-};
-
-/**
- * Attach action actions.
- *
- * @protected
- */
-OO.ui.Dialog.prototype.attachActions = function () {
-       // Remember the list of potentially attached actions
-       this.attachedActions = this.actions.get();
-};
-
-/**
- * Detach action actions.
- *
- * @protected
- * @chainable
- */
-OO.ui.Dialog.prototype.detachActions = function () {
-       var i, len;
-
-       // 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 = [];
-};
-
-/**
- * Execute an action.
- *
- * @param {string} action Symbolic name of action to execute
- * @return {jQuery.Promise} Promise resolved when action completes, rejected if it fails
- */
-OO.ui.Dialog.prototype.executeAction = function ( action ) {
-       this.pushPending();
-       this.currentAction = action;
-       return this.getActionProcess( action ).execute()
-               .always( this.popPending.bind( this ) );
-};
-
-/**
- * MessageDialogs display a confirmation or alert message. By default, the rendered dialog box
- * consists of a header that contains the dialog title, a body with the message, and a footer that
- * contains any {@link OO.ui.ActionWidget action widgets}. The MessageDialog class is the only type
- * of {@link OO.ui.Dialog dialog} that is usually instantiated directly.
- *
- * There are two basic types of message dialogs, confirmation and alert:
- *
- * - **confirmation**: the dialog title describes what a progressive action will do and the message provides
- *  more details about the consequences.
- * - **alert**: the dialog title describes which event occurred and the message provides more information
- *  about why the event occurred.
- *
- * The MessageDialog class specifies two actions: ‘accept’, the primary
- * action (e.g., ‘ok’) and ‘reject,’ the safe action (e.g., ‘cancel’). Both will close the window,
- * passing along the selected action.
- *
- * For more information and examples, please see the [OOjs UI documentation on MediaWiki][1].
- *
- *     @example
- *     // Example: Creating and opening a message dialog window.
- *     var messageDialog = new OO.ui.MessageDialog();
- *
- *     // Create and append a window manager.
- *     var windowManager = new OO.ui.WindowManager();
- *     $( 'body' ).append( windowManager.$element );
- *     windowManager.addWindows( [ messageDialog ] );
- *     // Open the window.
- *     windowManager.openWindow( messageDialog, {
- *         title: 'Basic message dialog',
- *         message: 'This is the message'
- *     } );
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Message_Dialogs
- *
- * @class
- * @extends OO.ui.Dialog
- *
- * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.MessageDialog = function OoUiMessageDialog( config ) {
-       // Parent constructor
-       OO.ui.MessageDialog.parent.call( this, config );
-
-       // Properties
-       this.verticalActionLayout = null;
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-messageDialog' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.MessageDialog, OO.ui.Dialog );
-
-/* Static Properties */
-
-OO.ui.MessageDialog.static.name = 'message';
-
-OO.ui.MessageDialog.static.size = 'small';
-
-OO.ui.MessageDialog.static.verbose = false;
-
-/**
- * Dialog title.
- *
- * The title of a confirmation dialog describes what a progressive action will do. The
- * title of an alert dialog describes which event occurred.
- *
- * @static
- * @inheritable
- * @property {jQuery|string|Function|null}
- */
-OO.ui.MessageDialog.static.title = null;
-
-/**
- * The message displayed in the dialog body.
- *
- * A confirmation message describes the consequences of a progressive action. An alert
- * message describes why an event occurred.
- *
- * @static
- * @inheritable
- * @property {jQuery|string|Function|null}
- */
-OO.ui.MessageDialog.static.message = null;
-
-// Note that OO.ui.alert() and OO.ui.confirm() rely on these.
-OO.ui.MessageDialog.static.actions = [
-       { action: 'accept', label: OO.ui.deferMsg( 'ooui-dialog-message-accept' ), flags: 'primary' },
-       { action: 'reject', label: OO.ui.deferMsg( 'ooui-dialog-message-reject' ), flags: 'safe' }
-];
-
-/* Methods */
-
-/**
- * @inheritdoc
- */
-OO.ui.MessageDialog.prototype.setManager = function ( manager ) {
-       OO.ui.MessageDialog.parent.prototype.setManager.call( this, manager );
-
-       // Events
-       this.manager.connect( this, {
-               resize: 'onResize'
-       } );
-
-       return this;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.MessageDialog.prototype.onActionResize = function ( action ) {
-       this.fitActions();
-       return OO.ui.MessageDialog.parent.prototype.onActionResize.call( this, action );
-};
-
-/**
- * Handle window resized events.
- *
- * @private
- */
-OO.ui.MessageDialog.prototype.onResize = function () {
-       var dialog = this;
-       dialog.fitActions();
-       // Wait for CSS transition to finish and do it again :(
-       setTimeout( function () {
-               dialog.fitActions();
-       }, 300 );
-};
-
-/**
- * Toggle action layout between vertical and horizontal.
- *
- * @private
- * @param {boolean} [value] Layout actions vertically, omit to toggle
- * @chainable
- */
-OO.ui.MessageDialog.prototype.toggleVerticalActionLayout = function ( value ) {
-       value = value === undefined ? !this.verticalActionLayout : !!value;
-
-       if ( value !== this.verticalActionLayout ) {
-               this.verticalActionLayout = value;
-               this.$actions
-                       .toggleClass( 'oo-ui-messageDialog-actions-vertical', value )
-                       .toggleClass( 'oo-ui-messageDialog-actions-horizontal', !value );
-       }
-
-       return this;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.MessageDialog.prototype.getActionProcess = function ( action ) {
-       if ( action ) {
-               return new OO.ui.Process( function () {
-                       this.close( { action: action } );
-               }, this );
-       }
-       return OO.ui.MessageDialog.parent.prototype.getActionProcess.call( this, action );
-};
-
-/**
- * @inheritdoc
- *
- * @param {Object} [data] Dialog opening data
- * @param {jQuery|string|Function|null} [data.title] Description of the action being confirmed
- * @param {jQuery|string|Function|null} [data.message] Description of the action's consequence
- * @param {boolean} [data.verbose] Message is verbose and should be styled as a long message
- * @param {Object[]} [data.actions] List of OO.ui.ActionOptionWidget configuration options for each
- *   action item
- */
-OO.ui.MessageDialog.prototype.getSetupProcess = function ( data ) {
-       data = data || {};
-
-       // Parent method
-       return OO.ui.MessageDialog.parent.prototype.getSetupProcess.call( this, data )
-               .next( function () {
-                       this.title.setLabel(
-                               data.title !== undefined ? data.title : this.constructor.static.title
-                       );
-                       this.message.setLabel(
-                               data.message !== undefined ? data.message : this.constructor.static.message
-                       );
-                       this.message.$element.toggleClass(
-                               'oo-ui-messageDialog-message-verbose',
-                               data.verbose !== undefined ? data.verbose : this.constructor.static.verbose
-                       );
-               }, this );
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.MessageDialog.prototype.getReadyProcess = function ( data ) {
-       data = data || {};
-
-       // Parent method
-       return OO.ui.MessageDialog.parent.prototype.getReadyProcess.call( this, data )
-               .next( function () {
-                       // Focus the primary action button
-                       var actions = this.actions.get();
-                       actions = actions.filter( function ( action ) {
-                               return action.getFlags().indexOf( 'primary' ) > -1;
-                       } );
-                       if ( actions.length > 0 ) {
-                               actions[ 0 ].$button.focus();
-                       }
-               }, this );
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.MessageDialog.prototype.getBodyHeight = function () {
-       var bodyHeight, oldOverflow,
-               $scrollable = this.container.$element;
-
-       oldOverflow = $scrollable[ 0 ].style.overflow;
-       $scrollable[ 0 ].style.overflow = 'hidden';
-
-       OO.ui.Element.static.reconsiderScrollbars( $scrollable[ 0 ] );
-
-       bodyHeight = this.text.$element.outerHeight( true );
-       $scrollable[ 0 ].style.overflow = oldOverflow;
-
-       return bodyHeight;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.MessageDialog.prototype.setDimensions = function ( dim ) {
-       var $scrollable = this.container.$element;
-       OO.ui.MessageDialog.parent.prototype.setDimensions.call( this, 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';
-
-               OO.ui.Element.static.reconsiderScrollbars( $scrollable[ 0 ] );
-
-               $scrollable[ 0 ].style.overflow = oldOverflow;
-       }, 300 );
-
-       return this;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.MessageDialog.prototype.initialize = function () {
-       // Parent method
-       OO.ui.MessageDialog.parent.prototype.initialize.call( this );
-
-       // Properties
-       this.$actions = $( '<div>' );
-       this.container = new OO.ui.PanelLayout( {
-               scrollable: true, classes: [ 'oo-ui-messageDialog-container' ]
-       } );
-       this.text = new OO.ui.PanelLayout( {
-               padded: true, expanded: false, classes: [ 'oo-ui-messageDialog-text' ]
-       } );
-       this.message = new OO.ui.LabelWidget( {
-               classes: [ 'oo-ui-messageDialog-message' ]
-       } );
-
-       // Initialization
-       this.title.$element.addClass( 'oo-ui-messageDialog-title' );
-       this.$content.addClass( 'oo-ui-messageDialog-content' );
-       this.container.$element.append( this.text.$element );
-       this.text.$element.append( this.title.$element, this.message.$element );
-       this.$body.append( this.container.$element );
-       this.$actions.addClass( 'oo-ui-messageDialog-actions' );
-       this.$foot.append( this.$actions );
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.MessageDialog.prototype.attachActions = function () {
-       var i, len, other, special, others;
-
-       // Parent method
-       OO.ui.MessageDialog.parent.prototype.attachActions.call( this );
-
-       special = this.actions.getSpecial();
-       others = this.actions.getOthers();
-
-       if ( special.safe ) {
-               this.$actions.append( special.safe.$element );
-               special.safe.toggleFramed( false );
-       }
-       if ( others.length ) {
-               for ( i = 0, len = others.length; i < len; i++ ) {
-                       other = others[ i ];
-                       this.$actions.append( other.$element );
-                       other.toggleFramed( false );
-               }
-       }
-       if ( special.primary ) {
-               this.$actions.append( special.primary.$element );
-               special.primary.toggleFramed( false );
-       }
-
-       if ( !this.isOpening() ) {
-               // If the dialog is currently opening, this will be called automatically soon.
-               // This also calls #fitActions.
-               this.updateSize();
-       }
-};
-
-/**
- * Fit action actions into columns or rows.
- *
- * Columns will be used if all labels can fit without overflow, otherwise rows will be used.
- *
- * @private
- */
-OO.ui.MessageDialog.prototype.fitActions = function () {
-       var i, len, action,
-               previous = this.verticalActionLayout,
-               actions = this.actions.get();
-
-       // Detect clipping
-       this.toggleVerticalActionLayout( false );
-       for ( i = 0, len = actions.length; i < len; 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 ) {
-               // We changed the layout, window height might need to be updated.
-               this.updateSize();
-       }
-};
-
-/**
- * ProcessDialog windows encapsulate a {@link OO.ui.Process process} and all of the code necessary
- * to complete it. If the process terminates with an error, a customizable {@link OO.ui.Error error
- * interface} alerts users to the trouble, permitting the user to dismiss the error and try again when
- * relevant. The ProcessDialog class is always extended and customized with the actions and content
- * required for each process.
- *
- * The process dialog box consists of a header that visually represents the ‘working’ state of long
- * processes with an animation. The header contains the dialog title as well as
- * two {@link OO.ui.ActionWidget action widgets}:  a ‘safe’ action on the left (e.g., ‘Cancel’) and
- * a ‘primary’ action on the right (e.g., ‘Done’).
- *
- * Like other windows, the process dialog is managed by a {@link OO.ui.WindowManager window manager}.
- * Please see the [OOjs UI documentation on MediaWiki][1] for more information and examples.
- *
- *     @example
- *     // Example: Creating and opening a process dialog window.
- *     function MyProcessDialog( config ) {
- *         MyProcessDialog.parent.call( this, config );
- *     }
- *     OO.inheritClass( MyProcessDialog, OO.ui.ProcessDialog );
- *
- *     MyProcessDialog.static.title = 'Process dialog';
- *     MyProcessDialog.static.actions = [
- *         { action: 'save', label: 'Done', flags: 'primary' },
- *         { label: 'Cancel', flags: 'safe' }
- *     ];
- *
- *     MyProcessDialog.prototype.initialize = function () {
- *         MyProcessDialog.parent.prototype.initialize.apply( this, arguments );
- *         this.content = new OO.ui.PanelLayout( { padded: true, expanded: false } );
- *         this.content.$element.append( '<p>This is a process dialog window. The header contains the title and two buttons: \'Cancel\' (a safe action) on the left and \'Done\' (a primary action)  on the right.</p>' );
- *         this.$body.append( this.content.$element );
- *     };
- *     MyProcessDialog.prototype.getActionProcess = function ( action ) {
- *         var dialog = this;
- *         if ( action ) {
- *             return new OO.ui.Process( function () {
- *                 dialog.close( { action: action } );
- *             } );
- *         }
- *         return MyProcessDialog.parent.prototype.getActionProcess.call( this, action );
- *     };
- *
- *     var windowManager = new OO.ui.WindowManager();
- *     $( 'body' ).append( windowManager.$element );
- *
- *     var dialog = new MyProcessDialog();
- *     windowManager.addWindows( [ dialog ] );
- *     windowManager.openWindow( dialog );
- *
- * [1]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Process_Dialogs
- *
- * @abstract
- * @class
- * @extends OO.ui.Dialog
- *
- * @constructor
- * @param {Object} [config] Configuration options
- */
-OO.ui.ProcessDialog = function OoUiProcessDialog( config ) {
-       // Parent constructor
-       OO.ui.ProcessDialog.parent.call( this, config );
-
-       // Properties
-       this.fitOnOpen = false;
-
-       // Initialization
-       this.$element.addClass( 'oo-ui-processDialog' );
-};
-
-/* Setup */
-
-OO.inheritClass( OO.ui.ProcessDialog, OO.ui.Dialog );
-
-/* Methods */
-
-/**
- * Handle dismiss button click events.
- *
- * Hides errors.
- *
- * @private
- */
-OO.ui.ProcessDialog.prototype.onDismissErrorButtonClick = function () {
-       this.hideErrors();
-};
-
-/**
- * Handle retry button click events.
- *
- * Hides errors and then tries again.
- *
- * @private
- */
-OO.ui.ProcessDialog.prototype.onRetryButtonClick = function () {
-       this.hideErrors();
-       this.executeAction( this.currentAction );
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.ProcessDialog.prototype.onActionResize = function ( action ) {
-       if ( this.actions.isSpecial( action ) ) {
-               this.fitLabel();
-       }
-       return OO.ui.ProcessDialog.parent.prototype.onActionResize.call( this, action );
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.ProcessDialog.prototype.initialize = function () {
-       // Parent method
-       OO.ui.ProcessDialog.parent.prototype.initialize.call( this );
-
-       // Properties
-       this.$navigation = $( '<div>' );
-       this.$location = $( '<div>' );
-       this.$safeActions = $( '<div>' );
-       this.$primaryActions = $( '<div>' );
-       this.$otherActions = $( '<div>' );
-       this.dismissButton = new OO.ui.ButtonWidget( {
-               label: OO.ui.msg( 'ooui-dialog-process-dismiss' )
-       } );
-       this.retryButton = new OO.ui.ButtonWidget();
-       this.$errors = $( '<div>' );
-       this.$errorsTitle = $( '<div>' );
-
-       // Events
-       this.dismissButton.connect( this, { click: 'onDismissErrorButtonClick' } );
-       this.retryButton.connect( this, { click: 'onRetryButtonClick' } );
-
-       // Initialization
-       this.title.$element.addClass( 'oo-ui-processDialog-title' );
-       this.$location
-               .append( this.title.$element )
-               .addClass( 'oo-ui-processDialog-location' );
-       this.$safeActions.addClass( 'oo-ui-processDialog-actions-safe' );
-       this.$primaryActions.addClass( 'oo-ui-processDialog-actions-primary' );
-       this.$otherActions.addClass( 'oo-ui-processDialog-actions-other' );
-       this.$errorsTitle
-               .addClass( 'oo-ui-processDialog-errors-title' )
-               .text( OO.ui.msg( 'ooui-dialog-process-error' ) );
-       this.$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' )
-               .append( this.$errors );
-       this.$navigation
-               .addClass( 'oo-ui-processDialog-navigation' )
-               .append( this.$safeActions, this.$location, this.$primaryActions );
-       this.$head.append( this.$navigation );
-       this.$foot.append( this.$otherActions );
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.ProcessDialog.prototype.getActionWidgets = function ( actions ) {
-       var i, len, widgets = [];
-       for ( i = 0, len = actions.length; i < len; i++ ) {
-               widgets.push(
-                       new OO.ui.ActionWidget( $.extend( { framed: true }, actions[ i ] ) )
-               );
-       }
-       return widgets;
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.ProcessDialog.prototype.attachActions = function () {
-       var i, len, other, special, others;
-
-       // Parent method
-       OO.ui.ProcessDialog.parent.prototype.attachActions.call( this );
-
-       special = this.actions.getSpecial();
-       others = this.actions.getOthers();
-       if ( special.primary ) {
-               this.$primaryActions.append( special.primary.$element );
-       }
-       for ( i = 0, len = others.length; i < len; i++ ) {
-               other = others[ i ];
-               this.$otherActions.append( other.$element );
-       }
-       if ( special.safe ) {
-               this.$safeActions.append( special.safe.$element );
-       }
-
-       this.fitLabel();
-       this.$body.css( 'bottom', this.$foot.outerHeight( true ) );
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.ProcessDialog.prototype.executeAction = function ( action ) {
-       var process = this;
-       return OO.ui.ProcessDialog.parent.prototype.executeAction.call( this, action )
-               .fail( function ( errors ) {
-                       process.showErrors( errors || [] );
-               } );
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.ProcessDialog.prototype.setDimensions = function () {
-       // Parent method
-       OO.ui.ProcessDialog.parent.prototype.setDimensions.apply( this, arguments );
-
-       this.fitLabel();
-};
-
-/**
- * Fit label between actions.
- *
- * @private
- * @chainable
- */
-OO.ui.ProcessDialog.prototype.fitLabel = function () {
-       var safeWidth, primaryWidth, biggerWidth, labelWidth, navigationWidth, leftWidth, rightWidth,
-               size = this.getSizeProperties();
-
-       if ( typeof size.width !== 'number' ) {
-               if ( this.isOpened() ) {
-                       navigationWidth = this.$head.width() - 20;
-               } else if ( this.isOpening() ) {
-                       if ( !this.fitOnOpen ) {
-                               // Size is relative and the dialog isn't open yet, so wait.
-                               this.manager.opening.done( this.fitLabel.bind( this ) );
-                               this.fitOnOpen = true;
-                       }
-                       return;
-               } else {
-                       return;
-               }
-       } else {
-               navigationWidth = size.width - 20;
-       }
-
-       safeWidth = this.$safeActions.is( ':visible' ) ? this.$safeActions.width() : 0;
-       primaryWidth = this.$primaryActions.is( ':visible' ) ? this.$primaryActions.width() : 0;
-       biggerWidth = Math.max( safeWidth, primaryWidth );
-
-       labelWidth = this.title.$element.width();
-
-       if ( 2 * biggerWidth + labelWidth < navigationWidth ) {
-               // We have enough space to center the label
-               leftWidth = rightWidth = biggerWidth;
-       } else {
-               // Let's hope we at least have enough space not to overlap, because we can't wrap the label…
-               if ( this.getDir() === 'ltr' ) {
-                       leftWidth = safeWidth;
-                       rightWidth = primaryWidth;
-               } else {
-                       leftWidth = primaryWidth;
-                       rightWidth = safeWidth;
-               }
-       }
-
-       this.$location.css( { paddingLeft: leftWidth, paddingRight: rightWidth } );
-
-       return this;
-};
-
-/**
- * Handle errors that occurred during accept or reject processes.
- *
- * @private
- * @param {OO.ui.Error[]|OO.ui.Error} errors Errors to be handled
- */
-OO.ui.ProcessDialog.prototype.showErrors = function ( errors ) {
-       var i, len, $item, actions,
-               items = [],
-               abilities = {},
-               recoverable = true,
-               warning = false;
-
-       if ( errors instanceof OO.ui.Error ) {
-               errors = [ errors ];
-       }
-
-       for ( i = 0, len = errors.length; i < len; i++ ) {
-               if ( !errors[ i ].isRecoverable() ) {
-                       recoverable = false;
-               }
-               if ( errors[ i ].isWarning() ) {
-                       warning = true;
-               }
-               $item = $( '<div>' )
-                       .addClass( 'oo-ui-processDialog-error' )
-                       .append( errors[ i ].getMessage() );
-               items.push( $item[ 0 ] );
-       }
-       this.$errorItems = $( items );
-       if ( recoverable ) {
-               abilities[ this.currentAction ] = true;
-               // Copy the flags from the first matching action
-               actions = this.actions.get( { actions: this.currentAction } );
-               if ( actions.length ) {
-                       this.retryButton.clearFlags().setFlags( actions[ 0 ].getFlags() );
-               }
-       } else {
-               abilities[ this.currentAction ] = false;
-               this.actions.setAbilities( abilities );
-       }
-       if ( warning ) {
-               this.retryButton.setLabel( OO.ui.msg( 'ooui-dialog-process-continue' ) );
-       } else {
-               this.retryButton.setLabel( OO.ui.msg( 'ooui-dialog-process-retry' ) );
-       }
-       this.retryButton.toggle( recoverable );
-       this.$errorsTitle.after( this.$errorItems );
-       this.$errors.removeClass( 'oo-ui-element-hidden' ).scrollTop( 0 );
-};
-
-/**
- * Hide errors.
- *
- * @private
- */
-OO.ui.ProcessDialog.prototype.hideErrors = function () {
-       this.$errors.addClass( 'oo-ui-element-hidden' );
-       if ( this.$errorItems ) {
-               this.$errorItems.remove();
-               this.$errorItems = null;
-       }
-};
-
-/**
- * @inheritdoc
- */
-OO.ui.ProcessDialog.prototype.getTeardownProcess = function ( data ) {
-       // Parent method
-       return OO.ui.ProcessDialog.parent.prototype.getTeardownProcess.call( this, data )
-               .first( function () {
-                       // Make sure to hide errors
-                       this.hideErrors();
-                       this.fitOnOpen = false;
-               }, this );
-};
-
-/**
- * @class OO.ui
- */
-
-/**
- * Lazy-initialize and return a global OO.ui.WindowManager instance, used by OO.ui.alert and
- * OO.ui.confirm.
- *
- * @private
- * @return {OO.ui.WindowManager}
- */
-OO.ui.getWindowManager = function () {
-       if ( !OO.ui.windowManager ) {
-               OO.ui.windowManager = new OO.ui.WindowManager();
-               $( 'body' ).append( OO.ui.windowManager.$element );
-               OO.ui.windowManager.addWindows( {
-                       messageDialog: new OO.ui.MessageDialog()
-               } );
-       }
-       return OO.ui.windowManager;
-};
-
-/**
- * Display a quick modal alert dialog, using a OO.ui.MessageDialog. While the dialog is open, the
- * rest of the page will be dimmed out and the user won't be able to interact with it. The dialog
- * has only one action button, labelled "OK", clicking it will simply close the dialog.
- *
- * A window manager is created automatically when this function is called for the first time.
- *
- *     @example
- *     OO.ui.alert( 'Something happened!' ).done( function () {
- *         console.log( 'User closed the dialog.' );
- *     } );
- *
- * @param {jQuery|string} text Message text to display
- * @param {Object} [options] Additional options, see OO.ui.MessageDialog#getSetupProcess
- * @return {jQuery.Promise} Promise resolved when the user closes the dialog
- */
-OO.ui.alert = function ( text, options ) {
-       return OO.ui.getWindowManager().openWindow( 'messageDialog', $.extend( {
-               message: text,
-               verbose: true,
-               actions: [ OO.ui.MessageDialog.static.actions[ 0 ] ]
-       }, options ) ).then( function ( opened ) {
-               return opened.then( function ( closing ) {
-                       return closing.then( function () {
-                               return $.Deferred().resolve();
-                       } );
-               } );
-       } );
-};
-
-/**
- * Display a quick modal confirmation dialog, using a OO.ui.MessageDialog. While the dialog is open,
- * the rest of the page will be dimmed out and the user won't be able to interact with it. The
- * dialog has two action buttons, one to confirm an operation (labelled "OK") and one to cancel it
- * (labelled "Cancel").
- *
- * A window manager is created automatically when this function is called for the first time.
- *
- *     @example
- *     OO.ui.confirm( 'Are you sure?' ).done( function ( confirmed ) {
- *         if ( confirmed ) {
- *             console.log( 'User clicked "OK"!' );
- *         } else {
- *             console.log( 'User clicked "Cancel" or closed the dialog.' );
- *         }
- *     } );
- *
- * @param {jQuery|string} text Message text to display
- * @param {Object} [options] Additional options, see OO.ui.MessageDialog#getSetupProcess
- * @return {jQuery.Promise} Promise resolved when the user closes the dialog. If the user chose to
- *  confirm, the promise will resolve to boolean `true`; otherwise, it will resolve to boolean
- *  `false`.
- */
-OO.ui.confirm = function ( text, options ) {
-       return OO.ui.getWindowManager().openWindow( 'messageDialog', $.extend( {
-               message: text,
-               verbose: true
-       }, options ) ).then( function ( opened ) {
-               return opened.then( function ( closing ) {
-                       return closing.then( function ( data ) {
-                               return $.Deferred().resolve( !!( data && data.action === 'accept' ) );
-                       } );
-               } );
-       } );
-};
-
-}( OO ) );
diff --git a/resources/src/mediawiki.special/mediawiki.special.apisandbox.css b/resources/src/mediawiki.special/mediawiki.special.apisandbox.css
new file mode 100644 (file)
index 0000000..e52955f
--- /dev/null
@@ -0,0 +1,74 @@
+.mw-apisandbox-fullscreen {
+       overflow: hidden;
+}
+
+.mw-apisandbox-toolbar {
+       text-align: right;
+       padding: 0.5em;
+}
+
+.mw-apisandbox-popup .oo-ui-popupWidget-body > .oo-ui-widget {
+       vertical-align: middle;
+}
+
+/* So DateTimeInputWidget's calendar popup works... */
+.mw-apisandbox-popup .oo-ui-popupWidget-popup,
+.mw-apisandbox-popup .oo-ui-popupWidget-body {
+       overflow: visible;
+}
+
+.mw-apisandbox-fullscreen #mw-apisandbox-ui {
+       position: fixed;
+       top: 0;
+       left: 0;
+       bottom: 0;
+       right: 0;
+       background: #fff;
+       z-index: 100;
+}
+
+.mw-apisandbox-spacer {
+       display: inline-block;
+       height: 1px;
+       width: 5em;
+}
+
+.mw-apisandbox-optionalWidget {
+       width: 100%;
+}
+
+.mw-apisandbox-optionalWidget.oo-ui-widget-disabled {
+       position: relative;
+       z-index: 0; /* New stacking context to prevent the overlay from leaking out */
+}
+
+.mw-apisandbox-optionalWidget-overlay {
+       position: absolute;
+       left: 0;
+       right: 0;
+       top: 0;
+       bottom: 0;
+       z-index: 2;
+       cursor: pointer;
+}
+
+.mw-apisandbox-optionalWidget-fields {
+       display: table;
+       width: 100%;
+}
+
+.mw-apisandbox-optionalWidget-widget,
+.mw-apisandbox-optionalWidget-checkbox {
+       display: table-cell;
+       vertical-align: middle;
+}
+
+.mw-apisandbox-optionalWidget-checkbox {
+       width: 1%; /* Will be expanded by content */
+       white-space: nowrap;
+       padding-left: 0.5em;
+}
+
+.oo-ui-textInputWidget.oo-ui-widget-enabled > .oo-ui-indicatorElement-indicator.mw-apisandbox-clickable-indicator {
+       cursor: pointer;
+}
diff --git a/resources/src/mediawiki.special/mediawiki.special.apisandbox.js b/resources/src/mediawiki.special/mediawiki.special.apisandbox.js
new file mode 100644 (file)
index 0000000..32ccdcd
--- /dev/null
@@ -0,0 +1,1659 @@
+/*global OO */
+( function ( $, mw, OO ) {
+       'use strict';
+       var ApiSandbox, Util, WidgetMethods, Validators,
+               $content, panel, booklet, oldhash, windowManager, fullscreenButton,
+               api = new mw.Api(),
+               bookletPages = [],
+               availableFormats = {},
+               resultPage = null,
+               suppressErrors = true,
+               updatingBooklet = false,
+               pages = {},
+               moduleInfoCache = {};
+
+       WidgetMethods = {
+               textInputWidget: {
+                       getApiValue: function () {
+                               return this.getValue();
+                       },
+                       setApiValue: function ( v ) {
+                               if ( v === undefined ) {
+                                       v = this.paramInfo[ 'default' ];
+                               }
+                               this.setValue( v );
+                       },
+                       apiCheckValid: function () {
+                               var that = this;
+                               return this.isValid().done( function ( ok ) {
+                                       ok = ok || suppressErrors;
+                                       that.setIcon( ok ? null : 'alert' );
+                                       that.setIconTitle( ok ? '' : mw.message( 'apisandbox-alert-field' ).plain() );
+                               } );
+                       }
+               },
+
+               dateTimeInputWidget: {
+                       isValid: function () {
+                               var ok = !Util.apiBool( this.paramInfo.required ) || this.getApiValue() !== '';
+                               return $.Deferred().resolve( ok ).promise();
+                       }
+               },
+
+               tokenWidget: {
+                       alertTokenError: function ( code, error ) {
+                               windowManager.openWindow( 'errorAlert', {
+                                       title: mw.message(
+                                               'apisandbox-results-fixtoken-fail', this.paramInfo.tokentype
+                                       ).parse(),
+                                       message: error,
+                                       actions: [
+                                               {
+                                                       action: 'accept',
+                                                       label: OO.ui.msg( 'ooui-dialog-process-dismiss' ),
+                                                       flags: 'primary'
+                                               }
+                                       ]
+                               } );
+                       },
+                       fetchToken: function () {
+                               this.pushPending();
+                               return api.getToken( this.paramInfo.tokentype )
+                                       .done( this.setApiValue.bind( this ) )
+                                       .fail( this.alertTokenError.bind( this ) )
+                                       .always( this.popPending.bind( this ) );
+                       },
+                       setApiValue: function ( v ) {
+                               WidgetMethods.textInputWidget.setApiValue.call( this, v );
+                               if ( v === '123ABC' ) {
+                                       this.fetchToken();
+                               }
+                       }
+               },
+
+               passwordWidget: {
+                       getApiValueForDisplay: function () {
+                               return '';
+                       }
+               },
+
+               toggleSwitchWidget: {
+                       getApiValue: function () {
+                               return this.getValue() ? 1 : undefined;
+                       },
+                       setApiValue: function ( v ) {
+                               this.setValue( Util.apiBool( v ) );
+                       },
+                       apiCheckValid: function () {
+                               return $.Deferred().resolve( true ).promise();
+                       }
+               },
+
+               dropdownWidget: {
+                       getApiValue: function () {
+                               var item = this.getMenu().getSelectedItem();
+                               return item === null ? undefined : item.getData();
+                       },
+                       setApiValue: function ( v ) {
+                               var menu = this.getMenu();
+
+                               if ( v === undefined ) {
+                                       v = this.paramInfo[ 'default' ];
+                               }
+                               if ( v === undefined ) {
+                                       menu.selectItem();
+                               } else {
+                                       menu.selectItemByData( String( v ) );
+                               }
+                       },
+                       apiCheckValid: function () {
+                               var ok = this.getApiValue() !== undefined || suppressErrors;
+                               this.setIcon( ok ? null : 'alert' );
+                               this.setIconTitle( ok ? '' : mw.message( 'apisandbox-alert-field' ).plain() );
+                               return $.Deferred().resolve( ok ).promise();
+                       }
+               },
+
+               capsuleWidget: {
+                       getApiValue: function () {
+                               return this.getItemsData().join( '|' );
+                       },
+                       setApiValue: function ( v ) {
+                               this.setItemsFromData( v === undefined || v === '' ? [] : String( v ).split( '|' ) );
+                       },
+                       apiCheckValid: function () {
+                               var ok = this.getApiValue() !== undefined || suppressErrors;
+                               this.setIcon( ok ? null : 'alert' );
+                               this.setIconTitle( ok ? '' : mw.message( 'apisandbox-alert-field' ).plain() );
+                               return $.Deferred().resolve( ok ).promise();
+                       }
+               },
+
+               optionalWidget: {
+                       getApiValue: function () {
+                               return this.isDisabled() ? undefined : this.widget.getApiValue();
+                       },
+                       setApiValue: function ( v ) {
+                               this.setDisabled( v === undefined );
+                               this.widget.setApiValue( v );
+                       },
+                       apiCheckValid: function () {
+                               if ( this.isDisabled() ) {
+                                       return $.Deferred().resolve( true ).promise();
+                               } else {
+                                       return this.widget.apiCheckValid();
+                               }
+                       }
+               },
+
+               submoduleWidget: {
+                       single: function () {
+                               var v = this.isDisabled() ? this.paramInfo[ 'default' ] : this.getApiValue();
+                               return v === undefined ? [] : [ { value: v, path: this.paramInfo.submodules[ v ] } ];
+                       },
+                       multi: function () {
+                               var map = this.paramInfo.submodules,
+                                       v = this.isDisabled() ? this.paramInfo[ 'default' ] : this.getApiValue();
+                               return v === undefined || v === '' ? [] : $.map( String( v ).split( '|' ), function ( v ) {
+                                       return { value: v, path: map[ v ] };
+                               } );
+                       }
+               },
+
+               uploadWidget: {
+                       getApiValueForDisplay: function () {
+                               return '...';
+                       },
+                       getApiValue: function () {
+                               return this.getValue();
+                       },
+                       setApiValue: function () {
+                               // Can't, sorry.
+                       },
+                       apiCheckValid: function () {
+                               var ok = this.getValue() !== null || suppressErrors;
+                               this.setIcon( ok ? null : 'alert' );
+                               this.setIconTitle( ok ? '' : mw.message( 'apisandbox-alert-field' ).plain() );
+                               return $.Deferred().resolve( ok ).promise();
+                       }
+               }
+       };
+
+       Validators = {
+               generic: function () {
+                       return !Util.apiBool( this.paramInfo.required ) || this.getApiValue() !== '';
+               }
+       };
+
+       /**
+        * @class mw.special.ApiSandbox.Utils
+        * @private
+        */
+       Util = {
+               /**
+                * Fetch API module info
+                *
+                * @param {string} module Module to fetch data for
+                * @return {jQuery.Promise}
+                */
+               fetchModuleInfo: function ( module ) {
+                       var apiPromise,
+                               deferred = $.Deferred();
+
+                       if ( moduleInfoCache.hasOwnProperty( module ) ) {
+                               return deferred
+                                       .resolve( moduleInfoCache[ module ] )
+                                       .promise( { abort: function () {} } );
+                       } else {
+                               apiPromise = api.post( {
+                                       action: 'paraminfo',
+                                       modules: module,
+                                       helpformat: 'html',
+                                       uselang: mw.config.get( 'wgUserLanguage' )
+                               } ).done( function ( data ) {
+                                       var info;
+
+                                       if ( data.warnings && data.warnings.paraminfo ) {
+                                               deferred.reject( '???', data.warnings.paraminfo[ '*' ] );
+                                               return;
+                                       }
+
+                                       info = data.paraminfo.modules;
+                                       if ( !info || info.length !== 1 || info[ 0 ].path !== module ) {
+                                               deferred.reject( '???', 'No module data returned' );
+                                               return;
+                                       }
+
+                                       moduleInfoCache[ module ] = info[ 0 ];
+                                       deferred.resolve( info[ 0 ] );
+                               } ).fail( function ( code, details ) {
+                                       if ( code === 'http' ) {
+                                               details = 'HTTP error: ' + details.exception;
+                                       } else if ( details.error ) {
+                                               details = details.error.info;
+                                       }
+                                       deferred.reject( code, details );
+                               } );
+                               return deferred
+                                       .promise( { abort: apiPromise.abort } );
+                       }
+               },
+
+               /**
+                * Mark all currently-in-use tokens as bad
+                */
+               markTokensBad: function () {
+                       var page, subpages, i,
+                               checkPages = [ pages.main ];
+
+                       while ( checkPages.length ) {
+                               page = checkPages.shift();
+
+                               if ( page.tokenWidget ) {
+                                       api.badToken( page.tokenWidget.paramInfo.tokentype );
+                               }
+
+                               subpages = page.getSubpages();
+                               for ( i = 0; i < subpages.length; i++ ) {
+                                       if ( pages.hasOwnProperty( subpages[ i ].key ) ) {
+                                               checkPages.push( pages[ subpages[ i ].key ] );
+                                       }
+                               }
+                       }
+               },
+
+               /**
+                * Test an API boolean
+                *
+                * @param {Mixed} value
+                * @return {boolean}
+                */
+               apiBool: function ( value ) {
+                       return value !== undefined && value !== false;
+               },
+
+               /**
+                * Create a widget for a parameter.
+                *
+                * @param {Object} pi Parameter info from API
+                * @param {Object} opts Additional options
+                * @return {OO.ui.Widget}
+                */
+               createWidgetForParameter: function ( pi, opts ) {
+                       var widget, innerWidget, finalWidget, items, $button, $content, func,
+                               multiMode = 'none';
+
+                       opts = opts || {};
+
+                       switch ( pi.type ) {
+                               case 'boolean':
+                                       widget = new OO.ui.ToggleSwitchWidget();
+                                       widget.paramInfo = pi;
+                                       $.extend( widget, WidgetMethods.toggleSwitchWidget );
+                                       pi.required = true; // Avoid wrapping in the non-required widget
+                                       break;
+
+                               case 'string':
+                               case 'user':
+                                       if ( pi.tokentype ) {
+                                               widget = new TextInputWithIndicatorWidget( {
+                                                       input: {
+                                                               indicator: 'previous',
+                                                               indicatorTitle: mw.message( 'apisandbox-fetch-token' ).text(),
+                                                               required: Util.apiBool( pi.required )
+                                                       }
+                                               } );
+                                       } else if ( Util.apiBool( pi.multi ) ) {
+                                               widget = new OO.ui.CapsuleMultiSelectWidget( {
+                                                       allowArbitrary: true
+                                               } );
+                                               widget.paramInfo = pi;
+                                               $.extend( widget, WidgetMethods.capsuleWidget );
+                                       } else {
+                                               widget = new OO.ui.TextInputWidget( {
+                                                       required: Util.apiBool( pi.required )
+                                               } );
+                                       }
+                                       if ( !Util.apiBool( pi.multi ) ) {
+                                               widget.paramInfo = pi;
+                                               $.extend( widget, WidgetMethods.textInputWidget );
+                                               widget.setValidation( Validators.generic );
+                                       }
+                                       if ( pi.tokentype ) {
+                                               $.extend( widget, WidgetMethods.tokenWidget );
+                                               widget.input.paramInfo = pi;
+                                               $.extend( widget.input, WidgetMethods.textInputWidget );
+                                               $.extend( widget.input, WidgetMethods.tokenWidget );
+                                               widget.on( 'indicator', widget.fetchToken, [], widget );
+                                       }
+                                       break;
+
+                               case 'text':
+                                       widget = new OO.ui.TextInputWidget( {
+                                               multiline: true,
+                                               required: Util.apiBool( pi.required )
+                                       } );
+                                       widget.paramInfo = pi;
+                                       $.extend( widget, WidgetMethods.textInputWidget );
+                                       widget.setValidation( Validators.generic );
+                                       break;
+
+                               case 'password':
+                                       widget = new OO.ui.TextInputWidget( {
+                                               type: 'password',
+                                               required: Util.apiBool( pi.required )
+                                       } );
+                                       widget.paramInfo = pi;
+                                       $.extend( widget, WidgetMethods.textInputWidget );
+                                       $.extend( widget, WidgetMethods.passwordWidget );
+                                       widget.setValidation( Validators.generic );
+                                       multiMode = 'enter';
+                                       break;
+
+                               case 'integer':
+                                       widget = new OO.ui.NumberInputWidget( {
+                                               required: Util.apiBool( pi.required ),
+                                               isInteger: true
+                                       } );
+                                       widget.setIcon = widget.input.setIcon.bind( widget.input );
+                                       widget.setIconTitle = widget.input.setIconTitle.bind( widget.input );
+                                       widget.isValid = widget.input.isValid.bind( widget.input );
+                                       widget.paramInfo = pi;
+                                       $.extend( widget, WidgetMethods.textInputWidget );
+                                       if ( Util.apiBool( pi.enforcerange ) ) {
+                                               widget.setRange( pi.min || -Infinity, pi.max || Infinity );
+                                       }
+                                       multiMode = 'enter';
+                                       break;
+
+                               case 'limit':
+                                       widget = new OO.ui.NumberInputWidget( {
+                                               required: Util.apiBool( pi.required ),
+                                               isInteger: true
+                                       } );
+                                       widget.setIcon = widget.input.setIcon.bind( widget.input );
+                                       widget.setIconTitle = widget.input.setIconTitle.bind( widget.input );
+                                       widget.isValid = widget.input.isValid.bind( widget.input );
+                                       widget.input.setValidation( function ( value ) {
+                                               return value === 'max' || widget.validateNumber( value );
+                                       } );
+                                       widget.paramInfo = pi;
+                                       $.extend( widget, WidgetMethods.textInputWidget );
+                                       widget.setRange( pi.min || 0, mw.config.get( 'apihighlimits' ) ? pi.highmax : pi.max );
+                                       multiMode = 'enter';
+                                       break;
+
+                               case 'timestamp':
+                                       widget = new mw.widgets.datetime.DateTimeInputWidget( {
+                                               formatter: {
+                                                       format: '${year|0}-${month|0}-${day|0}T${hour|0}:${minute|0}:${second|0}${zone|short}'
+                                               },
+                                               required: Util.apiBool( pi.required ),
+                                               clearable: false
+                                       } );
+                                       widget.paramInfo = pi;
+                                       $.extend( widget, WidgetMethods.textInputWidget );
+                                       $.extend( widget, WidgetMethods.dateTimeInputWidget );
+                                       multiMode = 'indicator';
+                                       break;
+
+                               case 'upload':
+                                       widget = new OO.ui.SelectFileWidget();
+                                       widget.paramInfo = pi;
+                                       $.extend( widget, WidgetMethods.uploadWidget );
+                                       break;
+
+                               case 'namespace':
+                                       items = $.map( mw.config.get( 'wgFormattedNamespaces' ), function ( name, ns ) {
+                                               if ( ns === '0' ) {
+                                                       name = mw.message( 'blanknamespace' ).text();
+                                               }
+                                               return new OO.ui.MenuOptionWidget( { data: ns, label: name } );
+                                       } ).sort( function ( a, b ) {
+                                               return a.data - b.data;
+                                       } );
+                                       if ( Util.apiBool( pi.multi ) ) {
+                                               widget = new OO.ui.CapsuleMultiSelectWidget( {
+                                                       menu: { items: items }
+                                               } );
+                                               widget.paramInfo = pi;
+                                               $.extend( widget, WidgetMethods.capsuleWidget );
+                                       } else {
+                                               widget = new OO.ui.DropdownWidget( {
+                                                       menu: { items: items }
+                                               } );
+                                               widget.paramInfo = pi;
+                                               $.extend( widget, WidgetMethods.dropdownWidget );
+                                       }
+                                       break;
+
+                               default:
+                                       if ( !$.isArray( pi.type ) ) {
+                                               throw new Error( 'Unknown parameter type ' + pi.type );
+                                       }
+
+                                       items = $.map( pi.type, function ( v ) {
+                                               return new OO.ui.MenuOptionWidget( { data: String( v ), label: String( v ) } );
+                                       } );
+                                       if ( Util.apiBool( pi.multi ) ) {
+                                               widget = new OO.ui.CapsuleMultiSelectWidget( {
+                                                       menu: { items: items }
+                                               } );
+                                               widget.paramInfo = pi;
+                                               $.extend( widget, WidgetMethods.capsuleWidget );
+                                               if ( Util.apiBool( pi.submodules ) ) {
+                                                       widget.getSubmodules = WidgetMethods.submoduleWidget.multi;
+                                                       widget.on( 'change', ApiSandbox.updateUI );
+                                               }
+                                       } else {
+                                               widget = new OO.ui.DropdownWidget( {
+                                                       menu: { items: items }
+                                               } );
+                                               widget.paramInfo = pi;
+                                               $.extend( widget, WidgetMethods.dropdownWidget );
+                                               if ( Util.apiBool( pi.submodules ) ) {
+                                                       widget.getSubmodules = WidgetMethods.submoduleWidget.single;
+                                                       widget.getMenu().on( 'choose', ApiSandbox.updateUI );
+                                               }
+                                       }
+
+                                       break;
+                       }
+
+                       if ( Util.apiBool( pi.multi ) && multiMode !== 'none' ) {
+                               innerWidget = widget;
+                               switch ( multiMode ) {
+                                       case 'enter':
+                                               $content = innerWidget.$element;
+                                               break;
+
+                                       case 'indicator':
+                                               $button = innerWidget.$indicator;
+                                               $button.css( 'cursor', 'pointer' );
+                                               $button.attr( 'tabindex', 0 );
+                                               $button.parent().append( $button );
+                                               innerWidget.setIndicator( 'next' );
+                                               $content = innerWidget.$element;
+                                               break;
+
+                                       default:
+                                               throw new Error( 'Unknown multiMode "' + multiMode + '"' );
+                               }
+
+                               widget = new OO.ui.CapsuleMultiSelectWidget( {
+                                       allowArbitrary: true,
+                                       popup: {
+                                               classes: [ 'mw-apisandbox-popup' ],
+                                               $content: $content
+                                       }
+                               } );
+                               widget.paramInfo = pi;
+                               $.extend( widget, WidgetMethods.capsuleWidget );
+
+                               func = function () {
+                                       if ( !innerWidget.isDisabled() ) {
+                                               innerWidget.apiCheckValid().done( function ( ok ) {
+                                                       if ( ok ) {
+                                                               widget.addItemsFromData( [ innerWidget.getApiValue() ] );
+                                                               innerWidget.setApiValue( undefined );
+                                                       }
+                                               } );
+                                               return false;
+                                       }
+                               };
+                               switch ( multiMode ) {
+                                       case 'enter':
+                                               innerWidget.connect( null, { enter: func } );
+                                               break;
+
+                                       case 'indicator':
+                                               $button.on( {
+                                                       click: func,
+                                                       keypress: function ( e ) {
+                                                               if ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) {
+                                                                       func();
+                                                               }
+                                                       }
+                                               } );
+                                               break;
+                               }
+                       }
+
+                       if ( Util.apiBool( pi.required ) || opts.nooptional ) {
+                               finalWidget = widget;
+                       } else {
+                               finalWidget = new OptionalWidget( widget );
+                               finalWidget.paramInfo = pi;
+                               $.extend( finalWidget, WidgetMethods.optionalWidget );
+                               if ( widget.getSubmodules ) {
+                                       finalWidget.getSubmodules = widget.getSubmodules.bind( widget );
+                                       finalWidget.on( 'disable', function () { setTimeout( ApiSandbox.updateUI ); } );
+                               }
+                               finalWidget.setDisabled( true );
+                       }
+
+                       widget.setApiValue( pi[ 'default' ] );
+
+                       return finalWidget;
+               },
+
+               /**
+                * Parse an HTML string, adding target="_blank" to any links
+                *
+                * @param {string} html HTML to parse
+                * @return {jQuery}
+                */
+               parseHTML: function ( html ) {
+                       var $ret = $( $.parseHTML( html ) );
+                       $ret.filter( 'a' ).add( $ret.find( 'a' ) )
+                               .filter( '[href]:not([target])' )
+                               .attr( 'target', '_blank' );
+                       return $ret;
+               }
+       };
+
+       /**
+       * Interface to ApiSandbox UI
+       *
+       * @class mw.special.ApiSandbox
+       */
+       mw.special.ApiSandbox = ApiSandbox = {
+               /**
+                * Initialize the UI
+                *
+                * Automatically called on $.ready()
+                */
+               init: function () {
+                       var $toolbar;
+
+                       $content = $( '#mw-apisandbox' );
+
+                       windowManager = new OO.ui.WindowManager();
+                       $( 'body' ).append( windowManager.$element );
+                       windowManager.addWindows( {
+                               errorAlert: new OO.ui.MessageDialog()
+                       } );
+
+                       fullscreenButton = new OO.ui.ButtonWidget( {
+                               label: mw.message( 'apisandbox-fullscreen' ).text(),
+                               title: mw.message( 'apisandbox-fullscreen-tooltip' ).text()
+                       } ).on( 'click', ApiSandbox.toggleFullscreen );
+
+                       $toolbar = $( '<div>' )
+                               .addClass( 'mw-apisandbox-toolbar' )
+                               .append(
+                                       fullscreenButton.$element,
+                                       new OO.ui.ButtonWidget( {
+                                               label: mw.message( 'apisandbox-submit' ).text(),
+                                               flags: [ 'primary', 'constructive' ]
+                                       } ).on( 'click', ApiSandbox.sendRequest ).$element,
+                                       new OO.ui.ButtonWidget( {
+                                               label: mw.message( 'apisandbox-reset' ).text(),
+                                               flags: 'destructive'
+                                       } ).on( 'click', ApiSandbox.resetUI ).$element
+                               );
+
+                       booklet = new OO.ui.BookletLayout( {
+                               outlined: true,
+                               autoFocus: false
+                       } );
+
+                       panel = new OO.ui.PanelLayout( {
+                               classes: [ 'mw-apisandbox-container' ],
+                               content: [ booklet ],
+                               expanded: false,
+                               framed: true
+                       } );
+
+                       pages.main = new ApiSandbox.PageLayout( { key: 'main', path: 'main' } );
+
+                       // Parse the current hash string
+                       if ( !ApiSandbox.loadFromHash() ) {
+                               ApiSandbox.updateUI();
+                       }
+
+                       // If the hashchange event exists, use it. Otherwise, fake it.
+                       // And, of course, IE has to be dumb.
+                       if ( 'onhashchange' in window &&
+                               ( document.documentMode === undefined || document.documentMode >= 8 )
+                       ) {
+                               $( window ).on( 'hashchange', ApiSandbox.loadFromHash );
+                       } else {
+                               setInterval( function () {
+                                       if ( oldhash !== location.hash ) {
+                                               ApiSandbox.loadFromHash();
+                                       }
+                               }, 1000 );
+                       }
+
+                       $content
+                               .empty()
+                               .append( $( '<p>' ).append( mw.message( 'apisandbox-intro' ).parse() ) )
+                               .append(
+                                       $( '<div>', { id: 'mw-apisandbox-ui' } )
+                                               .append( $toolbar )
+                                               .append( panel.$element )
+                               );
+
+                       $( window ).on( 'resize', ApiSandbox.resizePanel );
+
+                       ApiSandbox.resizePanel();
+               },
+
+               /**
+                * Toggle "fullscreen" mode
+                */
+               toggleFullscreen: function () {
+                       var $body = $( document.body );
+
+                       $body.toggleClass( 'mw-apisandbox-fullscreen' );
+                       if ( $body.hasClass( 'mw-apisandbox-fullscreen' ) ) {
+                               fullscreenButton.setLabel( mw.message( 'apisandbox-unfullscreen' ).text() );
+                               fullscreenButton.setTitle( mw.message( 'apisandbox-unfullscreen-tooltip' ).text() );
+                               $body.append( $( '#mw-apisandbox-ui' ) );
+                       } else {
+                               fullscreenButton.setLabel( mw.message( 'apisandbox-fullscreen' ).text() );
+                               fullscreenButton.setTitle( mw.message( 'apisandbox-fullscreen-tooltip' ).text() );
+                               $content.append( $( '#mw-apisandbox-ui' ) );
+                       }
+                       ApiSandbox.resizePanel();
+               },
+
+               /**
+                * Set the height of the panel based on the current viewport.
+                */
+               resizePanel: function () {
+                       var height = $( window ).height(),
+                               contentTop = $content.offset().top;
+
+                       if ( $( document.body ).hasClass( 'mw-apisandbox-fullscreen' ) ) {
+                               height -= panel.$element.offset().top - $( '#mw-apisandbox-ui' ).offset().top;
+                               panel.$element.height( height - 1 );
+                       } else {
+                               // Subtract the height of the intro text
+                               height -= panel.$element.offset().top - contentTop;
+
+                               panel.$element.height( height - 10 );
+                               $( window ).scrollTop( contentTop - 5 );
+                       }
+               },
+
+               /**
+                * Update the current query when the page hash changes
+                */
+               loadFromHash: function () {
+                       var params, m, re,
+                               hash = location.hash;
+
+                       if ( oldhash === hash ) {
+                               return false;
+                       }
+                       oldhash = hash;
+                       if ( hash === '' ) {
+                               return false;
+                       }
+
+                       // I'm surprised this doesn't seem to exist in jQuery or mw.util.
+                       params = {};
+                       hash = hash.replace( '+', '%20' );
+                       re = /([^&=#]+)=?([^&#]*)/g;
+                       while ( ( m = re.exec( hash ) ) ) {
+                               params[ decodeURIComponent( m[ 1 ] ) ] = decodeURIComponent( m[ 2 ] );
+                       }
+
+                       ApiSandbox.updateUI( params );
+                       return true;
+               },
+
+               /**
+                * Update the pages in the booklet
+                *
+                * @param {Object} [params] Optional query parameters to load
+                */
+               updateUI: function ( params ) {
+                       var i, page, subpages, j, removePages,
+                               addPages = [];
+
+                       if ( !$.isPlainObject( params ) ) {
+                               params = undefined;
+                       }
+
+                       if ( updatingBooklet ) {
+                               return;
+                       }
+                       updatingBooklet = true;
+                       try {
+                               if ( params !== undefined ) {
+                                       pages.main.loadQueryParams( params );
+                               }
+                               addPages.push( pages.main );
+                               if ( resultPage !== null ) {
+                                       addPages.push( resultPage );
+                               }
+                               pages.main.apiCheckValid();
+
+                               i = 0;
+                               while ( addPages.length ) {
+                                       page = addPages.shift();
+                                       if ( bookletPages[ i ] !== page ) {
+                                               for ( j = i; j < bookletPages.length; j++ ) {
+                                                       if ( bookletPages[ j ].getName() === page.getName() ) {
+                                                               bookletPages.splice( j, 1 );
+                                                       }
+                                               }
+                                               bookletPages.splice( i, 0, page );
+                                               booklet.addPages( [ page ], i );
+                                       }
+                                       i++;
+
+                                       if ( page.getSubpages ) {
+                                               subpages = page.getSubpages();
+                                               for ( j = 0; j < subpages.length; j++ ) {
+                                                       if ( !pages.hasOwnProperty( subpages[ j ].key ) ) {
+                                                               subpages[ j ].indentLevel = page.indentLevel + 1;
+                                                               pages[ subpages[ j ].key ] = new ApiSandbox.PageLayout( subpages[ j ] );
+                                                       }
+                                                       if ( params !== undefined ) {
+                                                               pages[ subpages[ j ].key ].loadQueryParams( params );
+                                                       }
+                                                       addPages.splice( j, 0, pages[ subpages[ j ].key ] );
+                                                       pages[ subpages[ j ].key ].apiCheckValid();
+                                               }
+                                       }
+                               }
+
+                               if ( bookletPages.length > i ) {
+                                       removePages = bookletPages.splice( i, bookletPages.length - i );
+                                       booklet.removePages( removePages );
+                               }
+
+                               if ( !booklet.getCurrentPageName() ) {
+                                       booklet.selectFirstSelectablePage();
+                               }
+                       } finally {
+                               updatingBooklet = false;
+                       }
+               },
+
+               /**
+                * Reset button handler
+                */
+               resetUI: function () {
+                       suppressErrors = true;
+                       pages = {
+                               main: new ApiSandbox.PageLayout( { key: 'main', path: 'main' } )
+                       };
+                       resultPage = null;
+                       ApiSandbox.updateUI();
+               },
+
+               /**
+                * Submit button handler
+                */
+               sendRequest: function () {
+                       var page, subpages, i, query, $result,
+                               progress, $progressText, progressLoading,
+                               deferreds = [],
+                               params = {},
+                               displayParams = {},
+                               checkPages = [ pages.main ];
+
+                       suppressErrors = false;
+
+                       while ( checkPages.length ) {
+                               page = checkPages.shift();
+                               deferreds.push( page.apiCheckValid() );
+                               page.getQueryParams( params, displayParams );
+                               subpages = page.getSubpages();
+                               for ( i = 0; i < subpages.length; i++ ) {
+                                       if ( pages.hasOwnProperty( subpages[ i ].key ) ) {
+                                               checkPages.push( pages[ subpages[ i ].key ] );
+                                       }
+                               }
+                       }
+
+                       $.when.apply( $, deferreds ).done( function () {
+                               if ( $.inArray( false, arguments ) !== -1 ) {
+                                       windowManager.openWindow( 'errorAlert', {
+                                               title: mw.message( 'apisandbox-submit-invalid-fields-title' ).parse(),
+                                               message: mw.message( 'apisandbox-submit-invalid-fields-message' ).parse(),
+                                               actions: [
+                                                       {
+                                                               action: 'accept',
+                                                               label: OO.ui.msg( 'ooui-dialog-process-dismiss' ),
+                                                               flags: 'primary'
+                                                       }
+                                               ]
+                                       } );
+                                       return;
+                               }
+
+                               query = $.param( displayParams );
+
+                               // Force a 'fm' format with wrappedhtml=1, if available
+                               if ( params.format !== undefined ) {
+                                       if ( availableFormats.hasOwnProperty( params.format + 'fm' ) ) {
+                                               params.format = params.format + 'fm';
+                                       }
+                                       if ( params.format.substr( -2 ) === 'fm' ) {
+                                               params.wrappedhtml = 1;
+                                       }
+                               }
+
+                               progressLoading = false;
+                               $progressText = $( '<span>' ).text( mw.message( 'apisandbox-sending-request' ).text() );
+                               progress = new OO.ui.ProgressBarWidget( {
+                                       progress: false,
+                                       $content: $progressText
+                               } );
+
+                               $result = $( '<div>' )
+                                       .append( progress.$element );
+
+                               resultPage = page = new OO.ui.PageLayout( '|results|' );
+                               page.setupOutlineItem = function () {
+                                       this.outlineItem.setLabel( mw.message( 'apisandbox-results' ).text() );
+                               };
+                               page.$element.empty()
+                                       .append(
+                                               new OO.ui.FieldLayout(
+                                                       new OO.ui.TextInputWidget( {
+                                                               readOnly: true,
+                                                               value: mw.util.wikiScript( 'api' ) + '?' + query
+                                                       } ), {
+                                                               label: mw.message( 'apisandbox-request-url-label' ).parse()
+                                                       }
+                                               ).$element,
+                                               $result
+                                       );
+                               ApiSandbox.updateUI();
+                               booklet.setPage( '|results|' );
+
+                               location.href = oldhash = '#' + query;
+
+                               api.post( params, {
+                                       contentType: 'multipart/form-data',
+                                       dataType: 'text',
+                                       xhr: function () {
+                                               var xhr = new window.XMLHttpRequest();
+                                               xhr.upload.addEventListener( 'progress', function ( e ) {
+                                                       if ( !progressLoading ) {
+                                                               if ( e.lengthComputable ) {
+                                                                       progress.setProgress( e.loaded * 100 / e.total );
+                                                               } else {
+                                                                       progress.setProgress( false );
+                                                               }
+                                                       }
+                                               } );
+                                               xhr.addEventListener( 'progress', function ( e ) {
+                                                       if ( !progressLoading ) {
+                                                               progressLoading = true;
+                                                               $progressText.text( mw.message( 'apisandbox-loading-results' ).text() );
+                                                       }
+                                                       if ( e.lengthComputable ) {
+                                                               progress.setProgress( e.loaded * 100 / e.total );
+                                                       } else {
+                                                               progress.setProgress( false );
+                                                       }
+                                               } );
+                                               return xhr;
+                                       }
+                               } )
+                                       .fail( function ( code, data ) {
+                                               var details = 'HTTP error: ' + data.exception;
+                                               $result.empty()
+                                                       .append(
+                                                               new OO.ui.LabelWidget( {
+                                                                       label: mw.message( 'apisandbox-results-error', details ).text(),
+                                                                       classes: [ 'error' ]
+                                                               } ).$element
+                                                       );
+                                       } )
+                                       .done( function ( data, jqXHR ) {
+                                               var m, loadTime, button,
+                                                       ct = jqXHR.getResponseHeader( 'Content-Type' );
+
+                                               $result.empty();
+                                               if ( /^text\/mediawiki-api-prettyprint-wrapped(?:;|$)/.test( ct ) ) {
+                                                       data = $.parseJSON( data );
+                                                       if ( data.modules.length ) {
+                                                               mw.loader.load( data.modules );
+                                                       }
+                                                       $result.append( Util.parseHTML( data.html ) );
+                                                       loadTime = data.time;
+                                               } else if ( ( m = data.match( /<pre[ >][\s\S]*<\/pre>/ ) ) ) {
+                                                       $result.append( Util.parseHTML( m[ 0 ] ) );
+                                                       if ( ( m = data.match( /"wgBackendResponseTime":\s*(\d+)/ ) ) ) {
+                                                               loadTime = parseInt( m[ 1 ], 10 );
+                                                       }
+                                               } else {
+                                                       $( '<pre>' )
+                                                               .addClass( 'api-pretty-content' )
+                                                               .text( data )
+                                                               .appendTo( $result );
+                                               }
+                                               if ( typeof loadTime === 'number' ) {
+                                                       $result.append(
+                                                               $( '<div>' ).append(
+                                                                       new OO.ui.LabelWidget( {
+                                                                               label: mw.message( 'apisandbox-request-time', loadTime ).text()
+                                                                       } ).$element
+                                                               )
+                                                       );
+                                               }
+
+                                               if ( jqXHR.getResponseHeader( 'MediaWiki-API-Error' ) === 'badtoken' ) {
+                                                       // Flush all saved tokens in case one of them is the bad one.
+                                                       Util.markTokensBad();
+                                                       button = new OO.ui.ButtonWidget( {
+                                                               label: mw.message( 'apisandbox-results-fixtoken' ).text()
+                                                       } );
+                                                       button.on( 'click', ApiSandbox.fixTokenAndResend )
+                                                               .on( 'click', button.setDisabled, [ true ], button )
+                                                               .$element.appendTo( $result );
+                                               }
+                                       } );
+                       } );
+               },
+
+               /**
+                * Handler for the "Correct token and resubmit" button
+                *
+                * Used on a 'badtoken' error, it re-fetches token parameters for all
+                * pages and then re-submits the query.
+                */
+               fixTokenAndResend: function () {
+                       var page, subpages, i, k,
+                               ok = true,
+                               tokenWait = { dummy: true },
+                               checkPages = [ pages.main ],
+                               success = function ( k ) {
+                                       delete tokenWait[ k ];
+                                       if ( ok && $.isEmptyObject( tokenWait ) ) {
+                                               ApiSandbox.sendRequest();
+                                       }
+                               },
+                               failure = function ( k ) {
+                                       delete tokenWait[ k ];
+                                       ok = false;
+                               };
+
+                       while ( checkPages.length ) {
+                               page = checkPages.shift();
+
+                               if ( page.tokenWidget ) {
+                                       k = page.apiModule + page.tokenWidget.paramInfo.name;
+                                       tokenWait[ k ] = page.tokenWidget.fetchToken()
+                                               .done( success.bind( page.tokenWidget, k ) )
+                                               .fail( failure.bind( page.tokenWidget, k ) );
+                               }
+
+                               subpages = page.getSubpages();
+                               for ( i = 0; i < subpages.length; i++ ) {
+                                       if ( pages.hasOwnProperty( subpages[ i ].key ) ) {
+                                               checkPages.push( pages[ subpages[ i ].key ] );
+                                       }
+                               }
+                       }
+
+                       success( 'dummy', '' );
+               },
+
+               /**
+                * Reset validity indicators for all widgets
+                */
+               updateValidityIndicators: function () {
+                       var page, subpages, i,
+                               checkPages = [ pages.main ];
+
+                       while ( checkPages.length ) {
+                               page = checkPages.shift();
+                               page.apiCheckValid();
+                               subpages = page.getSubpages();
+                               for ( i = 0; i < subpages.length; i++ ) {
+                                       if ( pages.hasOwnProperty( subpages[ i ].key ) ) {
+                                               checkPages.push( pages[ subpages[ i ].key ] );
+                                       }
+                               }
+                       }
+               }
+       };
+
+       /**
+        * PageLayout for API modules
+        *
+        * @class
+        * @private
+        * @extends OO.ui.PageLayout
+        * @constructor
+        * @param {Object} [config] Configuration options
+        */
+       ApiSandbox.PageLayout = function ( config ) {
+               config = $.extend( { prefix: '' }, config );
+               this.displayText = config.key;
+               this.apiModule = config.path;
+               this.prefix = config.prefix;
+               this.paramInfo = null;
+               this.apiIsValid = true;
+               this.loadFromQueryParams = null;
+               this.widgets = {};
+               this.tokenWidget = null;
+               this.indentLevel = config.indentLevel ? config.indentLevel : 0;
+               ApiSandbox.PageLayout[ 'super' ].call( this, config.key, config );
+               this.loadParamInfo();
+       };
+       OO.inheritClass( ApiSandbox.PageLayout, OO.ui.PageLayout );
+       ApiSandbox.PageLayout.prototype.setupOutlineItem = function () {
+               this.outlineItem.setLevel( this.indentLevel );
+               this.outlineItem.setLabel( this.displayText );
+               this.outlineItem.setIcon( this.apiIsValid || suppressErrors ? null : 'alert' );
+               this.outlineItem.setIconTitle(
+                       this.apiIsValid || suppressErrors ? '' : mw.message( 'apisandbox-alert-page' ).plain()
+               );
+       };
+
+       /**
+        * Fetch module information for this page's module, then create UI
+        */
+       ApiSandbox.PageLayout.prototype.loadParamInfo = function () {
+               var dynamicFieldset, dynamicParamNameWidget,
+                       that = this,
+                       addDynamicParamWidget = function () {
+                               var name, layout, widget, button;
+
+                               // Check name is filled in
+                               name = dynamicParamNameWidget.getValue().trim();
+                               if ( name === '' ) {
+                                       dynamicParamNameWidget.focus();
+                                       return;
+                               }
+
+                               if ( that.widgets[ name ] !== undefined ) {
+                                       windowManager.openWindow( 'errorAlert', {
+                                               title: mw.message(
+                                                       'apisandbox-dynamic-error-exists', name
+                                               ).parse(),
+                                               actions: [
+                                                       {
+                                                               action: 'accept',
+                                                               label: OO.ui.msg( 'ooui-dialog-process-dismiss' ),
+                                                               flags: 'primary'
+                                                       }
+                                               ]
+                                       } );
+                                       return;
+                               }
+
+                               widget = Util.createWidgetForParameter( {
+                                       name: name,
+                                       type: 'string',
+                                       'default': ''
+                               }, {
+                                       nooptional: true
+                               } );
+                               button = new OO.ui.ButtonWidget( {
+                                       icon: 'remove',
+                                       flags: 'destructive'
+                               } );
+                               layout = new OO.ui.ActionFieldLayout(
+                                       widget,
+                                       button,
+                                       {
+                                               label: name,
+                                               align: 'left'
+                                       }
+                               );
+                               button.on( 'click', removeDynamicParamWidget, [ name, layout ] );
+                               that.widgets[ name ] = widget;
+                               dynamicFieldset.addItems( [ layout ], dynamicFieldset.getItems().length - 1 );
+                               widget.focus();
+
+                               dynamicParamNameWidget.setValue( '' );
+                       },
+                       removeDynamicParamWidget = function ( name, layout ) {
+                               dynamicFieldset.removeItems( [ layout ] );
+                               delete that.widgets[ name ];
+                       };
+
+               this.$element.empty()
+                       .append( new OO.ui.ProgressBarWidget( {
+                               progress: false,
+                               text: mw.message( 'apisandbox-loading', this.displayText ).text()
+                       } ).$element );
+
+               Util.fetchModuleInfo( this.apiModule )
+                       .done( function ( pi ) {
+                               var prefix, i, j, dl, widget, $widgetLabel, widgetField, helpField, tmp, flag, count,
+                                       items = [],
+                                       deprecatedItems = [],
+                                       buttons = [],
+                                       filterFmModules = function ( v ) {
+                                               return v.substr( -2 ) !== 'fm' ||
+                                                       !availableFormats.hasOwnProperty( v.substr( 0, v.length - 2 ) );
+                                       },
+                                       widgetLabelOnClick = function () {
+                                               var f = this.getField();
+                                               if ( $.isFunction( f.setDisabled ) ) {
+                                                       f.setDisabled( false );
+                                               }
+                                               if ( $.isFunction( f.focus ) ) {
+                                                       f.focus();
+                                               }
+                                       },
+                                       doNothing = function () {};
+
+                               // This is something of a hack. We always want the 'format' and
+                               // 'action' parameters from the main module to be specified,
+                               // and for 'format' we also want to simplify the dropdown since
+                               // we always send the 'fm' variant.
+                               if ( that.apiModule === 'main' ) {
+                                       for ( i = 0; i < pi.parameters.length; i++ ) {
+                                               if ( pi.parameters[ i ].name === 'action' ) {
+                                                       pi.parameters[ i ].required = true;
+                                                       delete pi.parameters[ i ][ 'default' ];
+                                               }
+                                               if ( pi.parameters[ i ].name === 'format' ) {
+                                                       tmp = pi.parameters[ i ].type;
+                                                       for ( j = 0; j < tmp.length; j++ ) {
+                                                               availableFormats[ tmp[ j ] ] = true;
+                                                       }
+                                                       pi.parameters[ i ].type = $.grep( tmp, filterFmModules );
+                                                       pi.parameters[ i ][ 'default' ] = 'json';
+                                                       pi.parameters[ i ].required = true;
+                                               }
+                                       }
+                               }
+
+                               // Hide the 'wrappedhtml' parameter on format modules
+                               if ( pi.group === 'format' ) {
+                                       pi.parameters = $.grep( pi.parameters, function ( p ) {
+                                               return p.name !== 'wrappedhtml';
+                                       } );
+                               }
+
+                               that.paramInfo = pi;
+
+                               items.push( new OO.ui.FieldLayout(
+                                       new OO.ui.Widget( {} ).toggle( false ), {
+                                               align: 'top',
+                                               label: Util.parseHTML( pi.description )
+                                       }
+                               ) );
+
+                               if ( pi.helpurls.length ) {
+                                       buttons.push( new OO.ui.PopupButtonWidget( {
+                                               label: mw.message( 'apisandbox-helpurls' ).text(),
+                                               icon: 'help',
+                                               popup: {
+                                                       $content: $( '<ul>' ).append( $.map( pi.helpurls, function ( link ) {
+                                                               return $( '<li>' ).append( $( '<a>', {
+                                                                       href: link,
+                                                                       target: '_blank',
+                                                                       text: link
+                                                               } ) );
+                                                       } ) )
+                                               }
+                                       } ) );
+                               }
+
+                               if ( pi.examples.length ) {
+                                       buttons.push( new OO.ui.PopupButtonWidget( {
+                                               label: mw.message( 'apisandbox-examples' ).text(),
+                                               icon: 'code',
+                                               popup: {
+                                                       $content: $( '<ul>' ).append( $.map( pi.examples, function ( example ) {
+                                                               var a = $( '<a>', {
+                                                                       href: '#' + example.query,
+                                                                       html: example.description
+                                                               } );
+                                                               a.find( 'a' ).contents().unwrap(); // Can't nest links
+                                                               return $( '<li>' ).append( a );
+                                                       } ) )
+                                               }
+                                       } ) );
+                               }
+
+                               if ( buttons.length ) {
+                                       items.push( new OO.ui.FieldLayout(
+                                               new OO.ui.ButtonGroupWidget( {
+                                                       items: buttons
+                                               } ), { align: 'top' }
+                                       ) );
+                               }
+
+                               if ( pi.parameters.length ) {
+                                       prefix = that.prefix + pi.prefix;
+                                       for ( i = 0; i < pi.parameters.length; i++ ) {
+                                               widget = Util.createWidgetForParameter( pi.parameters[ i ] );
+                                               that.widgets[ prefix + pi.parameters[ i ].name ] = widget;
+                                               if ( pi.parameters[ i ].tokentype ) {
+                                                       that.tokenWidget = widget;
+                                               }
+
+                                               dl = $( '<dl>' );
+                                               dl.append( $( '<dd>', {
+                                                       addClass: 'description',
+                                                       append: Util.parseHTML( pi.parameters[ i ].description )
+                                               } ) );
+                                               if ( pi.parameters[ i ].info && pi.parameters[ i ].info.length ) {
+                                                       for ( j = 0; j < pi.parameters[ i ].info.length; j++ ) {
+                                                               dl.append( $( '<dd>', {
+                                                                       addClass: 'info',
+                                                                       append: Util.parseHTML( pi.parameters[ i ].info[ j ] )
+                                                               } ) );
+                                                       }
+                                               }
+                                               flag = true;
+                                               count = 1e100;
+                                               switch ( pi.parameters[ i ].type ) {
+                                                       case 'namespace':
+                                                               flag = false;
+                                                               count = mw.config.get( 'wgFormattedNamespaces' ).length;
+                                                               break;
+
+                                                       case 'limit':
+                                                               if ( pi.parameters[ i ].highmax !== undefined ) {
+                                                                       dl.append( $( '<dd>', {
+                                                                               addClass: 'info',
+                                                                               append: Util.parseHTML( mw.message(
+                                                                                       'api-help-param-limit2', pi.parameters[ i ].max, pi.parameters[ i ].highmax
+                                                                               ).parse() )
+                                                                       } ) );
+                                                               } else {
+                                                                       dl.append( $( '<dd>', {
+                                                                               addClass: 'info',
+                                                                               append: Util.parseHTML( mw.message(
+                                                                                       'api-help-param-limit', pi.parameters[ i ].max
+                                                                               ).parse() )
+                                                                       } ) );
+                                                               }
+                                                               break;
+
+                                                       case 'integer':
+                                                               tmp = '';
+                                                               if ( pi.parameters[ i ].min !== undefined ) {
+                                                                       tmp += 'min';
+                                                               }
+                                                               if ( pi.parameters[ i ].max !== undefined ) {
+                                                                       tmp += 'max';
+                                                               }
+                                                               if ( tmp !== '' ) {
+                                                                       dl.append( $( '<dd>', {
+                                                                               addClass: 'info',
+                                                                               append: Util.parseHTML( mw.message(
+                                                                                       'api-help-param-integer-' + tmp,
+                                                                                       Util.apiBool( pi.parameters[ i ].multi ) ? 2 : 1,
+                                                                                       pi.parameters[ i ].min, pi.parameters[ i ].max
+                                                                               ).parse() )
+                                                                       } ) );
+                                                               }
+                                                               break;
+
+                                                       default:
+                                                               if ( $.isArray( pi.parameters[ i ].type ) ) {
+                                                                       flag = false;
+                                                                       count = pi.parameters[ i ].type.length;
+                                                               }
+                                                               break;
+                                               }
+                                               if ( Util.apiBool( pi.parameters[ i ].multi ) ) {
+                                                       tmp = [];
+                                                       if ( flag && !( widget instanceof OO.ui.CapsuleMultiSelectWidget ) &&
+                                                               !(
+                                                                       widget instanceof OptionalWidget &&
+                                                                       widget.widget instanceof OO.ui.CapsuleMultiSelectWidget
+                                                               )
+                                                       ) {
+                                                               tmp.push( mw.message( 'api-help-param-multi-separate' ).parse() );
+                                                       }
+                                                       if ( count > pi.parameters[ i ].lowlimit ) {
+                                                               tmp.push(
+                                                                       mw.message( 'api-help-param-multi-max',
+                                                                               pi.parameters[ i ].lowlimit, pi.parameters[ i ].highlimit
+                                                                       ).parse()
+                                                               );
+                                                       }
+                                                       if ( tmp.length ) {
+                                                               dl.append( $( '<dd>', {
+                                                                       addClass: 'info',
+                                                                       append: Util.parseHTML( tmp.join( ' ' ) )
+                                                               } ) );
+                                                       }
+                                               }
+                                               helpField = new OO.ui.FieldLayout(
+                                                       new OO.ui.Widget( {
+                                                               $content: '\xa0',
+                                                               classes: [ 'mw-apisandbox-spacer' ]
+                                                       } ), {
+                                                               align: 'inline',
+                                                               label: dl
+                                                       }
+                                               );
+
+                                               $widgetLabel = $( '<span>' );
+                                               widgetField = new OO.ui.FieldLayout(
+                                                       widget,
+                                                       {
+                                                               align: 'left',
+                                                               label: prefix + pi.parameters[ i ].name,
+                                                               $label: $widgetLabel
+                                                       }
+                                               );
+
+                                               // FieldLayout only does click for InputElement
+                                               // widgets. So supply our own click handler.
+                                               $widgetLabel.on( 'click', widgetLabelOnClick.bind( widgetField ) );
+
+                                               // Don't grey out the label when the field is disabled,
+                                               // it makes it too hard to read and our "disabled"
+                                               // isn't really disabled.
+                                               widgetField.onFieldDisable = doNothing;
+
+                                               if ( Util.apiBool( pi.parameters[ i ].deprecated ) ) {
+                                                       deprecatedItems.push( widgetField, helpField );
+                                               } else {
+                                                       items.push( widgetField, helpField );
+                                               }
+                                       }
+                               }
+
+                               if ( !pi.parameters.length && !Util.apiBool( pi.dynamicparameters ) ) {
+                                       items.push( new OO.ui.FieldLayout(
+                                               new OO.ui.Widget( {} ).toggle( false ), {
+                                                       align: 'top',
+                                                       label: Util.parseHTML( mw.message( 'apisandbox-no-parameters' ).parse() )
+                                               }
+                                       ) );
+                               }
+
+                               that.$element.empty();
+
+                               new OO.ui.FieldsetLayout( {
+                                       label: that.displayText
+                               } ).addItems( items )
+                                       .$element.appendTo( that.$element );
+
+                               if ( Util.apiBool( pi.dynamicparameters ) ) {
+                                       dynamicFieldset = new OO.ui.FieldsetLayout();
+                                       dynamicParamNameWidget = new OO.ui.TextInputWidget( {
+                                               placeholder: mw.message( 'apisandbox-dynamic-parameters-add-placeholder' ).text()
+                                       } ).on( 'enter', addDynamicParamWidget );
+                                       dynamicFieldset.addItems( [
+                                               new OO.ui.FieldLayout(
+                                                       new OO.ui.Widget( {} ).toggle( false ), {
+                                                               align: 'top',
+                                                               label: Util.parseHTML( pi.dynamicparameters )
+                                                       }
+                                               ),
+                                               new OO.ui.ActionFieldLayout(
+                                                       dynamicParamNameWidget,
+                                                       new OO.ui.ButtonWidget( {
+                                                               icon: 'add',
+                                                               flags: 'constructive'
+                                                       } ).on( 'click', addDynamicParamWidget ),
+                                                       {
+                                                               label: mw.message( 'apisandbox-dynamic-parameters-add-label' ).text(),
+                                                               align: 'left'
+                                                       }
+                                               )
+                                       ] );
+                                       $( '<fieldset>' )
+                                               .append(
+                                                       $( '<legend>' ).text( mw.message( 'apisandbox-dynamic-parameters' ).text() ),
+                                                       dynamicFieldset.$element
+                                               )
+                                               .appendTo( that.$element );
+                               }
+
+                               if ( deprecatedItems.length ) {
+                                       tmp = new OO.ui.FieldsetLayout().addItems( deprecatedItems ).toggle( false );
+                                       $( '<fieldset>' )
+                                               .append(
+                                                       $( '<legend>' ).append(
+                                                               new OO.ui.ToggleButtonWidget( {
+                                                                       label: mw.message( 'apisandbox-deprecated-parameters' ).text()
+                                                               } ).on( 'change', tmp.toggle, [], tmp ).$element
+                                                       ),
+                                                       tmp.$element
+                                               )
+                                               .appendTo( that.$element );
+                               }
+
+                               // Load stored params, if any, then update the booklet if we
+                               // have subpages (or else just update our valid-indicator).
+                               tmp = that.loadFromQueryParams;
+                               that.loadFromQueryParams = null;
+                               if ( $.isPlainObject( tmp ) ) {
+                                       that.loadQueryParams( tmp );
+                               }
+                               if ( that.getSubpages().length > 0 ) {
+                                       ApiSandbox.updateUI( tmp );
+                               } else {
+                                       that.apiCheckValid();
+                               }
+                       } ).fail( function ( code, detail ) {
+                               that.$element.empty()
+                                       .append(
+                                               new OO.ui.LabelWidget( {
+                                                       label: mw.message( 'apisandbox-load-error', that.apiModule, detail ).text(),
+                                                       classes: [ 'error' ]
+                                               } ).$element,
+                                               new OO.ui.ButtonWidget( {
+                                                       label: mw.message( 'apisandbox-retry' ).text()
+                                               } ).on( 'click', that.loadParamInfo, [], that ).$element
+                                       );
+                       } );
+       };
+
+       /**
+        * Check that all widgets on the page are in a valid state.
+        *
+        * @return {boolean}
+        */
+       ApiSandbox.PageLayout.prototype.apiCheckValid = function () {
+               var that = this;
+
+               if ( this.paramInfo === null ) {
+                       return $.Deferred().resolve( false ).promise();
+               } else {
+                       return $.when.apply( $, $.map( this.widgets, function ( widget ) {
+                               return widget.apiCheckValid();
+                       } ) ).then( function () {
+                               that.apiIsValid = $.inArray( false, arguments ) === -1;
+                               if ( that.getOutlineItem() ) {
+                                       that.getOutlineItem().setIcon( that.apiIsValid || suppressErrors ? null : 'alert' );
+                                       that.getOutlineItem().setIconTitle(
+                                               that.apiIsValid || suppressErrors ? '' : mw.message( 'apisandbox-alert-page' ).plain()
+                                       );
+                               }
+                               return $.Deferred().resolve( that.apiIsValid ).promise();
+                       } );
+               }
+       };
+
+       /**
+        * Load form fields from query parameters
+        *
+        * @param {Object} params
+        */
+       ApiSandbox.PageLayout.prototype.loadQueryParams = function ( params ) {
+               if ( this.paramInfo === null ) {
+                       this.loadFromQueryParams = params;
+               } else {
+                       $.each( this.widgets, function ( name, widget ) {
+                               var v = params.hasOwnProperty( name ) ? params[ name ] : undefined;
+                               widget.setApiValue( v );
+                       } );
+               }
+       };
+
+       /**
+        * Load query params from form fields
+        *
+        * @param {Object} params Write query parameters into this object
+        * @param {Object} displayParams Write query parameters for display into this object
+        */
+       ApiSandbox.PageLayout.prototype.getQueryParams = function ( params, displayParams ) {
+               $.each( this.widgets, function ( name, widget ) {
+                       var value = widget.getApiValue();
+                       if ( value !== undefined ) {
+                               params[ name ] = value;
+                               if ( $.isFunction( widget.getApiValueForDisplay ) ) {
+                                       value = widget.getApiValueForDisplay();
+                               }
+                               displayParams[ name ] = value;
+                       }
+               } );
+       };
+
+       /**
+        * Fetch a list of subpage names loaded by this page
+        *
+        * @return {Array}
+        */
+       ApiSandbox.PageLayout.prototype.getSubpages = function () {
+               var ret = [];
+               $.each( this.widgets, function ( name, widget ) {
+                       var submodules, i;
+                       if ( $.isFunction( widget.getSubmodules ) ) {
+                               submodules = widget.getSubmodules();
+                               for ( i = 0; i < submodules.length; i++ ) {
+                                       ret.push( {
+                                               key: name + '=' + submodules[ i ].value,
+                                               path: submodules[ i ].path,
+                                               prefix: widget.paramInfo.submoduleparamprefix || ''
+                                       } );
+                               }
+                       }
+               } );
+               return ret;
+       };
+
+       /**
+        * A text input with a clickable indicator
+        *
+        * @class
+        * @private
+        * @constructor
+        * @param {Object} [config] Configuration options
+        */
+       function TextInputWithIndicatorWidget( config ) {
+               var k;
+
+               config = config || {};
+               TextInputWithIndicatorWidget[ 'super' ].call( this, config );
+
+               this.$indicator = $( '<span>' ).addClass( 'mw-apisandbox-clickable-indicator' );
+               OO.ui.mixin.TabIndexedElement.call(
+                       this, $.extend( {}, config, { $tabIndexed: this.$indicator } )
+               );
+
+               this.input = new OO.ui.TextInputWidget( $.extend( {
+                       $indicator: this.$indicator,
+                       disabled: this.isDisabled()
+               }, config.input ) );
+
+               // Forward most methods for convenience
+               for ( k in this.input ) {
+                       if ( $.isFunction( this.input[ k ] ) && !this[ k ] ) {
+                               this[ k ] = this.input[ k ].bind( this.input );
+                       }
+               }
+
+               this.$indicator.on( {
+                       click: this.onIndicatorClick.bind( this ),
+                       keypress: this.onIndicatorKeyPress.bind( this )
+               } );
+
+               this.$element.append( this.input.$element );
+       }
+       OO.inheritClass( TextInputWithIndicatorWidget, OO.ui.Widget );
+       OO.mixinClass( TextInputWithIndicatorWidget, OO.ui.mixin.TabIndexedElement );
+       TextInputWithIndicatorWidget.prototype.onIndicatorClick = function ( e ) {
+               if ( !this.isDisabled() && e.which === 1 ) {
+                       this.emit( 'indicator' );
+               }
+               return false;
+       };
+       TextInputWithIndicatorWidget.prototype.onIndicatorKeyPress = function ( e ) {
+               if ( !this.isDisabled() && ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) ) {
+                       this.emit( 'indicator' );
+                       return false;
+               }
+       };
+       TextInputWithIndicatorWidget.prototype.setDisabled = function ( disabled ) {
+               TextInputWithIndicatorWidget[ 'super' ].prototype.setDisabled.call( this, disabled );
+               if ( this.input ) {
+                       this.input.setDisabled( this.isDisabled() );
+               }
+               return this;
+       };
+
+       /**
+        * A wrapper for a widget that provides an enable/disable button
+        *
+        * @class
+        * @private
+        * @constructor
+        * @param {OO.ui.Widget} widget
+        * @param {Object} [config] Configuration options
+        */
+       function OptionalWidget( widget, config ) {
+               var k;
+
+               config = config || {};
+
+               this.widget = widget;
+               this.$overlay = config.$overlay ||
+                       $( '<div>' ).addClass( 'mw-apisandbox-optionalWidget-overlay' );
+               this.checkbox = new OO.ui.CheckboxInputWidget( config.checkbox )
+                       .on( 'change', this.onCheckboxChange, [], this );
+
+               OptionalWidget[ 'super' ].call( this, config );
+
+               // Forward most methods for convenience
+               for ( k in this.widget ) {
+                       if ( $.isFunction( this.widget[ k ] ) && !this[ k ] ) {
+                               this[ k ] = this.widget[ k ].bind( this.widget );
+                       }
+               }
+
+               this.$overlay.on( 'click', this.onOverlayClick.bind( this ) );
+
+               this.$element
+                       .addClass( 'mw-apisandbox-optionalWidget' )
+                       .append(
+                               this.$overlay,
+                               $( '<div>' ).addClass( 'mw-apisandbox-optionalWidget-fields' ).append(
+                                       $( '<div>' ).addClass( 'mw-apisandbox-optionalWidget-widget' ).append(
+                                               widget.$element
+                                       ),
+                                       $( '<div>' ).addClass( 'mw-apisandbox-optionalWidget-checkbox' ).append(
+                                               this.checkbox.$element
+                                       )
+                               )
+                       );
+
+               this.setDisabled( widget.isDisabled() );
+       }
+       OO.inheritClass( OptionalWidget, OO.ui.Widget );
+       OptionalWidget.prototype.onCheckboxChange = function ( checked ) {
+               this.setDisabled( !checked );
+       };
+       OptionalWidget.prototype.onOverlayClick = function () {
+               this.setDisabled( false );
+               if ( $.isFunction( this.widget.focus ) ) {
+                       this.widget.focus();
+               }
+       };
+       OptionalWidget.prototype.setDisabled = function ( disabled ) {
+               OptionalWidget[ 'super' ].prototype.setDisabled.call( this, disabled );
+               this.widget.setDisabled( this.isDisabled() );
+               this.checkbox.setSelected( !this.isDisabled() );
+               this.$overlay.toggle( this.isDisabled() );
+               return this;
+       };
+
+       $( ApiSandbox.init );
+
+}( jQuery, mediaWiki, OO ) );
diff --git a/resources/src/mediawiki.special/mediawiki.special.apisandbox.top.css b/resources/src/mediawiki.special/mediawiki.special.apisandbox.top.css
new file mode 100644 (file)
index 0000000..4dc4c27
--- /dev/null
@@ -0,0 +1,3 @@
+.client-js .mw-apisandbox-nojs {
+       display: none;
+}
index fe5e634..99e4569 100644 (file)
@@ -1,4 +1,4 @@
-h1.firstHeading {
+.mw-special-ApiHelp h1.firstHeading {
        display: none;
 }
 
index 028ef81..05f454c 100644 (file)
@@ -50,6 +50,7 @@ $wgAutoloadClasses += array(
        # tests/phpunit/includes
        'RevisionStorageTest' => "$testDir/phpunit/includes/RevisionStorageTest.php",
        'TestingAccessWrapper' => "$testDir/phpunit/includes/TestingAccessWrapper.php",
+       'TestLogger' => "$testDir/phpunit/includes/TestLogger.php",
 
        # tests/phpunit/includes/api
        'ApiFormatTestBase' => "$testDir/phpunit/includes/api/format/ApiFormatTestBase.php",
@@ -98,6 +99,10 @@ $wgAutoloadClasses += array(
        'ResourceLoaderImageModuleTestable' =>
                "$testDir/phpunit/includes/resourceloader/ResourceLoaderImageModuleTest.php",
 
+       # tests/phpunit/includes/session
+       'MediaWiki\\Session\\TestBagOStuff' => "$testDir/phpunit/includes/session/TestBagOStuff.php",
+       'MediaWiki\\Session\\TestUtils' => "$testDir/phpunit/includes/session/TestUtils.php",
+
        # tests/phpunit/includes/specials
        'SpecialPageTestBase' => "$testDir/phpunit/includes/specials/SpecialPageTestBase.php",
 
@@ -122,6 +127,9 @@ $wgAutoloadClasses += array(
        'MockSvgHandler' => "$testDir/phpunit/mocks/media/MockSvgHandler.php",
        'MockDjVuHandler' => "$testDir/phpunit/mocks/media/MockDjVuHandler.php",
        'MockWebRequest' => "$testDir/phpunit/mocks/MockWebRequest.php",
+       'MediaWiki\\Session\\DummySessionBackend'
+               => "$testDir/phpunit/mocks/session/DummySessionBackend.php",
+       'DummySessionProvider' => "$testDir/phpunit/mocks/session/DummySessionProvider.php",
 
        # tests/parser
        'NewParserTest' => "$testDir/phpunit/includes/parser/NewParserTest.php",
index d866ed8..d4e7119 100644 (file)
@@ -2520,6 +2520,7 @@ Barack Obama <President> of the United States
 </p>
 !! end
 
+## PHP parser discards the "<pre " string
 !! test
 Handle broken pre-like tags (bug 64025)
 !! options
@@ -2530,13 +2531,8 @@ parsoid=wt2html
 <table><pre </table>
 !! html/php
 <pre>x</pre>
-<table>&lt;pre </table>
+<table><pre></pre></table>
 
-!! html/php+tidy
-<pre>
-x
-</pre>
-<p>&lt;pre</p>
 !! html/parsoid
 <pre about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"a":{"&lt;pre":null},"sa":{"&lt;pre":""},"stx":"html","pi":[[{"k":"1","spc":["","","",""]}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;pre &lt;pre>x&lt;/pre>"}},"i":0}}]}'>x</pre>
 
@@ -10969,8 +10965,6 @@ Un-closed <includeonly>
 !! wikitext
 <includeonly>
 !! html
-<p>&lt;includeonly&gt;
-</p>
 !! end
 
 ## We used to, but no longer wt2wt this test since the default serializer
index 523cf68..f69e342 100644 (file)
@@ -221,6 +221,8 @@ abstract class MediaWikiTestCase extends PHPUnit_Framework_TestCase {
        }
 
        protected function tearDown() {
+               global $wgRequest;
+
                $status = ob_get_status();
                if ( isset( $status['name'] ) &&
                        $status['name'] === 'MediaWikiTestCase::wfResetOutputBuffersBarrier'
@@ -252,6 +254,12 @@ abstract class MediaWikiTestCase extends PHPUnit_Framework_TestCase {
                $this->mwGlobals = array();
                RequestContext::resetMain();
                MediaHandler::resetCache();
+               if ( session_id() !== '' ) {
+                       session_write_close();
+                       session_id( '' );
+               }
+               $wgRequest = new FauxRequest();
+               MediaWiki\Session\SessionManager::resetCache();
 
                $phpErrorLevel = intval( ini_get( 'error_reporting' ) );
 
@@ -509,6 +517,13 @@ abstract class MediaWikiTestCase extends PHPUnit_Framework_TestCase {
                                false,
                                $user
                        );
+
+                       // doEditContent() probably started the session via
+                       // User::loadFromSession(). Close it now.
+                       if ( session_id() !== '' ) {
+                               session_write_close();
+                               session_id( '' );
+                       }
                }
        }
 
diff --git a/tests/phpunit/includes/TestLogger.php b/tests/phpunit/includes/TestLogger.php
new file mode 100644 (file)
index 0000000..7099c3a
--- /dev/null
@@ -0,0 +1,105 @@
+<?php
+/**
+ * Testing logger
+ *
+ * Copyright (C) 2015 Brad Jorsch <bjorsch@wikimedia.org>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @author Brad Jorsch <bjorsch@wikimedia.org>
+ */
+
+use Psr\Log\LogLevel;
+
+/**
+ * A logger that may be configured to either buffer logs or to print them to
+ * the output where PHPUnit will complain about them.
+ *
+ * @since 1.27
+ */
+class TestLogger extends \Psr\Log\AbstractLogger {
+       private $collect = false;
+       private $buffer = array();
+       private $filter = null;
+
+       /**
+        * @param bool $collect Whether to collect logs
+        * @param callable $filter Filter logs before collecting/printing. Signature is
+        *  string|null function ( string $message, string $level );
+        */
+       public function __construct( $collect = false, $filter = null ) {
+               $this->collect = $collect;
+               $this->filter = $filter;
+       }
+
+       /**
+        * Set the "collect" flag
+        * @param bool $collect
+        */
+       public function setCollect( $collect ) {
+               $this->collect = $collect;
+       }
+
+       /**
+        * Return the collected logs
+        * @return array Array of array( string $level, string $message )
+        */
+       public function getBuffer() {
+               return $this->buffer;
+       }
+
+       /**
+        * Clear the collected log buffer
+        */
+       public function clearBuffer() {
+               $this->buffer = array();
+       }
+
+       public function log( $level, $message, array $context = array() ) {
+               $message = trim( $message );
+
+               if ( $this->filter ) {
+                       $message = call_user_func( $this->filter, $message, $level );
+                       if ( $message === null ) {
+                               return;
+                       }
+               }
+
+               if ( $this->collect ) {
+                       $this->buffer[] = array( $level, $message );
+               } else {
+                       switch ( $level ) {
+                               case LogLevel::DEBUG:
+                               case LogLevel::INFO:
+                               case LogLevel::NOTICE:
+                                       trigger_error( "LOG[$level]: $message", E_USER_NOTICE );
+                                       break;
+
+                               case LogLevel::WARNING:
+                                       trigger_error( "LOG[$level]: $message", E_USER_WARNING );
+                                       break;
+
+                               case LogLevel::ERROR:
+                               case LogLevel::CRITICAL:
+                               case LogLevel::ALERT:
+                               case LogLevel::EMERGENCY:
+                                       trigger_error( "LOG[$level]: $message", E_USER_ERROR );
+                                       break;
+                       }
+               }
+       }
+}
index 3945102..35905c5 100644 (file)
@@ -10,7 +10,6 @@
 class ApiCreateAccountTest extends ApiTestCase {
        protected function setUp() {
                parent::setUp();
-               LoginForm::setCreateaccountToken();
                $this->setMwGlobals( array( 'wgEnableEmail' => true ) );
        }
 
@@ -114,7 +113,7 @@ class ApiCreateAccountTest extends ApiTestCase {
        public function testNoName() {
                $this->doApiRequest( array(
                        'action' => 'createaccount',
-                       'token' => LoginForm::getCreateaccountToken(),
+                       'token' => LoginForm::getCreateaccountToken()->toString(),
                        'password' => 'password',
                ) );
        }
@@ -127,7 +126,7 @@ class ApiCreateAccountTest extends ApiTestCase {
                $this->doApiRequest( array(
                        'action' => 'createaccount',
                        'name' => 'testName',
-                       'token' => LoginForm::getCreateaccountToken(),
+                       'token' => LoginForm::getCreateaccountToken()->toString(),
                ) );
        }
 
@@ -139,7 +138,7 @@ class ApiCreateAccountTest extends ApiTestCase {
                $this->doApiRequest( array(
                        'action' => 'createaccount',
                        'name' => 'Apitestsysop',
-                       'token' => LoginForm::getCreateaccountToken(),
+                       'token' => LoginForm::getCreateaccountToken()->toString(),
                        'password' => 'password',
                        'email' => 'test@domain.test',
                ) );
@@ -153,7 +152,7 @@ class ApiCreateAccountTest extends ApiTestCase {
                $this->doApiRequest( array(
                        'action' => 'createaccount',
                        'name' => 'Test User',
-                       'token' => LoginForm::getCreateaccountToken(),
+                       'token' => LoginForm::getCreateaccountToken()->toString(),
                        'password' => 'password',
                        'email' => 'invalid',
                ) );
index 7dfd14f..894feb7 100644 (file)
@@ -13,9 +13,13 @@ class ApiLoginTest extends ApiTestCase {
         * Test result of attempted login with an empty username
         */
        public function testApiLoginNoName() {
+               $session = array(
+                       'wsTokenSecrets' => array( 'login' => 'foobar' ),
+               );
                $data = $this->doApiRequest( array( 'action' => 'login',
                        'lgname' => '', 'lgpassword' => self::$users['sysop']->password,
-               ) );
+                       'lgtoken' => (string)( new MediaWiki\Session\Token( 'foobar', '' ) )
+               ), $session );
                $this->assertEquals( 'NoName', $data[0]['login']['result'] );
        }
 
@@ -179,4 +183,94 @@ class ApiLoginTest extends ApiTestCase {
                $this->assertArrayHasKey( 'lgtoken', $data[0]['login'] );
        }
 
+       public function testBotPassword() {
+               global $wgServer, $wgSessionProviders;
+
+               if ( !isset( $wgServer ) ) {
+                       $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' );
+               }
+
+               $this->setMwGlobals( array(
+                       'wgSessionProviders' => array_merge( $wgSessionProviders, array(
+                               array(
+                                       'class' => 'MediaWiki\\Session\\BotPasswordSessionProvider',
+                                       'args' => array( array( 'priority' => 40 ) ),
+                               )
+                       ) ),
+                       'wgEnableBotPasswords' => true,
+                       'wgBotPasswordsDatabase' => false,
+                       'wgCentralIdLookupProvider' => 'local',
+                       'wgGrantPermissions' => array(
+                               'test' => array( 'read' => true ),
+                       ),
+               ) );
+
+               // Make sure our session provider is present
+               $manager = TestingAccessWrapper::newFromObject( MediaWiki\Session\SessionManager::singleton() );
+               if ( !isset( $manager->sessionProviders['MediaWiki\\Session\\BotPasswordSessionProvider'] ) ) {
+                       $tmp = $manager->sessionProviders;
+                       $manager->sessionProviders = null;
+                       $manager->sessionProviders = $tmp + $manager->getProviders();
+               }
+               $this->assertNotNull(
+                       MediaWiki\Session\SessionManager::singleton()->getProvider(
+                               'MediaWiki\\Session\\BotPasswordSessionProvider'
+                       ),
+                       'sanity check'
+               );
+
+               $user = self::$users['sysop'];
+               $centralId = CentralIdLookup::factory()->centralIdFromLocalUser( $user->getUser() );
+               $this->assertNotEquals( 0, $centralId, 'sanity check' );
+
+               $passwordFactory = new PasswordFactory();
+               $passwordFactory->init( RequestContext::getMain()->getConfig() );
+               // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only
+               $passwordFactory->setDefaultType( 'A' );
+               $pwhash = $passwordFactory->newFromPlaintext( 'foobaz' );
+
+               $dbw = wfGetDB( DB_MASTER );
+               $dbw->insert(
+                       'bot_passwords',
+                       array(
+                               'bp_user' => $centralId,
+                               'bp_app_id' => 'foo',
+                               'bp_password' => $pwhash->toString(),
+                               'bp_token' => '',
+                               'bp_restrictions' => MWRestrictions::newDefault()->toJson(),
+                               'bp_grants' => '["test"]',
+                       ),
+                       __METHOD__
+               );
+
+               $lgName = $user->username . BotPassword::getSeparator() . 'foo';
+
+               $ret = $this->doApiRequest( array(
+                       'action' => 'login',
+                       'lgname' => $lgName,
+                       'lgpassword' => 'foobaz',
+               ) );
+
+               $result = $ret[0];
+               $this->assertNotInternalType( 'bool', $result );
+               $this->assertNotInternalType( 'null', $result['login'] );
+
+               $a = $result['login']['result'];
+               $this->assertEquals( 'NeedToken', $a );
+               $token = $result['login']['token'];
+
+               $ret = $this->doApiRequest( array(
+                       'action' => 'login',
+                       'lgtoken' => $token,
+                       'lgname' => $lgName,
+                       'lgpassword' => 'foobaz',
+               ), $ret[2] );
+
+               $result = $ret[0];
+               $this->assertNotInternalType( 'bool', $result );
+               $a = $result['login']['result'];
+
+               $this->assertEquals( 'Success', $a );
+       }
+
 }
index 01113a6..52c9fec 100644 (file)
@@ -47,11 +47,7 @@ abstract class ApiTestCase extends MediaWikiLangTestCase {
 
        protected function tearDown() {
                // Avoid leaking session over tests
-               if ( session_id() != '' ) {
-                       global $wgUser;
-                       $wgUser->logout();
-                       session_destroy();
-               }
+               MediaWiki\Session\SessionManager::getGlobalSession()->clear();
 
                parent::tearDown();
        }
@@ -152,12 +148,12 @@ abstract class ApiTestCase extends MediaWikiLangTestCase {
                if ( isset( $session['wsToken'] ) && $session['wsToken'] ) {
                        // @todo Why does this directly mess with the session? Fix that.
                        // add edit token to fake session
-                       $session['wsEditToken'] = $session['wsToken'];
+                       $session['wsTokenSecrets']['default'] = $session['wsToken'];
                        // add token to request parameters
                        $timestamp = wfTimestamp();
                        $params['token'] = hash_hmac( 'md5', $timestamp, $session['wsToken'] ) .
                                dechex( $timestamp ) .
-                               User::EDIT_TOKEN_SUFFIX;
+                               MediaWiki\Session\Token::SUFFIX;
 
                        return $this->doApiRequest( $params, $session, false, $user );
                } else {
index 87f794c..b6ae641 100644 (file)
@@ -15,8 +15,6 @@ abstract class ApiTestCaseUpload extends ApiTestCase {
                        'wgEnableAPI' => true,
                ) );
 
-               wfSetupSession();
-
                $this->clearFakeUploads();
        }
 
index a9e5be2..e0487c2 100644 (file)
@@ -37,11 +37,21 @@ class RequestContextTest extends MediaWikiTestCase {
         * @covers RequestContext::importScopedSession
         */
        public function testImportScopedSession() {
+               // Make sure session handling is started
+               if ( !MediaWiki\Session\PHPSessionHandler::isInstalled() ) {
+                       MediaWiki\Session\PHPSessionHandler::install(
+                               MediaWiki\Session\SessionManager::singleton()
+                       );
+               }
+               $oldSessionId = session_id();
+
                $context = RequestContext::getMain();
 
                $oInfo = $context->exportSession();
                $this->assertEquals( '127.0.0.1', $oInfo['ip'], "Correct initial IP address." );
                $this->assertEquals( 0, $oInfo['userId'], "Correct initial user ID." );
+               $this->assertFalse( MediaWiki\Session\SessionManager::getGlobalSession()->isPersistent(),
+                       'Global session isn\'t persistent to start' );
 
                $user = User::newFromName( 'UnitTestContextUser' );
                $user->addToDatabase();
@@ -76,7 +86,16 @@ class RequestContextTest extends MediaWikiTestCase {
                        $context->getRequest()->getAllHeaders(),
                        "Correct context headers."
                );
-               $this->assertEquals( $sinfo['sessionId'], session_id(), "Correct context session ID." );
+               $this->assertEquals(
+                       $sinfo['sessionId'],
+                       MediaWiki\Session\SessionManager::getGlobalSession()->getId(),
+                       "Correct context session ID."
+               );
+               if ( \MediaWiki\Session\PhpSessionHandler::isEnabled() ) {
+                       $this->assertEquals( $sinfo['sessionId'], session_id(), "Correct context session ID." );
+               } else {
+                       $this->assertEquals( $oldSessionId, session_id(), "Unchanged PHP session ID." );
+               }
                $this->assertEquals( true, $context->getUser()->isLoggedIn(), "Correct context user." );
                $this->assertEquals( $sinfo['userId'], $context->getUser()->getId(), "Correct context user ID." );
                $this->assertEquals(
@@ -92,5 +111,7 @@ class RequestContextTest extends MediaWikiTestCase {
                $this->assertEquals( $oInfo['headers'], $info['headers'], "Correct restored headers." );
                $this->assertEquals( $oInfo['sessionId'], $info['sessionId'], "Correct restored session ID." );
                $this->assertEquals( $oInfo['userId'], $info['userId'], "Correct restored user ID." );
+               $this->assertFalse( MediaWiki\Session\SessionManager::getGlobalSession()->isPersistent(),
+                       'Global session isn\'t persistent after restoring the context' );
        }
 }
diff --git a/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php b/tests/phpunit/includes/libs/objectcache/CachedBagOStuffTest.php
new file mode 100644 (file)
index 0000000..3b19c9a
--- /dev/null
@@ -0,0 +1,52 @@
+<?php
+
+/**
+ * @group BagOStuff
+ */
+class CachedBagOStuffTest extends PHPUnit_Framework_TestCase {
+
+       public function testGetFromBackend() {
+               $backend = new HashBagOStuff;
+               $cache = new CachedBagOStuff( $backend );
+
+               $backend->set( 'foo', 'bar' );
+               $this->assertEquals( 'bar', $cache->get( 'foo' ) );
+
+               $backend->set( 'foo', 'baz' );
+               $this->assertEquals( 'bar', $cache->get( 'foo' ), 'cached' );
+       }
+
+       public function testSetAndDelete() {
+               $backend = new HashBagOStuff;
+               $cache = new CachedBagOStuff( $backend );
+
+               for ( $i = 0; $i < 10; $i++ ) {
+                       $cache->set( "key$i", 1 );
+                       $this->assertEquals( 1, $cache->get( "key$i" ) );
+                       $this->assertEquals( 1, $backend->get( "key$i" ) );
+                       $cache->delete( "key$i" );
+                       $this->assertEquals( false, $cache->get( "key$i" ) );
+                       $this->assertEquals( false, $backend->get( "key$i" ) );
+               }
+       }
+
+       public function testWriteCacheOnly() {
+               $backend = new HashBagOStuff;
+               $cache = new CachedBagOStuff( $backend );
+
+               $cache->set( 'foo', 'bar', 0, CachedBagOStuff::WRITE_CACHE_ONLY );
+               $this->assertEquals( 'bar', $cache->get( 'foo' ) );
+               $this->assertFalse( $backend->get( 'foo' ) );
+
+               $cache->set( 'foo', 'old' );
+               $this->assertEquals( 'old', $cache->get( 'foo' ) );
+               $this->assertEquals( 'old', $backend->get( 'foo' ) );
+
+               $cache->set( 'foo', 'new', 0, CachedBagOStuff::WRITE_CACHE_ONLY );
+               $this->assertEquals( 'new', $cache->get( 'foo' ) );
+               $this->assertEquals( 'old', $backend->get( 'foo' ) );
+
+               $cache->delete( 'foo', CachedBagOStuff::WRITE_CACHE_ONLY );
+               $this->assertEquals( 'old', $cache->get( 'foo' ) ); // Reloaded from backend
+       }
+}
index b940230..1ebba1a 100644 (file)
@@ -48,7 +48,7 @@ class PreprocessorTest extends MediaWikiTestCase {
                        array( "<noinclude> Foo bar </noinclude>", "<root><ignore>&lt;noinclude&gt;</ignore> Foo bar <ignore>&lt;/noinclude&gt;</ignore></root>" ),
                        array( "<noinclude>\n{{Foo}}\n</noinclude>", "<root><ignore>&lt;noinclude&gt;</ignore>\n<template lineStart=\"1\"><title>Foo</title></template>\n<ignore>&lt;/noinclude&gt;</ignore></root>" ),
                        array( "<noinclude>\n{{Foo}}\n</noinclude>\n", "<root><ignore>&lt;noinclude&gt;</ignore>\n<template lineStart=\"1\"><title>Foo</title></template>\n<ignore>&lt;/noinclude&gt;</ignore>\n</root>" ),
-                       array( "<gallery>foo bar", "<root>&lt;gallery&gt;foo bar</root>" ),
+                       array( "<gallery>foo bar", "<root><ext><name>gallery</name><attr></attr><inner>foo bar</inner></ext></root>" ),
                        array( "<{{foo}}>", "<root>&lt;<template><title>foo</title></template>&gt;</root>" ),
                        array( "<{{{foo}}}>", "<root>&lt;<tplarg><title>foo</title></tplarg>&gt;</root>" ),
                        array( "<gallery></gallery</gallery>", "<root><ext><name>gallery</name><attr></attr><inner>&lt;/gallery</inner><close>&lt;/gallery&gt;</close></ext></root>" ),
diff --git a/tests/phpunit/includes/search/SearchEnginePrefixTest.php b/tests/phpunit/includes/search/SearchEnginePrefixTest.php
new file mode 100644 (file)
index 0000000..2664fa6
--- /dev/null
@@ -0,0 +1,334 @@
+<?php
+/**
+ * @group Search
+ * @group Database
+ */
+class SearchEnginePrefixTest extends MediaWikiLangTestCase {
+
+       /**
+        * @var SearchEngine
+        */
+       private $search;
+
+       public function addDBData() {
+               if ( !$this->isWikitextNS( NS_MAIN ) ) {
+                       // tests are skipped if NS_MAIN is not wikitext
+                       return;
+               }
+
+               $this->insertPage( 'Sandbox' );
+               $this->insertPage( 'Bar' );
+               $this->insertPage( 'Example' );
+               $this->insertPage( 'Example Bar' );
+               $this->insertPage( 'Example Foo' );
+               $this->insertPage( 'Example Foo/Bar' );
+               $this->insertPage( 'Example/Baz' );
+               $this->insertPage( 'Redirect test', '#REDIRECT [[Redirect Test]]' );
+               $this->insertPage( 'Redirect Test' );
+               $this->insertPage( 'Redirect Test Worse Result' );
+               $this->insertPage( 'Redirect test2', '#REDIRECT [[Redirect Test2]]' );
+               $this->insertPage( 'Redirect TEST2', '#REDIRECT [[Redirect Test2]]' );
+               $this->insertPage( 'Redirect Test2' );
+               $this->insertPage( 'Redirect Test2 Worse Result' );
+
+               $this->insertPage( 'Talk:Sandbox' );
+               $this->insertPage( 'Talk:Example' );
+
+               $this->insertPage( 'User:Example' );
+       }
+
+       protected function setUp() {
+               parent::setUp();
+
+               if ( !$this->isWikitextNS( NS_MAIN ) ) {
+                       $this->markTestSkipped( 'Main namespace does not support wikitext.' );
+               }
+
+               // Avoid special pages from extensions interferring with the tests
+               $this->setMwGlobals( 'wgSpecialPages', array() );
+               $this->search = SearchEngine::create();
+               $this->search->setNamespaces( array() );
+       }
+
+       protected function searchProvision( Array $results = null ) {
+               if ( $results === null ) {
+                       $this->setMwGlobals( 'wgHooks', array() );
+               } else {
+                       $this->setMwGlobals( 'wgHooks', array(
+                               'PrefixSearchBackend' => array(
+                                       function ( $namespaces, $search, $limit, &$srchres ) use ( $results ) {
+                                               $srchres = $results;
+                                               return false;
+                                       }
+                               ),
+                       ) );
+               }
+       }
+
+       public static function provideSearch() {
+               return array(
+                       array( array(
+                               'Empty string',
+                               'query' => '',
+                               'results' => array(),
+                       ) ),
+                       array( array(
+                               'Main namespace with title prefix',
+                               'query' => 'Ex',
+                               'results' => array(
+                                       'Example',
+                                       'Example/Baz',
+                                       'Example Bar',
+                               ),
+                               // Third result when testing offset
+                               'offsetresult' => array(
+                                       'Example Foo',
+                               ),
+                       ) ),
+                       array( array(
+                               'Talk namespace prefix',
+                               'query' => 'Talk:',
+                               'results' => array(
+                                       'Talk:Example',
+                                       'Talk:Sandbox',
+                               ),
+                       ) ),
+                       array( array(
+                               'User namespace prefix',
+                               'query' => 'User:',
+                               'results' => array(
+                                       'User:Example',
+                               ),
+                       ) ),
+                       array( array(
+                               'Special namespace prefix',
+                               'query' => 'Special:',
+                               'results' => array(
+                                       'Special:ActiveUsers',
+                                       'Special:AllMessages',
+                                       'Special:AllMyFiles',
+                               ),
+                               // Third result when testing offset
+                               'offsetresult' => array(
+                                       'Special:AllMyUploads',
+                               ),
+                       ) ),
+                       array( array(
+                               'Special namespace with prefix',
+                               'query' => 'Special:Un',
+                               'results' => array(
+                                       'Special:Unblock',
+                                       'Special:UncategorizedCategories',
+                                       'Special:UncategorizedFiles',
+                               ),
+                               // Third result when testing offset
+                               'offsetresult' => array(
+                                       'Special:UncategorizedImages',
+                               ),
+                       ) ),
+                       array( array(
+                               'Special page name',
+                               'query' => 'Special:EditWatchlist',
+                               'results' => array(
+                                       'Special:EditWatchlist',
+                               ),
+                       ) ),
+                       array( array(
+                               'Special page subpages',
+                               'query' => 'Special:EditWatchlist/',
+                               'results' => array(
+                                       'Special:EditWatchlist/clear',
+                                       'Special:EditWatchlist/raw',
+                               ),
+                       ) ),
+                       array( array(
+                               'Special page subpages with prefix',
+                               'query' => 'Special:EditWatchlist/cl',
+                               'results' => array(
+                                       'Special:EditWatchlist/clear',
+                               ),
+                       ) ),
+               );
+       }
+
+       /**
+        * @dataProvider provideSearch
+        * @covers SearchEngine::defaultPrefixSearch
+        */
+       public function testSearch( Array $case ) {
+               $this->search->setLimitOffset( 3 );
+               $results = $this->search->defaultPrefixSearch( $case['query'] );
+               $results = array_map( function( Title $t ) {
+                       return $t->getPrefixedText();
+               }, $results );
+               $this->assertEquals(
+                       $case['results'],
+                       $results,
+                       $case[0]
+               );
+       }
+
+       /**
+        * @dataProvider provideSearch
+        * @covers SearchEngine::defaultPrefixSearch
+        */
+       public function testSearchWithOffset( Array $case ) {
+               $this->search->setLimitOffset( 3, 1 );
+               $results = $this->search->defaultPrefixSearch( $case['query'] );
+               $results = array_map( function( Title $t ) {
+                       return $t->getPrefixedText();
+               }, $results );
+
+               // We don't expect the first result when offsetting
+               array_shift( $case['results'] );
+               // And sometimes we expect a different last result
+               $expected = isset( $case['offsetresult'] ) ?
+                       array_merge( $case['results'], $case['offsetresult'] ) :
+                       $case['results'];
+
+               $this->assertEquals(
+                       $expected,
+                       $results,
+                       $case[0]
+               );
+       }
+
+       public static function provideSearchBackend() {
+               return array(
+                       array( array(
+                               'Simple case',
+                               'provision' => array(
+                                       'Bar',
+                                       'Barcelona',
+                                       'Barbara',
+                               ),
+                               'query' => 'Bar',
+                               'results' => array(
+                                       'Bar',
+                                       'Barcelona',
+                                       'Barbara',
+                               ),
+                       ) ),
+                       array( array(
+                               'Exact match not on top (bug 70958)',
+                               'provision' => array(
+                                       'Barcelona',
+                                       'Bar',
+                                       'Barbara',
+                               ),
+                               'query' => 'Bar',
+                               'results' => array(
+                                       'Bar',
+                                       'Barcelona',
+                                       'Barbara',
+                               ),
+                       ) ),
+                       array( array(
+                               'Exact match missing (bug 70958)',
+                               'provision' => array(
+                                       'Barcelona',
+                                       'Barbara',
+                                       'Bart',
+                               ),
+                               'query' => 'Bar',
+                               'results' => array(
+                                       'Bar',
+                                       'Barcelona',
+                                       'Barbara',
+                               ),
+                       ) ),
+                       array( array(
+                               'Exact match missing and not existing',
+                               'provision' => array(
+                                       'Exile',
+                                       'Exist',
+                                       'External',
+                               ),
+                               'query' => 'Ex',
+                               'results' => array(
+                                       'Exile',
+                                       'Exist',
+                                       'External',
+                               ),
+                       ) ),
+                       array( array(
+                               "Exact match shouldn't override already found match if " .
+                                       "exact is redirect and found isn't",
+                               'provision' => array(
+                                       // Target of the exact match is low in the list
+                                       'Redirect Test Worse Result',
+                                       'Redirect Test',
+                               ),
+                               'query' => 'redirect test',
+                               'results' => array(
+                                       // Redirect target is pulled up and exact match isn't added
+                                       'Redirect Test',
+                                       'Redirect Test Worse Result',
+                               ),
+                       ) ),
+                       array( array(
+                               "Exact match shouldn't override already found match if " .
+                                       "both exact match and found match are redirect",
+                               'provision' => array(
+                                       // Another redirect to the same target as the exact match
+                                       // is low in the list
+                                       'Redirect Test2 Worse Result',
+                                       'Redirect test2',
+                               ),
+                               'query' => 'redirect TEST2',
+                               'results' => array(
+                                       // Found redirect is pulled to the top and exact match isn't
+                                       // added
+                                       'Redirect test2',
+                                       'Redirect Test2 Worse Result',
+                               ),
+                       ) ),
+                       array( array(
+                               "Exact match should override any already found matches that " .
+                                       "are redirects to it",
+                               'provision' => array(
+                                       // Another redirect to the same target as the exact match
+                                       // is low in the list
+                                       'Redirect Test Worse Result',
+                                       'Redirect test',
+                               ),
+                               'query' => 'Redirect Test',
+                               'results' => array(
+                                       // Found redirect is pulled to the top and exact match isn't
+                                       // added
+                                       'Redirect Test',
+                                       'Redirect Test Worse Result',
+                                       'Redirect test',
+                               ),
+                       ) ),
+               );
+       }
+
+       /**
+        * @dataProvider provideSearchBackend
+        * @covers PrefixSearch::searchBackend
+        */
+       public function testSearchBackend( Array $case ) {
+               $search = $stub = $this->getMockBuilder( 'SearchEngine' )
+                       ->setMethods( array( 'completionSearchBackend' ) )->getMock();
+
+               $return = SearchSuggestionSet::fromStrings( $case['provision'] );
+
+               $search->expects( $this->any() )
+                       ->method( 'completionSearchBackend' )
+                       ->will( $this->returnValue( $return ) );
+
+               $search->setLimitOffset( 3 );
+               $results = $search->completionSearch( $case['query'] );
+
+               $results = $results->map( function( SearchSuggestion $s ) {
+                       return $s->getText();
+               } );
+
+               $this->assertEquals(
+                       $case['results'],
+                       $results,
+                       $case[0]
+               );
+       }
+}
diff --git a/tests/phpunit/includes/search/SearchSuggestionSetTest.php b/tests/phpunit/includes/search/SearchSuggestionSetTest.php
new file mode 100644 (file)
index 0000000..60559fc
--- /dev/null
@@ -0,0 +1,104 @@
+<?php
+
+/**
+ * Test for filter utilities.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ */
+
+class SearchSuggestionSetTest extends \PHPUnit_Framework_TestCase {
+       /**
+        * Test that adding a new suggestion at the end
+        * will keep proper score ordering
+        */
+       public function testAppend() {
+               $set = SearchSuggestionSet::emptySuggestionSet();
+               $this->assertEquals( 0, $set->getSize() );
+               $set->append( new SearchSuggestion( 3 ) );
+               $this->assertEquals( 3, $set->getWorstScore() );
+               $this->assertEquals( 3, $set->getBestScore() );
+
+               $suggestion = new SearchSuggestion( 4 );
+               $set->append( $suggestion );
+               $this->assertEquals( 2, $set->getWorstScore() );
+               $this->assertEquals( 3, $set->getBestScore() );
+               $this->assertEquals( 2, $suggestion->getScore() );
+
+               $suggestion = new SearchSuggestion( 2 );
+               $set->append( $suggestion );
+               $this->assertEquals( 1, $set->getWorstScore() );
+               $this->assertEquals( 3, $set->getBestScore() );
+               $this->assertEquals( 1, $suggestion->getScore() );
+
+               $scores = $set->map( function( $s ) {
+                       return $s->getScore();
+               } );
+               $sorted = $scores;
+               asort( $sorted );
+               $this->assertEquals( $sorted, $scores );
+       }
+
+       /**
+        * Test that adding a new best suggestion will keep proper score
+        * ordering
+        */
+       public function testInsertBest() {
+               $set = SearchSuggestionSet::emptySuggestionSet();
+               $this->assertEquals( 0, $set->getSize() );
+               $set->prepend( new SearchSuggestion( 3 ) );
+               $this->assertEquals( 3, $set->getWorstScore() );
+               $this->assertEquals( 3, $set->getBestScore() );
+
+               $suggestion = new SearchSuggestion( 4 );
+               $set->prepend( $suggestion );
+               $this->assertEquals( 3, $set->getWorstScore() );
+               $this->assertEquals( 4, $set->getBestScore() );
+               $this->assertEquals( 4, $suggestion->getScore() );
+
+               $suggestion = new SearchSuggestion( 0 );
+               $set->prepend( $suggestion );
+               $this->assertEquals( 3, $set->getWorstScore() );
+               $this->assertEquals( 5, $set->getBestScore() );
+               $this->assertEquals( 5, $suggestion->getScore() );
+
+               $suggestion = new SearchSuggestion( 2 );
+               $set->prepend( $suggestion );
+               $this->assertEquals( 3, $set->getWorstScore() );
+               $this->assertEquals( 6, $set->getBestScore() );
+               $this->assertEquals( 6, $suggestion->getScore() );
+
+               $scores = $set->map( function( $s ) {
+                       return $s->getScore();
+               } );
+               $sorted = $scores;
+               asort( $sorted );
+               $this->assertEquals( $sorted, $scores );
+       }
+
+       public function testShrink() {
+               $set = SearchSuggestionSet::emptySuggestionSet();
+               for ( $i = 0; $i < 100; $i++ ) {
+                       $set->append( new SearchSuggestion( 0 ) );
+               }
+               $set->shrink( 10 );
+               $this->assertEquals( 10, $set->getSize() );
+
+               $set->shrink( 0 );
+               $this->assertEquals( 0, $set->getSize() );
+       }
+
+       // TODO: test for fromTitles
+}
diff --git a/tests/phpunit/includes/session/BotPasswordSessionProviderTest.php b/tests/phpunit/includes/session/BotPasswordSessionProviderTest.php
new file mode 100644 (file)
index 0000000..52872a4
--- /dev/null
@@ -0,0 +1,288 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LogLevel;
+use MediaWikiTestCase;
+use User;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\BotPasswordSessionProvider
+ */
+class BotPasswordSessionProviderTest extends MediaWikiTestCase {
+
+       private $config;
+
+       private function getProvider( $name = null, $prefix = null ) {
+               global $wgSessionProviders;
+
+               $params = array(
+                       'priority' => 40,
+                       'sessionCookieName' => $name,
+                       'sessionCookieOptions' => array(),
+               );
+               if ( $prefix !== null ) {
+                       $params['sessionCookieOptions']['prefix'] = $prefix;
+               }
+
+               if ( !$this->config ) {
+                       $this->config = new \HashConfig( array(
+                               'CookiePrefix' => 'wgCookiePrefix',
+                               'EnableBotPasswords' => true,
+                               'BotPasswordsDatabase' => false,
+                               'SessionProviders' => $wgSessionProviders + array(
+                                       'MediaWiki\\Session\\BotPasswordSessionProvider' => array(
+                                               'class' => 'MediaWiki\\Session\\BotPasswordSessionProvider',
+                                               'args' => array( $params ),
+                                       )
+                               ),
+                       ) );
+               }
+               $manager = new SessionManager( array(
+                       'config' => new \MultiConfig( array( $this->config, \RequestContext::getMain()->getConfig() ) ),
+                       'logger' => new \Psr\Log\NullLogger,
+                       'store' => new TestBagOStuff,
+               ) );
+
+               return $manager->getProvider( 'MediaWiki\\Session\\BotPasswordSessionProvider' );
+       }
+
+       protected function setUp() {
+               parent::setUp();
+
+               $this->setMwGlobals( array(
+                       'wgEnableBotPasswords' => true,
+                       'wgBotPasswordsDatabase' => false,
+                       'wgCentralIdLookupProvider' => 'local',
+                       'wgGrantPermissions' => array(
+                               'test' => array( 'read' => true ),
+                       ),
+               ) );
+       }
+
+       public function addDBData() {
+               $passwordFactory = new \PasswordFactory();
+               $passwordFactory->init( \RequestContext::getMain()->getConfig() );
+               // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only
+               $passwordFactory->setDefaultType( 'A' );
+               $pwhash = $passwordFactory->newFromPlaintext( 'foobaz' );
+
+               $userId = \CentralIdLookup::factory( 'local' )->centralIdFromName( 'UTSysop' );
+
+               $dbw = wfGetDB( DB_MASTER );
+               $dbw->delete(
+                       'bot_passwords',
+                       array( 'bp_user' => $userId, 'bp_app_id' => 'BotPasswordSessionProvider' ),
+                       __METHOD__
+               );
+               $dbw->insert(
+                       'bot_passwords',
+                       array(
+                               'bp_user' => $userId,
+                               'bp_app_id' => 'BotPasswordSessionProvider',
+                               'bp_password' => $pwhash->toString(),
+                               'bp_token' => 'token!',
+                               'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}',
+                               'bp_grants' => '["test"]',
+                       ),
+                       __METHOD__
+               );
+       }
+
+       public function testConstructor() {
+               try {
+                       $provider = new BotPasswordSessionProvider();
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'MediaWiki\\Session\\BotPasswordSessionProvider::__construct: priority must be specified',
+                               $ex->getMessage()
+                       );
+               }
+
+               try {
+                       $provider = new BotPasswordSessionProvider( array(
+                               'priority' => SessionInfo::MIN_PRIORITY - 1
+                       ) );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'MediaWiki\\Session\\BotPasswordSessionProvider::__construct: Invalid priority',
+                               $ex->getMessage()
+                       );
+               }
+
+               try {
+                       $provider = new BotPasswordSessionProvider( array(
+                               'priority' => SessionInfo::MAX_PRIORITY + 1
+                       ) );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'MediaWiki\\Session\\BotPasswordSessionProvider::__construct: Invalid priority',
+                               $ex->getMessage()
+                       );
+               }
+
+               $provider = new BotPasswordSessionProvider( array(
+                       'priority' => 40
+               ) );
+               $priv = \TestingAccessWrapper::newFromObject( $provider );
+               $this->assertSame( 40, $priv->priority );
+               $this->assertSame( '_BPsession', $priv->sessionCookieName );
+               $this->assertSame( array(), $priv->sessionCookieOptions );
+
+               $provider = new BotPasswordSessionProvider( array(
+                       'priority' => 40,
+                       'sessionCookieName' => null,
+               ) );
+               $priv = \TestingAccessWrapper::newFromObject( $provider );
+               $this->assertSame( '_BPsession', $priv->sessionCookieName );
+
+               $provider = new BotPasswordSessionProvider( array(
+                       'priority' => 40,
+                       'sessionCookieName' => 'Foo',
+                       'sessionCookieOptions' => array( 'Bar' ),
+               ) );
+               $priv = \TestingAccessWrapper::newFromObject( $provider );
+               $this->assertSame( 'Foo', $priv->sessionCookieName );
+               $this->assertSame( array( 'Bar' ), $priv->sessionCookieOptions );
+       }
+
+       public function testBasics() {
+               $provider = $this->getProvider();
+
+               $this->assertTrue( $provider->persistsSessionID() );
+               $this->assertFalse( $provider->canChangeUser() );
+
+               $this->assertNull( $provider->newSessionInfo() );
+               $this->assertNull( $provider->newSessionInfo( 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' ) );
+       }
+
+       public function testProvideSessionInfo() {
+               $provider = $this->getProvider();
+               $request = new \FauxRequest;
+               $request->setCookie( '_BPsession', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'wgCookiePrefix' );
+
+               if ( !defined( 'MW_API' ) ) {
+                       $this->assertNull( $provider->provideSessionInfo( $request ) );
+                       define( 'MW_API', 1 );
+               }
+
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\SessionInfo', $info );
+               $this->assertSame( 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $info->getId() );
+
+               $this->config->set( 'EnableBotPasswords', false );
+               $this->assertNull( $provider->provideSessionInfo( $request ) );
+               $this->config->set( 'EnableBotPasswords', true );
+
+               $this->assertNull( $provider->provideSessionInfo( new \FauxRequest ) );
+       }
+
+       public function testNewSessionInfoForRequest() {
+               $provider = $this->getProvider();
+               $user = \User::newFromName( 'UTSysop' );
+               $request = $this->getMock( 'FauxRequest', array( 'getIP' ) );
+               $request->expects( $this->any() )->method( 'getIP' )
+                       ->will( $this->returnValue( '127.0.0.1' ) );
+               $bp = \BotPassword::newFromUser( $user, 'BotPasswordSessionProvider' );
+
+               $session = $provider->newSessionForRequest( $user, $bp, $request );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+
+               $this->assertEquals( $session->getId(), $request->getSession()->getId() );
+               $this->assertEquals( $user->getName(), $session->getUser()->getName() );
+
+               $this->assertEquals( array(
+                       'centralId' => $bp->getUserCentralId(),
+                       'appId' => $bp->getAppId(),
+                       'token' => $bp->getToken(),
+                       'rights' => array( 'read' ),
+               ), $session->getProviderMetadata() );
+
+               $this->assertEquals( array( 'read' ), $session->getAllowedUserRights() );
+       }
+
+       public function testCheckSessionInfo() {
+               $logger = new \TestLogger( true, function ( $m ) {
+                       return preg_replace(
+                               '/^Session \[\d+\][a-zA-Z0-9_\\\\]+<(?:null|anon|[+-]:\d+:\w+)>\w+: /', 'Session X: ', $m
+                       );
+               } );
+               $provider = $this->getProvider();
+               $provider->setLogger( $logger );
+
+               $user = \User::newFromName( 'UTSysop' );
+               $request = $this->getMock( 'FauxRequest', array( 'getIP' ) );
+               $request->expects( $this->any() )->method( 'getIP' )
+                       ->will( $this->returnValue( '127.0.0.1' ) );
+               $bp = \BotPassword::newFromUser( $user, 'BotPasswordSessionProvider' );
+
+               $data = array(
+                       'provider' => $provider,
+                       'id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+                       'userInfo' => UserInfo::newFromUser( $user, true ),
+                       'persisted' => false,
+                       'metadata' => array(
+                               'centralId' => $bp->getUserCentralId(),
+                               'appId' => $bp->getAppId(),
+                               'token' => $bp->getToken(),
+                       ),
+               );
+               $dataMD = $data['metadata'];
+
+               foreach ( array_keys( $data['metadata'] ) as $key ) {
+                       $data['metadata'] = $dataMD;
+                       unset( $data['metadata'][$key] );
+                       $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
+                       $metadata = $info->getProviderMetadata();
+
+                       $this->assertFalse( $provider->refreshSessionInfo( $info, $request, $metadata ) );
+                       $this->assertSame( array(
+                               array( LogLevel::INFO, "Session X: Missing metadata: $key" )
+                       ), $logger->getBuffer() );
+                       $logger->clearBuffer();
+               }
+
+               $data['metadata'] = $dataMD;
+               $data['metadata']['appId'] = 'Foobar';
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
+               $metadata = $info->getProviderMetadata();
+               $this->assertFalse( $provider->refreshSessionInfo( $info, $request, $metadata ) );
+               $this->assertSame( array(
+                       array( LogLevel::INFO, "Session X: No BotPassword for {$bp->getUserCentralId()} Foobar" ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               $data['metadata'] = $dataMD;
+               $data['metadata']['token'] = 'Foobar';
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
+               $metadata = $info->getProviderMetadata();
+               $this->assertFalse( $provider->refreshSessionInfo( $info, $request, $metadata ) );
+               $this->assertSame( array(
+                       array( LogLevel::INFO, 'Session X: BotPassword token check failed' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               $request2 = $this->getMock( 'FauxRequest', array( 'getIP' ) );
+               $request2->expects( $this->any() )->method( 'getIP' )
+                       ->will( $this->returnValue( '10.0.0.1' ) );
+               $data['metadata'] = $dataMD;
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
+               $metadata = $info->getProviderMetadata();
+               $this->assertFalse( $provider->refreshSessionInfo( $info, $request2, $metadata ) );
+               $this->assertSame( array(
+                       array( LogLevel::INFO, 'Session X: Restrictions check failed' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
+               $metadata = $info->getProviderMetadata();
+               $this->assertTrue( $provider->refreshSessionInfo( $info, $request, $metadata ) );
+               $this->assertSame( array(), $logger->getBuffer() );
+               $this->assertEquals( $dataMD + array( 'rights' => array( 'read' ) ), $metadata );
+       }
+}
diff --git a/tests/phpunit/includes/session/CookieSessionProviderTest.php b/tests/phpunit/includes/session/CookieSessionProviderTest.php
new file mode 100644 (file)
index 0000000..9ba67df
--- /dev/null
@@ -0,0 +1,728 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+use User;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\CookieSessionProvider
+ */
+class CookieSessionProviderTest extends MediaWikiTestCase {
+
+       private function getConfig() {
+               global $wgCookieExpiration;
+               return new \HashConfig( array(
+                       'CookiePrefix' => 'CookiePrefix',
+                       'CookiePath' => 'CookiePath',
+                       'CookieDomain' => 'CookieDomain',
+                       'CookieSecure' => true,
+                       'CookieHttpOnly' => true,
+                       'SessionName' => false,
+                       'ExtendedLoginCookies' => array( 'UserID', 'Token' ),
+                       'ExtendedLoginCookieExpiration' => $wgCookieExpiration * 2,
+               ) );
+       }
+
+       public function testConstructor() {
+               try {
+                       new CookieSessionProvider();
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'MediaWiki\\Session\\CookieSessionProvider::__construct: priority must be specified',
+                               $ex->getMessage()
+                       );
+               }
+
+               try {
+                       new CookieSessionProvider( array( 'priority' => 'foo' ) );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'MediaWiki\\Session\\CookieSessionProvider::__construct: Invalid priority',
+                               $ex->getMessage()
+                       );
+               }
+               try {
+                       new CookieSessionProvider( array( 'priority' => SessionInfo::MIN_PRIORITY - 1 ) );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'MediaWiki\\Session\\CookieSessionProvider::__construct: Invalid priority',
+                               $ex->getMessage()
+                       );
+               }
+               try {
+                       new CookieSessionProvider( array( 'priority' => SessionInfo::MAX_PRIORITY + 1 ) );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'MediaWiki\\Session\\CookieSessionProvider::__construct: Invalid priority',
+                               $ex->getMessage()
+                       );
+               }
+
+               try {
+                       new CookieSessionProvider( array( 'priority' => 1, 'cookieOptions' => null ) );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'MediaWiki\\Session\\CookieSessionProvider::__construct: cookieOptions must be an array',
+                               $ex->getMessage()
+                       );
+               }
+
+               $config = $this->getConfig();
+               $p = \TestingAccessWrapper::newFromObject(
+                       new CookieSessionProvider( array( 'priority' => 1 ) )
+               );
+               $p->setLogger( new \TestLogger() );
+               $p->setConfig( $config );
+               $this->assertEquals( 1, $p->priority );
+               $this->assertEquals( array(
+                       'callUserSetCookiesHook' => false,
+                       'sessionName' => 'CookiePrefix_session',
+               ), $p->params );
+               $this->assertEquals( array(
+                       'prefix' => 'CookiePrefix',
+                       'path' => 'CookiePath',
+                       'domain' => 'CookieDomain',
+                       'secure' => true,
+                       'httpOnly' => true,
+               ), $p->cookieOptions );
+
+               $config->set( 'SessionName', 'SessionName' );
+               $p = \TestingAccessWrapper::newFromObject(
+                       new CookieSessionProvider( array( 'priority' => 3 ) )
+               );
+               $p->setLogger( new \TestLogger() );
+               $p->setConfig( $config );
+               $this->assertEquals( 3, $p->priority );
+               $this->assertEquals( array(
+                       'callUserSetCookiesHook' => false,
+                       'sessionName' => 'SessionName',
+               ), $p->params );
+               $this->assertEquals( array(
+                       'prefix' => 'CookiePrefix',
+                       'path' => 'CookiePath',
+                       'domain' => 'CookieDomain',
+                       'secure' => true,
+                       'httpOnly' => true,
+               ), $p->cookieOptions );
+
+               $p = \TestingAccessWrapper::newFromObject( new CookieSessionProvider( array(
+                       'priority' => 10,
+                       'callUserSetCookiesHook' => true,
+                       'cookieOptions' => array(
+                               'prefix' => 'XPrefix',
+                               'path' => 'XPath',
+                               'domain' => 'XDomain',
+                               'secure' => 'XSecure',
+                               'httpOnly' => 'XHttpOnly',
+                       ),
+                       'sessionName' => 'XSession',
+               ) ) );
+               $p->setLogger( new \TestLogger() );
+               $p->setConfig( $config );
+               $this->assertEquals( 10, $p->priority );
+               $this->assertEquals( array(
+                       'callUserSetCookiesHook' => true,
+                       'sessionName' => 'XSession',
+               ), $p->params );
+               $this->assertEquals( array(
+                       'prefix' => 'XPrefix',
+                       'path' => 'XPath',
+                       'domain' => 'XDomain',
+                       'secure' => 'XSecure',
+                       'httpOnly' => 'XHttpOnly',
+               ), $p->cookieOptions );
+       }
+
+       public function testBasics() {
+               $provider = new CookieSessionProvider( array( 'priority' => 10 ) );
+
+               $this->assertTrue( $provider->persistsSessionID() );
+               $this->assertTrue( $provider->canChangeUser() );
+
+               $msg = $provider->whyNoSession();
+               $this->assertInstanceOf( 'Message', $msg );
+               $this->assertSame( 'sessionprovider-nocookies', $msg->getKey() );
+       }
+
+       public function testProvideSessionInfo() {
+               $params = array(
+                       'priority' => 20,
+                       'sessionName' => 'session',
+                       'cookieOptions' => array( 'prefix' => 'x' ),
+               );
+               $provider = new CookieSessionProvider( $params );
+               $provider->setLogger( new \TestLogger() );
+               $provider->setConfig( $this->getConfig() );
+               $provider->setManager( new SessionManager() );
+
+               $user = User::newFromName( 'UTSysop' );
+               $id = $user->getId();
+               $name = $user->getName();
+               $token = $user->getToken( true );
+
+               $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+
+               // No data
+               $request = new \FauxRequest();
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertNull( $info );
+
+               // Session key only
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       'session' => $sessionId,
+               ), '' );
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertNotNull( $info );
+               $this->assertSame( $params['priority'], $info->getPriority() );
+               $this->assertSame( $sessionId, $info->getId() );
+               $this->assertNotNull( $info->getUserInfo() );
+               $this->assertSame( 0, $info->getUserInfo()->getId() );
+               $this->assertNull( $info->getUserInfo()->getName() );
+               $this->assertFalse( $info->forceHTTPS() );
+
+               // User, no session key
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       'xUserID' => $id,
+                       'xToken' => $token,
+               ), '' );
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertNotNull( $info );
+               $this->assertSame( $params['priority'], $info->getPriority() );
+               $this->assertNotSame( $sessionId, $info->getId() );
+               $this->assertNotNull( $info->getUserInfo() );
+               $this->assertSame( $id, $info->getUserInfo()->getId() );
+               $this->assertSame( $name, $info->getUserInfo()->getName() );
+               $this->assertFalse( $info->forceHTTPS() );
+
+               // User and session key
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       'session' => $sessionId,
+                       'xUserID' => $id,
+                       'xToken' => $token,
+               ), '' );
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertNotNull( $info );
+               $this->assertSame( $params['priority'], $info->getPriority() );
+               $this->assertSame( $sessionId, $info->getId() );
+               $this->assertNotNull( $info->getUserInfo() );
+               $this->assertSame( $id, $info->getUserInfo()->getId() );
+               $this->assertSame( $name, $info->getUserInfo()->getName() );
+               $this->assertFalse( $info->forceHTTPS() );
+
+               // User with bad token
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       'session' => $sessionId,
+                       'xUserID' => $id,
+                       'xToken' => 'BADTOKEN',
+               ), '' );
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertNull( $info );
+
+               // User id with no token
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       'session' => $sessionId,
+                       'xUserID' => $id,
+               ), '' );
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertNotNull( $info );
+               $this->assertSame( $params['priority'], $info->getPriority() );
+               $this->assertSame( $sessionId, $info->getId() );
+               $this->assertNotNull( $info->getUserInfo() );
+               $this->assertFalse( $info->getUserInfo()->isVerified() );
+               $this->assertSame( $id, $info->getUserInfo()->getId() );
+               $this->assertSame( $name, $info->getUserInfo()->getName() );
+               $this->assertFalse( $info->forceHTTPS() );
+
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       'xUserID' => $id,
+               ), '' );
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertNull( $info );
+
+               // User and session key, with forceHTTPS flag
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       'session' => $sessionId,
+                       'xUserID' => $id,
+                       'xToken' => $token,
+                       'forceHTTPS' => true,
+               ), '' );
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertNotNull( $info );
+               $this->assertSame( $params['priority'], $info->getPriority() );
+               $this->assertSame( $sessionId, $info->getId() );
+               $this->assertNotNull( $info->getUserInfo() );
+               $this->assertSame( $id, $info->getUserInfo()->getId() );
+               $this->assertSame( $name, $info->getUserInfo()->getName() );
+               $this->assertTrue( $info->forceHTTPS() );
+
+               // Invalid user id
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       'session' => $sessionId,
+                       'xUserID' => '-1',
+               ), '' );
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertNull( $info );
+
+               // User id with matching name
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       'session' => $sessionId,
+                       'xUserID' => $id,
+                       'xUserName' => $name,
+               ), '' );
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertNotNull( $info );
+               $this->assertSame( $params['priority'], $info->getPriority() );
+               $this->assertSame( $sessionId, $info->getId() );
+               $this->assertNotNull( $info->getUserInfo() );
+               $this->assertFalse( $info->getUserInfo()->isVerified() );
+               $this->assertSame( $id, $info->getUserInfo()->getId() );
+               $this->assertSame( $name, $info->getUserInfo()->getName() );
+               $this->assertFalse( $info->forceHTTPS() );
+
+               // User id with wrong name
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       'session' => $sessionId,
+                       'xUserID' => $id,
+                       'xUserName' => 'Wrong',
+               ), '' );
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertNull( $info );
+       }
+
+       public function testGetVaryCookies() {
+               $provider = new CookieSessionProvider( array(
+                       'priority' => 1,
+                       'sessionName' => 'MySessionName',
+                       'cookieOptions' => array( 'prefix' => 'MyCookiePrefix' ),
+               ) );
+               $this->assertArrayEquals( array(
+                       'MyCookiePrefixToken',
+                       'MyCookiePrefixLoggedOut',
+                       'MySessionName',
+                       'forceHTTPS',
+               ), $provider->getVaryCookies() );
+       }
+
+       public function testSuggestLoginUsername() {
+               $provider = new CookieSessionProvider( array(
+                       'priority' => 1,
+                       'sessionName' => 'MySessionName',
+                       'cookieOptions' => array( 'prefix' => 'x' ),
+               ) );
+
+               $request = new \FauxRequest();
+               $this->assertEquals( null, $provider->suggestLoginUsername( $request ) );
+
+               $request->setCookies( array(
+                       'xUserName' => 'Example',
+               ), '' );
+               $this->assertEquals( 'Example', $provider->suggestLoginUsername( $request ) );
+       }
+
+       public function testPersistSession() {
+               $this->setMwGlobals( array( 'wgCookieExpiration' => 100 ) );
+
+               $provider = new CookieSessionProvider( array(
+                       'priority' => 1,
+                       'sessionName' => 'MySessionName',
+                       'callUserSetCookiesHook' => false,
+                       'cookieOptions' => array( 'prefix' => 'x' ),
+               ) );
+               $config = $this->getConfig();
+               $provider->setLogger( new \TestLogger() );
+               $provider->setConfig( $config );
+               $provider->setManager( SessionManager::singleton() );
+
+               $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+               $store = new TestBagOStuff();
+               $user = User::newFromName( 'UTSysop' );
+               $anon = new User;
+
+               $backend = new SessionBackend(
+                       new SessionId( $sessionId ),
+                       new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                               'provider' => $provider,
+                               'id' => $sessionId,
+                               'persisted' => true,
+                               'idIsSafe' => true,
+                       ) ),
+                       $store,
+                       new \Psr\Log\NullLogger(),
+                       10
+               );
+               \TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false;
+
+               $mock = $this->getMock( 'stdClass', array( 'onUserSetCookies' ) );
+               $mock->expects( $this->never() )->method( 'onUserSetCookies' );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'UserSetCookies' => array( $mock ) ) );
+
+               // Anonymous user
+               $backend->setUser( $anon );
+               $backend->setRememberUser( true );
+               $backend->setForceHTTPS( false );
+               $request = new \FauxRequest();
+               $provider->persistSession( $backend, $request );
+               $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) );
+               $this->assertSame( '', $request->response()->getCookie( 'xUserID' ) );
+               $this->assertSame( null, $request->response()->getCookie( 'xUserName' ) );
+               $this->assertSame( '', $request->response()->getCookie( 'xToken' ) );
+               $this->assertSame( '', $request->response()->getCookie( 'forceHTTPS' ) );
+               $this->assertSame( array(), $backend->getData() );
+
+               // Logged-in user, no remember
+               $backend->setUser( $user );
+               $backend->setRememberUser( false );
+               $backend->setForceHTTPS( false );
+               $request = new \FauxRequest();
+               $provider->persistSession( $backend, $request );
+               $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) );
+               $this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) );
+               $this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) );
+               $this->assertSame( '', $request->response()->getCookie( 'xToken' ) );
+               $this->assertSame( '', $request->response()->getCookie( 'forceHTTPS' ) );
+               $this->assertSame( array(), $backend->getData() );
+
+               // Logged-in user, remember
+               $backend->setUser( $user );
+               $backend->setRememberUser( true );
+               $backend->setForceHTTPS( true );
+               $request = new \FauxRequest();
+               $time = time();
+               $provider->persistSession( $backend, $request );
+               $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) );
+               $this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) );
+               $this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) );
+               $this->assertSame( $user->getToken(), $request->response()->getCookie( 'xToken' ) );
+               $this->assertSame( 'true', $request->response()->getCookie( 'forceHTTPS' ) );
+               $this->assertSame( array(), $backend->getData() );
+       }
+
+       /**
+        * @dataProvider provideCookieData
+        * @param bool $secure
+        * @param bool $remember
+        */
+       public function testCookieData( $secure, $remember ) {
+               $this->setMwGlobals( array(
+                       'wgCookieExpiration' => 100,
+                       'wgSecureLogin' => false,
+               ) );
+
+               $provider = new CookieSessionProvider( array(
+                       'priority' => 1,
+                       'sessionName' => 'MySessionName',
+                       'callUserSetCookiesHook' => false,
+                       'cookieOptions' => array( 'prefix' => 'x' ),
+               ) );
+               $config = $this->getConfig();
+               $config->set( 'CookieSecure', $secure );
+               $provider->setLogger( new \TestLogger() );
+               $provider->setConfig( $config );
+               $provider->setManager( SessionManager::singleton() );
+
+               $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+               $user = User::newFromName( 'UTSysop' );
+               $this->assertFalse( $user->requiresHTTPS(), 'sanity check' );
+
+               $backend = new SessionBackend(
+                       new SessionId( $sessionId ),
+                       new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                               'provider' => $provider,
+                               'id' => $sessionId,
+                               'persisted' => true,
+                               'idIsSafe' => true,
+                       ) ),
+                       new TestBagOStuff(),
+                       new \Psr\Log\NullLogger(),
+                       10
+               );
+               \TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false;
+               $backend->setUser( $user );
+               $backend->setRememberUser( $remember );
+               $backend->setForceHTTPS( $secure );
+               $request = new \FauxRequest();
+               $time = time();
+               $provider->persistSession( $backend, $request );
+
+               $defaults = array(
+                       'expire' => (int)100,
+                       'path' => $config->get( 'CookiePath' ),
+                       'domain' => $config->get( 'CookieDomain' ),
+                       'secure' => $secure,
+                       'httpOnly' => $config->get( 'CookieHttpOnly' ),
+                       'raw' => false,
+               );
+               $extendedExpiry = $config->get( 'ExtendedLoginCookieExpiration' );
+               $extendedExpiry = (int)( $extendedExpiry === null ? 0 : $extendedExpiry );
+               $this->assertEquals( array( 'UserID', 'Token' ), $config->get( 'ExtendedLoginCookies' ),
+                       'sanity check' );
+               $expect = array(
+                       'MySessionName' => array(
+                               'value' => (string)$sessionId,
+                               'expire' => 0,
+                       ) + $defaults,
+                       'xUserID' => array(
+                               'value' => (string)$user->getId(),
+                               'expire' => $extendedExpiry,
+                       ) + $defaults,
+                       'xUserName' => array(
+                               'value' => $user->getName(),
+                       ) + $defaults,
+                       'xToken' => array(
+                               'value' => $remember ? $user->getToken() : '',
+                               'expire' => $remember ? $extendedExpiry : -31536000,
+                       ) + $defaults,
+                       'forceHTTPS' => array(
+                               'value' => $secure ? 'true' : '',
+                               'secure' => false,
+                               'expire' => $secure ? $remember ? $defaults['expire'] : 0 : -31536000,
+                       ) + $defaults,
+               );
+               foreach ( $expect as $key => $value ) {
+                       $actual = $request->response()->getCookieData( $key );
+                       if ( $actual && $actual['expire'] > 0 ) {
+                               // Round expiry so we don't randomly fail if the seconds ticked during the test.
+                               $actual['expire'] = round( $actual['expire'] - $time, -2 );
+                       }
+                       $this->assertEquals( $value, $actual, "Cookie $key" );
+               }
+       }
+
+       public static function provideCookieData() {
+               return array(
+                       array( false, false ),
+                       array( false, true ),
+                       array( true, false ),
+                       array( true, true ),
+               );
+       }
+
+       protected function getSentRequest() {
+               $sentResponse = $this->getMock( 'FauxResponse', array( 'headersSent', 'setCookie', 'header' ) );
+               $sentResponse->expects( $this->any() )->method( 'headersSent' )
+                       ->will( $this->returnValue( true ) );
+               $sentResponse->expects( $this->never() )->method( 'setCookie' );
+               $sentResponse->expects( $this->never() )->method( 'header' );
+
+               $sentRequest = $this->getMock( 'FauxRequest', array( 'response' ) );
+               $sentRequest->expects( $this->any() )->method( 'response' )
+                       ->will( $this->returnValue( $sentResponse ) );
+               return $sentRequest;
+       }
+
+       public function testPersistSessionWithHook() {
+               $that = $this;
+
+               $provider = new CookieSessionProvider( array(
+                       'priority' => 1,
+                       'sessionName' => 'MySessionName',
+                       'callUserSetCookiesHook' => true,
+                       'cookieOptions' => array( 'prefix' => 'x' ),
+               ) );
+               $provider->setLogger( new \Psr\Log\NullLogger() );
+               $provider->setConfig( $this->getConfig() );
+               $provider->setManager( SessionManager::singleton() );
+
+               $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+               $store = new TestBagOStuff();
+               $user = User::newFromName( 'UTSysop' );
+               $anon = new User;
+
+               $backend = new SessionBackend(
+                       new SessionId( $sessionId ),
+                       new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                               'provider' => $provider,
+                               'id' => $sessionId,
+                               'persisted' => true,
+                               'idIsSafe' => true,
+                       ) ),
+                       $store,
+                       new \Psr\Log\NullLogger(),
+                       10
+               );
+               \TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false;
+
+               // Anonymous user
+               $mock = $this->getMock( 'stdClass', array( 'onUserSetCookies' ) );
+               $mock->expects( $this->never() )->method( 'onUserSetCookies' );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'UserSetCookies' => array( $mock ) ) );
+               $backend->setUser( $anon );
+               $backend->setRememberUser( true );
+               $backend->setForceHTTPS( false );
+               $request = new \FauxRequest();
+               $provider->persistSession( $backend, $request );
+               $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) );
+               $this->assertSame( '', $request->response()->getCookie( 'xUserID' ) );
+               $this->assertSame( null, $request->response()->getCookie( 'xUserName' ) );
+               $this->assertSame( '', $request->response()->getCookie( 'xToken' ) );
+               $this->assertSame( '', $request->response()->getCookie( 'forceHTTPS' ) );
+               $this->assertSame( array(), $backend->getData() );
+
+               $provider->persistSession( $backend, $this->getSentRequest() );
+
+               // Logged-in user, no remember
+               $mock = $this->getMock( __CLASS__, array( 'onUserSetCookies' ) );
+               $mock->expects( $this->once() )->method( 'onUserSetCookies' )
+                       ->will( $this->returnCallback( function ( $u, &$sessionData, &$cookies ) use ( $that, $user ) {
+                               $that->assertSame( $user, $u );
+                               $that->assertEquals( array(
+                                       'wsUserID' => $user->getId(),
+                                       'wsUserName' => $user->getName(),
+                                       'wsToken' => $user->getToken(),
+                               ), $sessionData );
+                               $that->assertEquals( array(
+                                       'UserID' => $user->getId(),
+                                       'UserName' => $user->getName(),
+                                       'Token' => false,
+                               ), $cookies );
+
+                               $sessionData['foo'] = 'foo!';
+                               $cookies['bar'] = 'bar!';
+                               return true;
+                       } ) );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'UserSetCookies' => array( $mock ) ) );
+               $backend->setUser( $user );
+               $backend->setRememberUser( false );
+               $backend->setForceHTTPS( false );
+               $backend->setLoggedOutTimestamp( $loggedOut = time() );
+               $request = new \FauxRequest();
+               $provider->persistSession( $backend, $request );
+               $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) );
+               $this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) );
+               $this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) );
+               $this->assertSame( '', $request->response()->getCookie( 'xToken' ) );
+               $this->assertSame( '', $request->response()->getCookie( 'forceHTTPS' ) );
+               $this->assertSame( 'bar!', $request->response()->getCookie( 'xbar' ) );
+               $this->assertSame( (string)$loggedOut, $request->response()->getCookie( 'xLoggedOut' ) );
+               $this->assertEquals( array(
+                       'wsUserID' => $user->getId(),
+                       'wsUserName' => $user->getName(),
+                       'wsToken' => $user->getToken(),
+                       'foo' => 'foo!',
+               ), $backend->getData() );
+
+               $provider->persistSession( $backend, $this->getSentRequest() );
+
+               // Logged-in user, remember
+               $mock = $this->getMock( __CLASS__, array( 'onUserSetCookies' ) );
+               $mock->expects( $this->once() )->method( 'onUserSetCookies' )
+                       ->will( $this->returnCallback( function ( $u, &$sessionData, &$cookies ) use ( $that, $user ) {
+                               $that->assertSame( $user, $u );
+                               $that->assertEquals( array(
+                                       'wsUserID' => $user->getId(),
+                                       'wsUserName' => $user->getName(),
+                                       'wsToken' => $user->getToken(),
+                               ), $sessionData );
+                               $that->assertEquals( array(
+                                       'UserID' => $user->getId(),
+                                       'UserName' => $user->getName(),
+                                       'Token' => $user->getToken(),
+                               ), $cookies );
+
+                               $sessionData['foo'] = 'foo 2!';
+                               $cookies['bar'] = 'bar 2!';
+                               return true;
+                       } ) );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'UserSetCookies' => array( $mock ) ) );
+               $backend->setUser( $user );
+               $backend->setRememberUser( true );
+               $backend->setForceHTTPS( true );
+               $backend->setLoggedOutTimestamp( 0 );
+               $request = new \FauxRequest();
+               $provider->persistSession( $backend, $request );
+               $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) );
+               $this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) );
+               $this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) );
+               $this->assertSame( $user->getToken(), $request->response()->getCookie( 'xToken' ) );
+               $this->assertSame( 'true', $request->response()->getCookie( 'forceHTTPS' ) );
+               $this->assertSame( 'bar 2!', $request->response()->getCookie( 'xbar' ) );
+               $this->assertSame( null, $request->response()->getCookie( 'xLoggedOut' ) );
+               $this->assertEquals( array(
+                       'wsUserID' => $user->getId(),
+                       'wsUserName' => $user->getName(),
+                       'wsToken' => $user->getToken(),
+                       'foo' => 'foo 2!',
+               ), $backend->getData() );
+
+               $provider->persistSession( $backend, $this->getSentRequest() );
+       }
+
+       public function testUnpersistSession() {
+               $provider = new CookieSessionProvider( array(
+                       'priority' => 1,
+                       'sessionName' => 'MySessionName',
+                       'cookieOptions' => array( 'prefix' => 'x' ),
+               ) );
+               $provider->setLogger( new \Psr\Log\NullLogger() );
+               $provider->setConfig( $this->getConfig() );
+               $provider->setManager( SessionManager::singleton() );
+
+               $request = new \FauxRequest();
+               $provider->unpersistSession( $request );
+               $this->assertSame( '', $request->response()->getCookie( 'MySessionName' ) );
+               $this->assertSame( '', $request->response()->getCookie( 'xUserID' ) );
+               $this->assertSame( null, $request->response()->getCookie( 'xUserName' ) );
+               $this->assertSame( '', $request->response()->getCookie( 'xToken' ) );
+               $this->assertSame( '', $request->response()->getCookie( 'forceHTTPS' ) );
+
+               $provider->unpersistSession( $this->getSentRequest() );
+       }
+
+       public function testSetLoggedOutCookie() {
+               $provider = \TestingAccessWrapper::newFromObject( new CookieSessionProvider( array(
+                       'priority' => 1,
+                       'sessionName' => 'MySessionName',
+                       'cookieOptions' => array( 'prefix' => 'x' ),
+               ) ) );
+               $provider->setLogger( new \Psr\Log\NullLogger() );
+               $provider->setConfig( $this->getConfig() );
+               $provider->setManager( SessionManager::singleton() );
+
+               $t1 = time();
+               $t2 = time() - 86400 * 2;
+
+               // Set it
+               $request = new \FauxRequest();
+               $provider->setLoggedOutCookie( $t1, $request );
+               $this->assertSame( (string)$t1, $request->response()->getCookie( 'xLoggedOut' ) );
+
+               // Too old
+               $request = new \FauxRequest();
+               $provider->setLoggedOutCookie( $t2, $request );
+               $this->assertSame( null, $request->response()->getCookie( 'xLoggedOut' ) );
+
+               // Don't reset if it's already set
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       'xLoggedOut' => $t1,
+               ), '' );
+               $provider->setLoggedOutCookie( $t1, $request );
+               $this->assertSame( null, $request->response()->getCookie( 'xLoggedOut' ) );
+       }
+
+       /**
+        * To be mocked for hooks, since PHPUnit can't otherwise mock methods that
+        * take references.
+        */
+       public function onUserSetCookies( $user, &$sessionData, &$cookies ) {
+       }
+
+}
diff --git a/tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php b/tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php
new file mode 100644 (file)
index 0000000..95f8e01
--- /dev/null
@@ -0,0 +1,301 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+use User;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\ImmutableSessionProviderWithCookie
+ */
+class ImmutableSessionProviderWithCookieTest extends MediaWikiTestCase {
+
+       private function getProvider( $name, $prefix = null ) {
+               $config = new \HashConfig();
+               $config->set( 'CookiePrefix', 'wgCookiePrefix' );
+
+               $params = array(
+                       'sessionCookieName' => $name,
+                       'sessionCookieOptions' => array(),
+               );
+               if ( $prefix !== null ) {
+                       $params['sessionCookieOptions']['prefix'] = $prefix;
+               }
+
+               $provider = $this->getMockBuilder( 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' )
+                       ->setConstructorArgs( array( $params ) )
+                       ->getMockForAbstractClass();
+               $provider->setLogger( new \TestLogger() );
+               $provider->setConfig( $config );
+               $provider->setManager( new SessionManager() );
+
+               return $provider;
+       }
+
+       public function testConstructor() {
+               $provider = $this->getMockBuilder( 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' )
+                       ->getMockForAbstractClass();
+               $priv = \TestingAccessWrapper::newFromObject( $provider );
+               $this->assertNull( $priv->sessionCookieName );
+               $this->assertSame( array(), $priv->sessionCookieOptions );
+
+               $provider = $this->getMockBuilder( 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' )
+                       ->setConstructorArgs( array( array(
+                               'sessionCookieName' => 'Foo',
+                               'sessionCookieOptions' => array( 'Bar' ),
+                       ) ) )
+                       ->getMockForAbstractClass();
+               $priv = \TestingAccessWrapper::newFromObject( $provider );
+               $this->assertSame( 'Foo', $priv->sessionCookieName );
+               $this->assertSame( array( 'Bar' ), $priv->sessionCookieOptions );
+
+               try {
+                       $provider = $this->getMockBuilder( 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' )
+                               ->setConstructorArgs( array( array(
+                                       'sessionCookieName' => false,
+                               ) ) )
+                               ->getMockForAbstractClass();
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'sessionCookieName must be a string',
+                               $ex->getMessage()
+                       );
+               }
+
+               try {
+                       $provider = $this->getMockBuilder( 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' )
+                               ->setConstructorArgs( array( array(
+                                       'sessionCookieOptions' => 'x',
+                               ) ) )
+                               ->getMockForAbstractClass();
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'sessionCookieOptions must be an array',
+                               $ex->getMessage()
+                       );
+               }
+       }
+
+       public function testBasics() {
+               $provider = $this->getProvider( null );
+               $this->assertFalse( $provider->persistsSessionID() );
+               $this->assertFalse( $provider->canChangeUser() );
+
+               $provider = $this->getProvider( 'Foo' );
+               $this->assertTrue( $provider->persistsSessionID() );
+               $this->assertFalse( $provider->canChangeUser() );
+
+               $msg = $provider->whyNoSession();
+               $this->assertInstanceOf( 'Message', $msg );
+               $this->assertSame( 'sessionprovider-nocookies', $msg->getKey() );
+       }
+
+       public function testGetVaryCookies() {
+               $provider = $this->getProvider( null );
+               $this->assertSame( array(), $provider->getVaryCookies() );
+
+               $provider = $this->getProvider( 'Foo' );
+               $this->assertSame( array( 'wgCookiePrefixFoo' ), $provider->getVaryCookies() );
+
+               $provider = $this->getProvider( 'Foo', 'Bar' );
+               $this->assertSame( array( 'BarFoo' ), $provider->getVaryCookies() );
+
+               $provider = $this->getProvider( 'Foo', '' );
+               $this->assertSame( array( 'Foo' ), $provider->getVaryCookies() );
+       }
+
+       public function testGetSessionIdFromCookie() {
+               $this->setMwGlobals( 'wgCookiePrefix', 'wgCookiePrefix' );
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       '' => 'empty---------------------------',
+                       'Foo' => 'foo-----------------------------',
+                       'wgCookiePrefixFoo' => 'wgfoo---------------------------',
+                       'BarFoo' => 'foobar--------------------------',
+                       'bad' => 'bad',
+               ), '' );
+
+               $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( null ) );
+               try {
+                       $provider->getSessionIdFromCookie( $request );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \BadMethodCallException $ex ) {
+                       $this->assertSame(
+                               'MediaWiki\\Session\\ImmutableSessionProviderWithCookie::getSessionIdFromCookie ' .
+                                       'may not be called when $this->sessionCookieName === null',
+                               $ex->getMessage()
+                       );
+               }
+
+               $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( 'Foo' ) );
+               $this->assertSame(
+                       'wgfoo---------------------------',
+                       $provider->getSessionIdFromCookie( $request )
+               );
+
+               $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( 'Foo', 'Bar' ) );
+               $this->assertSame(
+                       'foobar--------------------------',
+                       $provider->getSessionIdFromCookie( $request )
+               );
+
+               $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( 'Foo', '' ) );
+               $this->assertSame(
+                       'foo-----------------------------',
+                       $provider->getSessionIdFromCookie( $request )
+               );
+
+               $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( 'bad', '' ) );
+               $this->assertSame( null, $provider->getSessionIdFromCookie( $request ) );
+
+               $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( 'none', '' ) );
+               $this->assertSame( null, $provider->getSessionIdFromCookie( $request ) );
+       }
+
+       protected function getSentRequest() {
+               $sentResponse = $this->getMock( 'FauxResponse', array( 'headersSent', 'setCookie', 'header' ) );
+               $sentResponse->expects( $this->any() )->method( 'headersSent' )
+                       ->will( $this->returnValue( true ) );
+               $sentResponse->expects( $this->never() )->method( 'setCookie' );
+               $sentResponse->expects( $this->never() )->method( 'header' );
+
+               $sentRequest = $this->getMock( 'FauxRequest', array( 'response' ) );
+               $sentRequest->expects( $this->any() )->method( 'response' )
+                       ->will( $this->returnValue( $sentResponse ) );
+               return $sentRequest;
+       }
+
+       /**
+        * @dataProvider providePersistSession
+        * @param bool $secure
+        * @param bool $remember
+        */
+       public function testPersistSession( $secure, $remember ) {
+               $this->setMwGlobals( array(
+                       'wgCookieExpiration' => 100,
+                       'wgSecureLogin' => false,
+               ) );
+
+               $provider = $this->getProvider( 'session' );
+               $provider->setLogger( new \Psr\Log\NullLogger() );
+               $priv = \TestingAccessWrapper::newFromObject( $provider );
+               $priv->sessionCookieOptions = array(
+                       'prefix' => 'x',
+                       'path' => 'CookiePath',
+                       'domain' => 'CookieDomain',
+                       'secure' => false,
+                       'httpOnly' => true,
+               );
+
+               $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+               $user = User::newFromName( 'UTSysop' );
+               $this->assertFalse( $user->requiresHTTPS(), 'sanity check' );
+
+               $backend = new SessionBackend(
+                       new SessionId( $sessionId ),
+                       new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                               'provider' => $provider,
+                               'id' => $sessionId,
+                               'persisted' => true,
+                               'userInfo' => UserInfo::newFromUser( $user, true ),
+                               'idIsSafe' => true,
+                       ) ),
+                       new TestBagOStuff(),
+                       new \Psr\Log\NullLogger(),
+                       10
+               );
+               \TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false;
+               $backend->setRememberUser( $remember );
+               $backend->setForceHTTPS( $secure );
+
+               // No cookie
+               $priv->sessionCookieName = null;
+               $request = new \FauxRequest();
+               $provider->persistSession( $backend, $request );
+               $this->assertSame( array(), $request->response()->getCookies() );
+
+               // Cookie
+               $priv->sessionCookieName = 'session';
+               $request = new \FauxRequest();
+               $time = time();
+               $provider->persistSession( $backend, $request );
+
+               $cookie = $request->response()->getCookieData( 'xsession' );
+               $this->assertInternalType( 'array', $cookie );
+               if ( isset( $cookie['expire'] ) && $cookie['expire'] > 0 ) {
+                       // Round expiry so we don't randomly fail if the seconds ticked during the test.
+                       $cookie['expire'] = round( $cookie['expire'] - $time, -2 );
+               }
+               $this->assertEquals( array(
+                       'value' => $sessionId,
+                       'expire' => null,
+                       'path' => 'CookiePath',
+                       'domain' => 'CookieDomain',
+                       'secure' => $secure,
+                       'httpOnly' => true,
+                       'raw' => false,
+               ), $cookie );
+
+               $cookie = $request->response()->getCookieData( 'forceHTTPS' );
+               if ( $secure ) {
+                       $this->assertInternalType( 'array', $cookie );
+                       if ( isset( $cookie['expire'] ) && $cookie['expire'] > 0 ) {
+                               // Round expiry so we don't randomly fail if the seconds ticked during the test.
+                               $cookie['expire'] = round( $cookie['expire'] - $time, -2 );
+                       }
+                       $this->assertEquals( array(
+                               'value' => 'true',
+                               'expire' => $remember ? 100 : null,
+                               'path' => 'CookiePath',
+                               'domain' => 'CookieDomain',
+                               'secure' => false,
+                               'httpOnly' => true,
+                               'raw' => false,
+                       ), $cookie );
+               } else {
+                       $this->assertNull( $cookie );
+               }
+
+               // Headers sent
+               $request = $this->getSentRequest();
+               $provider->persistSession( $backend, $request );
+               $this->assertSame( array(), $request->response()->getCookies() );
+       }
+
+       public static function providePersistSession() {
+               return array(
+                       array( false, false ),
+                       array( false, true ),
+                       array( true, false ),
+                       array( true, true ),
+               );
+       }
+
+       public function testUnpersistSession() {
+               $provider = $this->getProvider( 'session', '' );
+               $provider->setLogger( new \Psr\Log\NullLogger() );
+               $priv = \TestingAccessWrapper::newFromObject( $provider );
+
+               // No cookie
+               $priv->sessionCookieName = null;
+               $request = new \FauxRequest();
+               $provider->unpersistSession( $request );
+               $this->assertSame( null, $request->response()->getCookie( 'session', '' ) );
+
+               // Cookie
+               $priv->sessionCookieName = 'session';
+               $request = new \FauxRequest();
+               $provider->unpersistSession( $request );
+               $this->assertSame( '', $request->response()->getCookie( 'session', '' ) );
+
+               // Headers sent
+               $request = $this->getSentRequest();
+               $provider->unpersistSession( $request );
+               $this->assertSame( null, $request->response()->getCookie( 'session', '' ) );
+       }
+
+}
diff --git a/tests/phpunit/includes/session/PHPSessionHandlerTest.php b/tests/phpunit/includes/session/PHPSessionHandlerTest.php
new file mode 100644 (file)
index 0000000..3044aa7
--- /dev/null
@@ -0,0 +1,373 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LogLevel;
+use MediaWikiTestCase;
+
+/**
+ * @group Session
+ * @covers MediaWiki\Session\PHPSessionHandler
+ */
+class PHPSessionHandlerTest extends MediaWikiTestCase {
+
+       private function getResetter( &$rProp = null ) {
+               $reset = array();
+
+               // Ignore "headers already sent" warnings during this test
+               set_error_handler( function ( $errno, $errstr ) use ( &$warnings ) {
+                       if ( preg_match( '/headers already sent/', $errstr ) ) {
+                               return true;
+                       }
+                       return false;
+               } );
+               $reset[] = new \ScopedCallback( 'restore_error_handler' );
+
+               $rProp = new \ReflectionProperty( 'MediaWiki\\Session\\PHPSessionHandler', 'instance' );
+               $rProp->setAccessible( true );
+               if ( $rProp->getValue() ) {
+                       $old = \TestingAccessWrapper::newFromObject( $rProp->getValue() );
+                       $oldManager = $old->manager;
+                       $oldStore = $old->store;
+                       $oldLogger = $old->logger;
+                       $reset[] = new \ScopedCallback(
+                               array( 'MediaWiki\\Session\\PHPSessionHandler', 'install' ),
+                               array( $oldManager, $oldStore, $oldLogger )
+                       );
+               }
+
+               return $reset;
+       }
+
+       public function testEnableFlags() {
+               $handler = \TestingAccessWrapper::newFromObject(
+                       $this->getMockBuilder( 'MediaWiki\\Session\\PHPSessionHandler' )
+                               ->setMethods( null )
+                               ->disableOriginalConstructor()
+                               ->getMock()
+               );
+
+               $rProp = new \ReflectionProperty( 'MediaWiki\\Session\\PHPSessionHandler', 'instance' );
+               $rProp->setAccessible( true );
+               $reset = new \ScopedCallback( array( $rProp, 'setValue' ), array( $rProp->getValue() ) );
+               $rProp->setValue( $handler );
+
+               $handler->setEnableFlags( 'enable' );
+               $this->assertTrue( $handler->enable );
+               $this->assertFalse( $handler->warn );
+               $this->assertTrue( PHPSessionHandler::isEnabled() );
+
+               $handler->setEnableFlags( 'warn' );
+               $this->assertTrue( $handler->enable );
+               $this->assertTrue( $handler->warn );
+               $this->assertTrue( PHPSessionHandler::isEnabled() );
+
+               $handler->setEnableFlags( 'disable' );
+               $this->assertFalse( $handler->enable );
+               $this->assertFalse( PHPSessionHandler::isEnabled() );
+
+               $rProp->setValue( null );
+               $this->assertFalse( PHPSessionHandler::isEnabled() );
+       }
+
+       public function testInstall() {
+               $reset = $this->getResetter( $rProp );
+               $rProp->setValue( null );
+
+               session_write_close();
+               ini_set( 'session.use_cookies', 1 );
+               ini_set( 'session.use_trans_sid', 1 );
+
+               $store = new TestBagOStuff();
+               $logger = new \TestLogger();
+               $manager = new SessionManager( array(
+                       'store' => $store,
+                       'logger' => $logger,
+               ) );
+
+               $this->assertFalse( PHPSessionHandler::isInstalled() );
+               PHPSessionHandler::install( $manager );
+               $this->assertTrue( PHPSessionHandler::isInstalled() );
+
+               $this->assertFalse( wfIniGetBool( 'session.use_cookies' ) );
+               $this->assertFalse( wfIniGetBool( 'session.use_trans_sid' ) );
+
+               $this->assertNotNull( $rProp->getValue() );
+               $priv = \TestingAccessWrapper::newFromObject( $rProp->getValue() );
+               $this->assertSame( $manager, $priv->manager );
+               $this->assertSame( $store, $priv->store );
+               $this->assertSame( $logger, $priv->logger );
+       }
+
+       /**
+        * @dataProvider provideHandlers
+        * @param string $handler php serialize_handler to use
+        */
+       public function testSessionHandling( $handler ) {
+               $this->hideDeprecated( '$_SESSION' );
+               $reset[] = $this->getResetter( $rProp );
+
+               $this->setMwGlobals( array(
+                       'wgSessionProviders' => array( array( 'class' => 'DummySessionProvider' ) ),
+                       'wgObjectCacheSessionExpiry' => 2,
+               ) );
+
+               $store = new TestBagOStuff();
+               $logger = new \TestLogger( true, function ( $m ) {
+                       return preg_match( '/^SessionBackend a{32} /', $m ) ? null : $m;
+               } );
+               $manager = new SessionManager( array(
+                       'store' => $store,
+                       'logger' => $logger,
+               ) );
+               PHPSessionHandler::install( $manager );
+               $wrap = \TestingAccessWrapper::newFromObject( $rProp->getValue() );
+               $reset[] = new \ScopedCallback(
+                       array( $wrap, 'setEnableFlags' ),
+                       array( $wrap->enable ? $wrap->warn ? 'warn' : 'enable' : 'disable' )
+               );
+               $wrap->setEnableFlags( 'warn' );
+
+               \MediaWiki\suppressWarnings();
+               ini_set( 'session.serialize_handler', $handler );
+               \MediaWiki\restoreWarnings();
+               if ( ini_get( 'session.serialize_handler' ) !== $handler ) {
+                       $this->markTestSkipped( "Cannot set session.serialize_handler to \"$handler\"" );
+               }
+
+               // Session IDs for testing
+               $sessionA = str_repeat( 'a', 32 );
+               $sessionB = str_repeat( 'b', 32 );
+               $sessionC = str_repeat( 'c', 32 );
+
+               // Set up garbage data in the session
+               $_SESSION['AuthenticationSessionTest'] = 'bogus';
+
+               session_id( $sessionA );
+               session_start();
+               $this->assertSame( array(), $_SESSION );
+               $this->assertSame( $sessionA, session_id() );
+
+               // Set some data in the session so we can see if it works.
+               $rand = mt_rand();
+               $_SESSION['AuthenticationSessionTest'] = $rand;
+               $expect = array( 'AuthenticationSessionTest' => $rand );
+               session_write_close();
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Something wrote to $_SESSION!' ),
+               ), $logger->getBuffer() );
+
+               // Screw up $_SESSION so we can tell the difference between "this
+               // worked" and "this did nothing"
+               $_SESSION['AuthenticationSessionTest'] = 'bogus';
+
+               // Re-open the session and see that data was actually reloaded
+               session_start();
+               $this->assertSame( $expect, $_SESSION );
+
+               // Make sure session_reset() works too.
+               if ( function_exists( 'session_reset' ) ) {
+                       $_SESSION['AuthenticationSessionTest'] = 'bogus';
+                       session_reset();
+                       $this->assertSame( $expect, $_SESSION );
+               }
+
+               // Test expiry
+               session_write_close();
+               ini_set( 'session.gc_divisor', 1 );
+               ini_set( 'session.gc_probability', 1 );
+               sleep( 3 );
+               session_start();
+               $this->assertSame( array(), $_SESSION );
+
+               // Re-fill the session, then test that session_destroy() works.
+               $_SESSION['AuthenticationSessionTest'] = $rand;
+               session_write_close();
+               session_start();
+               $this->assertSame( $expect, $_SESSION );
+               session_destroy();
+               session_id( $sessionA );
+               session_start();
+               $this->assertSame( array(), $_SESSION );
+               session_write_close();
+
+               // Test that our session handler won't clone someone else's session
+               session_id( $sessionB );
+               session_start();
+               $this->assertSame( $sessionB, session_id() );
+               $_SESSION['id'] = 'B';
+               session_write_close();
+
+               session_id( $sessionC );
+               session_start();
+               $this->assertSame( array(), $_SESSION );
+               $_SESSION['id'] = 'C';
+               session_write_close();
+
+               session_id( $sessionB );
+               session_start();
+               $this->assertSame( array( 'id' => 'B' ), $_SESSION );
+               session_write_close();
+
+               session_id( $sessionC );
+               session_start();
+               $this->assertSame( array( 'id' => 'C' ), $_SESSION );
+               session_destroy();
+
+               session_id( $sessionB );
+               session_start();
+               $this->assertSame( array( 'id' => 'B' ), $_SESSION );
+
+               // Test merging between Session and $_SESSION
+               session_write_close();
+
+               $session = $manager->getEmptySession();
+               $session->set( 'Unchanged', 'setup' );
+               $session->set( 'Unchanged, null', null );
+               $session->set( 'Changed in $_SESSION', 'setup' );
+               $session->set( 'Changed in Session', 'setup' );
+               $session->set( 'Changed in both', 'setup' );
+               $session->set( 'Deleted in Session', 'setup' );
+               $session->set( 'Deleted in $_SESSION', 'setup' );
+               $session->set( 'Deleted in both', 'setup' );
+               $session->set( 'Deleted in Session, changed in $_SESSION', 'setup' );
+               $session->set( 'Deleted in $_SESSION, changed in Session', 'setup' );
+               $session->persist();
+               $session->save();
+
+               session_id( $session->getId() );
+               session_start();
+               $session->set( 'Added in Session', 'Session' );
+               $session->set( 'Added in both', 'Session' );
+               $session->set( 'Changed in Session', 'Session' );
+               $session->set( 'Changed in both', 'Session' );
+               $session->set( 'Deleted in $_SESSION, changed in Session', 'Session' );
+               $session->remove( 'Deleted in Session' );
+               $session->remove( 'Deleted in both' );
+               $session->remove( 'Deleted in Session, changed in $_SESSION' );
+               $session->save();
+               $_SESSION['Added in $_SESSION'] = '$_SESSION';
+               $_SESSION['Added in both'] = '$_SESSION';
+               $_SESSION['Changed in $_SESSION'] = '$_SESSION';
+               $_SESSION['Changed in both'] = '$_SESSION';
+               $_SESSION['Deleted in Session, changed in $_SESSION'] = '$_SESSION';
+               unset( $_SESSION['Deleted in $_SESSION'] );
+               unset( $_SESSION['Deleted in both'] );
+               unset( $_SESSION['Deleted in $_SESSION, changed in Session'] );
+               session_write_close();
+
+               $this->assertEquals( array(
+                       'Added in Session' => 'Session',
+                       'Added in $_SESSION' => '$_SESSION',
+                       'Added in both' => 'Session',
+                       'Unchanged' => 'setup',
+                       'Unchanged, null' => null,
+                       'Changed in Session' => 'Session',
+                       'Changed in $_SESSION' => '$_SESSION',
+                       'Changed in both' => 'Session',
+                       'Deleted in Session, changed in $_SESSION' => '$_SESSION',
+                       'Deleted in $_SESSION, changed in Session' => 'Session',
+               ), iterator_to_array( $session ) );
+
+               $session->clear();
+               $session->set( 42, 'forty-two' );
+               $session->set( 'forty-two', 42 );
+               $session->set( 'wrong', 43 );
+               $session->persist();
+               $session->save();
+
+               session_start();
+               $this->assertArrayHasKey( 'forty-two', $_SESSION );
+               $this->assertSame( 42, $_SESSION['forty-two'] );
+               $this->assertArrayHasKey( 'wrong', $_SESSION );
+               unset( $_SESSION['wrong'] );
+               session_write_close();
+
+               $this->assertEquals( array(
+                       42 => 'forty-two',
+                       'forty-two' => 42,
+               ), iterator_to_array( $session ) );
+
+               // Test that write doesn't break if the session is invalid
+               $session = $manager->getEmptySession();
+               $session->persist();
+               session_id( $session->getId() );
+               session_start();
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array(
+                       'SessionCheckInfo' => array( function ( &$reason ) {
+                               $reason = 'Testing';
+                               return false;
+                       } ),
+               ) );
+               $this->assertNull( $manager->getSessionById( $session->getId(), true ), 'sanity check' );
+               session_write_close();
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array(
+                       'SessionCheckInfo' => array(),
+               ) );
+               $this->assertNotNull( $manager->getSessionById( $session->getId(), true ), 'sanity check' );
+       }
+
+       public static function provideHandlers() {
+               return array(
+                       array( 'php' ),
+                       array( 'php_binary' ),
+                       array( 'php_serialize' ),
+               );
+       }
+
+       /**
+        * @dataProvider provideDisabled
+        * @expectedException BadMethodCallException
+        * @expectedExceptionMessage Attempt to use PHP session management
+        */
+       public function testDisabled( $method, $args ) {
+               $rProp = new \ReflectionProperty( 'MediaWiki\\Session\\PHPSessionHandler', 'instance' );
+               $rProp->setAccessible( true );
+               $handler = $this->getMockBuilder( 'MediaWiki\\Session\\PHPSessionHandler' )
+                       ->setMethods( null )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+               \TestingAccessWrapper::newFromObject( $handler )->setEnableFlags( 'disable' );
+               $oldValue = $rProp->getValue();
+               $rProp->setValue( $handler );
+               $reset = new \ScopedCallback( array( $rProp, 'setValue' ), array( $oldValue ) );
+
+               call_user_func_array( array( $handler, $method ), $args );
+       }
+
+       public static function provideDisabled() {
+               return array(
+                       array( 'open', array( '', '' ) ),
+                       array( 'read', array( '' ) ),
+                       array( 'write', array( '', '' ) ),
+                       array( 'destroy', array( '' ) ),
+               );
+       }
+
+       /**
+        * @dataProvider provideWrongInstance
+        * @expectedException UnexpectedValueException
+        * @expectedExceptionMessageRegExp /: Wrong instance called!$/
+        */
+       public function testWrongInstance( $method, $args ) {
+               $handler = $this->getMockBuilder( 'MediaWiki\\Session\\PHPSessionHandler' )
+                       ->setMethods( null )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+               \TestingAccessWrapper::newFromObject( $handler )->setEnableFlags( 'enable' );
+
+               call_user_func_array( array( $handler, $method ), $args );
+       }
+
+       public static function provideWrongInstance() {
+               return array(
+                       array( 'open', array( '', '' ) ),
+                       array( 'close', array() ),
+                       array( 'read', array( '' ) ),
+                       array( 'write', array( '', '' ) ),
+                       array( 'destroy', array( '' ) ),
+                       array( 'gc', array( 0 ) ),
+               );
+       }
+
+}
diff --git a/tests/phpunit/includes/session/SessionBackendTest.php b/tests/phpunit/includes/session/SessionBackendTest.php
new file mode 100644 (file)
index 0000000..f08a07d
--- /dev/null
@@ -0,0 +1,767 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+use User;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\SessionBackend
+ */
+class SessionBackendTest extends MediaWikiTestCase {
+       const SESSIONID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+
+       protected $manager;
+       protected $config;
+       protected $provider;
+       protected $store;
+
+       protected $onSessionMetadataCalled = false;
+
+       /**
+        * Returns a non-persistent backend that thinks it has at least one session active
+        * @param User|null $user
+        */
+       protected function getBackend( User $user = null ) {
+               if ( !$this->config ) {
+                       $this->config = new \HashConfig();
+                       $this->manager = null;
+               }
+               if ( !$this->store ) {
+                       $this->store = new TestBagOStuff();
+                       $this->manager = null;
+               }
+
+               $logger = new \Psr\Log\NullLogger();
+               if ( !$this->manager ) {
+                       $this->manager = new SessionManager( array(
+                               'store' => $this->store,
+                               'logger' => $logger,
+                               'config' => $this->config,
+                       ) );
+               }
+
+               if ( !$this->provider ) {
+                       $this->provider = new \DummySessionProvider();
+               }
+               $this->provider->setLogger( $logger );
+               $this->provider->setConfig( $this->config );
+               $this->provider->setManager( $this->manager );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $this->provider,
+                       'id' => self::SESSIONID,
+                       'persisted' => true,
+                       'userInfo' => UserInfo::newFromUser( $user ?: new User, true ),
+                       'idIsSafe' => true,
+               ) );
+               $id = new SessionId( $info->getId() );
+
+               $backend = new SessionBackend( $id, $info, $this->store, $logger, 10 );
+               $priv = \TestingAccessWrapper::newFromObject( $backend );
+               $priv->persist = false;
+               $priv->requests = array( 100 => new \FauxRequest() );
+               $priv->usePhpSessionHandling = false;
+
+               $manager = \TestingAccessWrapper::newFromObject( $this->manager );
+               $manager->allSessionBackends = array( $backend->getId() => $backend );
+               $manager->allSessionIds = array( $backend->getId() => $id );
+               $manager->sessionProviders = array( (string)$this->provider => $this->provider );
+
+               return $backend;
+       }
+
+       public function testConstructor() {
+               // Set variables
+               $this->getBackend();
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $this->provider,
+                       'id' => self::SESSIONID,
+                       'persisted' => true,
+                       'userInfo' => UserInfo::newFromName( 'UTSysop', false ),
+                       'idIsSafe' => true,
+               ) );
+               $id = new SessionId( $info->getId() );
+               $logger = new \Psr\Log\NullLogger();
+               try {
+                       new SessionBackend( $id, $info, $this->store, $logger, 10 );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               "Refusing to create session for unverified user {$info->getUserInfo()}",
+                               $ex->getMessage()
+                       );
+               }
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'id' => self::SESSIONID,
+                       'userInfo' => UserInfo::newFromName( 'UTSysop', true ),
+                       'idIsSafe' => true,
+               ) );
+               $id = new SessionId( $info->getId() );
+               try {
+                       new SessionBackend( $id, $info, $this->store, $logger, 10 );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Cannot create session without a provider', $ex->getMessage() );
+               }
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $this->provider,
+                       'id' => self::SESSIONID,
+                       'persisted' => true,
+                       'userInfo' => UserInfo::newFromName( 'UTSysop', true ),
+                       'idIsSafe' => true,
+               ) );
+               $id = new SessionId( '!' . $info->getId() );
+               try {
+                       new SessionBackend( $id, $info, $this->store, $logger, 10 );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'SessionId and SessionInfo don\'t match',
+                               $ex->getMessage()
+                       );
+               }
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $this->provider,
+                       'id' => self::SESSIONID,
+                       'persisted' => true,
+                       'userInfo' => UserInfo::newFromName( 'UTSysop', true ),
+                       'idIsSafe' => true,
+               ) );
+               $id = new SessionId( $info->getId() );
+               $backend = new SessionBackend( $id, $info, $this->store, $logger, 10 );
+               $this->assertSame( self::SESSIONID, $backend->getId() );
+               $this->assertSame( $id, $backend->getSessionId() );
+               $this->assertSame( $this->provider, $backend->getProvider() );
+               $this->assertInstanceOf( 'User', $backend->getUser() );
+               $this->assertSame( 'UTSysop', $backend->getUser()->getName() );
+               $this->assertSame( $info->wasPersisted(), $backend->isPersistent() );
+               $this->assertSame( $info->wasRemembered(), $backend->shouldRememberUser() );
+               $this->assertSame( $info->forceHTTPS(), $backend->shouldForceHTTPS() );
+
+               $expire = time() + 100;
+               $this->store->setSessionMeta( self::SESSIONID, array( 'expires' => $expire ), 2 );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $this->provider,
+                       'id' => self::SESSIONID,
+                       'persisted' => true,
+                       'forceHTTPS' => true,
+                       'metadata' => array( 'foo' ),
+                       'idIsSafe' => true,
+               ) );
+               $id = new SessionId( $info->getId() );
+               $backend = new SessionBackend( $id, $info, $this->store, $logger, 10 );
+               $this->assertSame( self::SESSIONID, $backend->getId() );
+               $this->assertSame( $id, $backend->getSessionId() );
+               $this->assertSame( $this->provider, $backend->getProvider() );
+               $this->assertInstanceOf( 'User', $backend->getUser() );
+               $this->assertTrue( $backend->getUser()->isAnon() );
+               $this->assertSame( $info->wasPersisted(), $backend->isPersistent() );
+               $this->assertSame( $info->wasRemembered(), $backend->shouldRememberUser() );
+               $this->assertSame( $info->forceHTTPS(), $backend->shouldForceHTTPS() );
+               $this->assertSame( $expire, \TestingAccessWrapper::newFromObject( $backend )->expires );
+               $this->assertSame( array( 'foo' ), $backend->getProviderMetadata() );
+       }
+
+       public function testSessionStuff() {
+               $backend = $this->getBackend();
+               $priv = \TestingAccessWrapper::newFromObject( $backend );
+               $priv->requests = array(); // Remove dummy session
+
+               $manager = \TestingAccessWrapper::newFromObject( $this->manager );
+
+               $request1 = new \FauxRequest();
+               $session1 = $backend->getSession( $request1 );
+               $request2 = new \FauxRequest();
+               $session2 = $backend->getSession( $request2 );
+
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session1 );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session2 );
+               $this->assertSame( 2, count( $priv->requests ) );
+
+               $index = \TestingAccessWrapper::newFromObject( $session1 )->index;
+
+               $this->assertSame( $request1, $backend->getRequest( $index ) );
+               $this->assertSame( null, $backend->suggestLoginUsername( $index ) );
+               $request1->setCookie( 'UserName', 'Example' );
+               $this->assertSame( 'Example', $backend->suggestLoginUsername( $index ) );
+
+               $session1 = null;
+               $this->assertSame( 1, count( $priv->requests ) );
+               $this->assertArrayHasKey( $backend->getId(), $manager->allSessionBackends );
+               $this->assertSame( $backend, $manager->allSessionBackends[$backend->getId()] );
+               try {
+                       $backend->getRequest( $index );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid session index', $ex->getMessage() );
+               }
+               try {
+                       $backend->suggestLoginUsername( $index );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid session index', $ex->getMessage() );
+               }
+
+               $session2 = null;
+               $this->assertSame( 0, count( $priv->requests ) );
+               $this->assertArrayNotHasKey( $backend->getId(), $manager->allSessionBackends );
+               $this->assertArrayHasKey( $backend->getId(), $manager->allSessionIds );
+       }
+
+       public function testResetId() {
+               $id = session_id();
+
+               $builder = $this->getMockBuilder( 'DummySessionProvider' )
+                       ->setMethods( array( 'persistsSessionId', 'sessionIdWasReset' ) );
+
+               $this->provider = $builder->getMock();
+               $this->provider->expects( $this->any() )->method( 'persistsSessionId' )
+                       ->will( $this->returnValue( false ) );
+               $this->provider->expects( $this->never() )->method( 'sessionIdWasReset' );
+               $backend = $this->getBackend( User::newFromName( 'UTSysop' ) );
+               $manager = \TestingAccessWrapper::newFromObject( $this->manager );
+               $sessionId = $backend->getSessionId();
+               $backend->resetId();
+               $this->assertSame( self::SESSIONID, $backend->getId() );
+               $this->assertSame( $backend->getId(), $sessionId->getId() );
+               $this->assertSame( $id, session_id() );
+               $this->assertSame( $backend, $manager->allSessionBackends[self::SESSIONID] );
+
+               $this->provider = $builder->getMock();
+               $this->provider->expects( $this->any() )->method( 'persistsSessionId' )
+                       ->will( $this->returnValue( true ) );
+               $backend = $this->getBackend();
+               $this->provider->expects( $this->once() )->method( 'sessionIdWasReset' )
+                       ->with( $this->identicalTo( $backend ), $this->identicalTo( self::SESSIONID ) );
+               $manager = \TestingAccessWrapper::newFromObject( $this->manager );
+               $sessionId = $backend->getSessionId();
+               $backend->resetId();
+               $this->assertNotEquals( self::SESSIONID, $backend->getId() );
+               $this->assertSame( $backend->getId(), $sessionId->getId() );
+               $this->assertInternalType( 'array', $this->store->getSession( $backend->getId() ) );
+               $this->assertFalse( $this->store->getSession( self::SESSIONID ) );
+               $this->assertSame( $id, session_id() );
+               $this->assertArrayNotHasKey( self::SESSIONID, $manager->allSessionBackends );
+               $this->assertArrayHasKey( $backend->getId(), $manager->allSessionBackends );
+               $this->assertSame( $backend, $manager->allSessionBackends[$backend->getId()] );
+       }
+
+       public function testPersist() {
+               $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) );
+               $this->provider->expects( $this->once() )->method( 'persistSession' );
+               $backend = $this->getBackend();
+               $this->assertFalse( $backend->isPersistent(), 'sanity check' );
+               $backend->save(); // This one shouldn't call $provider->persistSession()
+
+               $backend->persist();
+               $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+
+               $this->provider = null;
+               $backend = $this->getBackend();
+               $wrap = \TestingAccessWrapper::newFromObject( $backend );
+               $wrap->persist = true;
+               $wrap->expires = 0;
+               $backend->persist();
+               $this->assertNotEquals( 0, $wrap->expires );
+       }
+
+       public function testRememberUser() {
+               $backend = $this->getBackend();
+
+               $remembered = $backend->shouldRememberUser();
+               $backend->setRememberUser( !$remembered );
+               $this->assertNotEquals( $remembered, $backend->shouldRememberUser() );
+               $backend->setRememberUser( $remembered );
+               $this->assertEquals( $remembered, $backend->shouldRememberUser() );
+       }
+
+       public function testForceHTTPS() {
+               $backend = $this->getBackend();
+
+               $force = $backend->shouldForceHTTPS();
+               $backend->setForceHTTPS( !$force );
+               $this->assertNotEquals( $force, $backend->shouldForceHTTPS() );
+               $backend->setForceHTTPS( $force );
+               $this->assertEquals( $force, $backend->shouldForceHTTPS() );
+       }
+
+       public function testLoggedOutTimestamp() {
+               $backend = $this->getBackend();
+
+               $backend->setLoggedOutTimestamp( 42 );
+               $this->assertSame( 42, $backend->getLoggedOutTimestamp() );
+               $backend->setLoggedOutTimestamp( '123' );
+               $this->assertSame( 123, $backend->getLoggedOutTimestamp() );
+       }
+
+       public function testSetUser() {
+               $user = User::newFromName( 'UTSysop' );
+
+               $this->provider = $this->getMock( 'DummySessionProvider', array( 'canChangeUser' ) );
+               $this->provider->expects( $this->any() )->method( 'canChangeUser' )
+                       ->will( $this->returnValue( false ) );
+               $backend = $this->getBackend();
+               $this->assertFalse( $backend->canSetUser() );
+               try {
+                       $backend->setUser( $user );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \BadMethodCallException $ex ) {
+                       $this->assertSame(
+                               'Cannot set user on this session; check $session->canSetUser() first',
+                               $ex->getMessage()
+                       );
+               }
+               $this->assertNotSame( $user, $backend->getUser() );
+
+               $this->provider = null;
+               $backend = $this->getBackend();
+               $this->assertTrue( $backend->canSetUser() );
+               $this->assertNotSame( $user, $backend->getUser(), 'sanity check' );
+               $backend->setUser( $user );
+               $this->assertSame( $user, $backend->getUser() );
+       }
+
+       public function testDirty() {
+               $backend = $this->getBackend();
+               $priv = \TestingAccessWrapper::newFromObject( $backend );
+               $priv->dataDirty = false;
+               $backend->dirty();
+               $this->assertTrue( $priv->dataDirty );
+       }
+
+       public function testGetData() {
+               $backend = $this->getBackend();
+               $data = $backend->getData();
+               $this->assertSame( array(), $data );
+               $this->assertTrue( \TestingAccessWrapper::newFromObject( $backend )->dataDirty );
+               $data['???'] = '!!!';
+               $this->assertSame( array( '???' => '!!!' ), $data );
+
+               $testData = array( 'foo' => 'foo!', 'bar', array( 'baz', null ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend();
+               $this->assertSame( $testData, $backend->getData() );
+               $this->assertFalse( \TestingAccessWrapper::newFromObject( $backend )->dataDirty );
+       }
+
+       public function testAddData() {
+               $backend = $this->getBackend();
+               $priv = \TestingAccessWrapper::newFromObject( $backend );
+
+               $priv->data = array( 'foo' => 1 );
+               $priv->dataDirty = false;
+               $backend->addData( array( 'foo' => 1 ) );
+               $this->assertSame( array( 'foo' => 1 ), $priv->data );
+               $this->assertFalse( $priv->dataDirty );
+
+               $priv->data = array( 'foo' => 1 );
+               $priv->dataDirty = false;
+               $backend->addData( array( 'foo' => '1' ) );
+               $this->assertSame( array( 'foo' => '1' ), $priv->data );
+               $this->assertTrue( $priv->dataDirty );
+
+               $priv->data = array( 'foo' => 1 );
+               $priv->dataDirty = false;
+               $backend->addData( array( 'bar' => 2 ) );
+               $this->assertSame( array( 'foo' => 1, 'bar' => 2 ), $priv->data );
+               $this->assertTrue( $priv->dataDirty );
+       }
+
+       public function testDelaySave() {
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) );
+               $backend = $this->getBackend();
+               $priv = \TestingAccessWrapper::newFromObject( $backend );
+               $priv->persist = true;
+
+               // Saves happen normally when no delay is in effect
+               $this->onSessionMetadataCalled = false;
+               $priv->metaDirty = true;
+               $backend->save();
+               $this->assertTrue( $this->onSessionMetadataCalled, 'sanity check' );
+
+               $this->onSessionMetadataCalled = false;
+               $priv->metaDirty = true;
+               $priv->autosave();
+               $this->assertTrue( $this->onSessionMetadataCalled, 'sanity check' );
+
+               $delay = $backend->delaySave();
+
+               // Autosave doesn't happen when no delay is in effect
+               $this->onSessionMetadataCalled = false;
+               $priv->metaDirty = true;
+               $priv->autosave();
+               $this->assertFalse( $this->onSessionMetadataCalled );
+
+               // Save still does happen when no delay is in effect
+               $priv->save();
+               $this->assertTrue( $this->onSessionMetadataCalled );
+
+               // Save happens when delay is consumed
+               $this->onSessionMetadataCalled = false;
+               $priv->metaDirty = true;
+               \ScopedCallback::consume( $delay );
+               $this->assertTrue( $this->onSessionMetadataCalled );
+
+               // Test multiple delays
+               $delay1 = $backend->delaySave();
+               $delay2 = $backend->delaySave();
+               $delay3 = $backend->delaySave();
+               $this->onSessionMetadataCalled = false;
+               $priv->metaDirty = true;
+               $priv->autosave();
+               $this->assertFalse( $this->onSessionMetadataCalled );
+               \ScopedCallback::consume( $delay3 );
+               $this->assertFalse( $this->onSessionMetadataCalled );
+               \ScopedCallback::consume( $delay1 );
+               $this->assertFalse( $this->onSessionMetadataCalled );
+               \ScopedCallback::consume( $delay2 );
+               $this->assertTrue( $this->onSessionMetadataCalled );
+       }
+
+       public function testSave() {
+               $user = User::newFromName( 'UTSysop' );
+               $this->store = new TestBagOStuff();
+               $testData = array( 'foo' => 'foo!', 'bar', array( 'baz', null ) );
+
+               $neverHook = $this->getMock( __CLASS__, array( 'onSessionMetadata' ) );
+               $neverHook->expects( $this->never() )->method( 'onSessionMetadata' );
+
+               $neverProvider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) );
+               $neverProvider->expects( $this->never() )->method( 'persistSession' );
+
+               // Not persistent or dirty
+               $this->provider = $neverProvider;
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $neverHook ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               $this->assertFalse( $backend->isPersistent(), 'sanity check' );
+               \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+               \TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
+               $backend->save();
+               $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );
+
+               // Not persistent, but dirty
+               $this->provider = $neverProvider;
+               $this->onSessionMetadataCalled = false;
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               $this->assertFalse( $backend->isPersistent(), 'sanity check' );
+               \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+               \TestingAccessWrapper::newFromObject( $backend )->dataDirty = true;
+               $backend->save();
+               $this->assertTrue( $this->onSessionMetadataCalled );
+               $blob = $this->store->getSession( self::SESSIONID );
+               $this->assertInternalType( 'array', $blob );
+               $this->assertArrayHasKey( 'metadata', $blob );
+               $metadata = $blob['metadata'];
+               $this->assertInternalType( 'array', $metadata );
+               $this->assertArrayHasKey( '???', $metadata );
+               $this->assertSame( '!!!', $metadata['???'] );
+               $this->assertFalse( $this->store->getSessionFromBackend( self::SESSIONID ),
+                       'making sure it didn\'t save to backend' );
+
+               // Persistent, not dirty
+               $this->provider = $neverProvider;
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $neverHook ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               \TestingAccessWrapper::newFromObject( $backend )->persist = true;
+               $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+               \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+               \TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
+               $backend->save();
+               $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );
+
+               $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) );
+               $this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $neverHook ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               \TestingAccessWrapper::newFromObject( $backend )->persist = true;
+               \TestingAccessWrapper::newFromObject( $backend )->forcePersist = true;
+               $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+               \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+               \TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
+               $backend->save();
+               $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );
+
+               // Persistent and dirty
+               $this->provider = $neverProvider;
+               $this->onSessionMetadataCalled = false;
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               \TestingAccessWrapper::newFromObject( $backend )->persist = true;
+               $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+               \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+               \TestingAccessWrapper::newFromObject( $backend )->dataDirty = true;
+               $backend->save();
+               $this->assertTrue( $this->onSessionMetadataCalled );
+               $blob = $this->store->getSession( self::SESSIONID );
+               $this->assertInternalType( 'array', $blob );
+               $this->assertArrayHasKey( 'metadata', $blob );
+               $metadata = $blob['metadata'];
+               $this->assertInternalType( 'array', $metadata );
+               $this->assertArrayHasKey( '???', $metadata );
+               $this->assertSame( '!!!', $metadata['???'] );
+               $this->assertNotSame( false, $this->store->getSessionFromBackend( self::SESSIONID ),
+                       'making sure it did save to backend' );
+
+               $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) );
+               $this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' );
+               $this->onSessionMetadataCalled = false;
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               \TestingAccessWrapper::newFromObject( $backend )->persist = true;
+               \TestingAccessWrapper::newFromObject( $backend )->forcePersist = true;
+               $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+               \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+               \TestingAccessWrapper::newFromObject( $backend )->dataDirty = true;
+               $backend->save();
+               $this->assertTrue( $this->onSessionMetadataCalled );
+               $blob = $this->store->getSession( self::SESSIONID );
+               $this->assertInternalType( 'array', $blob );
+               $this->assertArrayHasKey( 'metadata', $blob );
+               $metadata = $blob['metadata'];
+               $this->assertInternalType( 'array', $metadata );
+               $this->assertArrayHasKey( '???', $metadata );
+               $this->assertSame( '!!!', $metadata['???'] );
+               $this->assertNotSame( false, $this->store->getSessionFromBackend( self::SESSIONID ),
+                       'making sure it did save to backend' );
+
+               $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) );
+               $this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' );
+               $this->onSessionMetadataCalled = false;
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               \TestingAccessWrapper::newFromObject( $backend )->persist = true;
+               $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+               \TestingAccessWrapper::newFromObject( $backend )->metaDirty = true;
+               \TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
+               $backend->save();
+               $this->assertTrue( $this->onSessionMetadataCalled );
+               $blob = $this->store->getSession( self::SESSIONID );
+               $this->assertInternalType( 'array', $blob );
+               $this->assertArrayHasKey( 'metadata', $blob );
+               $metadata = $blob['metadata'];
+               $this->assertInternalType( 'array', $metadata );
+               $this->assertArrayHasKey( '???', $metadata );
+               $this->assertSame( '!!!', $metadata['???'] );
+               $this->assertNotSame( false, $this->store->getSessionFromBackend( self::SESSIONID ),
+                       'making sure it did save to backend' );
+
+               // Not marked dirty, but dirty data
+               $this->provider = $neverProvider;
+               $this->onSessionMetadataCalled = false;
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               \TestingAccessWrapper::newFromObject( $backend )->persist = true;
+               $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+               \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+               \TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
+               \TestingAccessWrapper::newFromObject( $backend )->dataHash = 'Doesn\'t match';
+               $backend->save();
+               $this->assertTrue( $this->onSessionMetadataCalled );
+               $blob = $this->store->getSession( self::SESSIONID );
+               $this->assertInternalType( 'array', $blob );
+               $this->assertArrayHasKey( 'metadata', $blob );
+               $metadata = $blob['metadata'];
+               $this->assertInternalType( 'array', $metadata );
+               $this->assertArrayHasKey( '???', $metadata );
+               $this->assertSame( '!!!', $metadata['???'] );
+               $this->assertNotSame( false, $this->store->getSessionFromBackend( self::SESSIONID ),
+                       'making sure it did save to backend' );
+
+               // Bad hook
+               $this->provider = null;
+               $mockHook = $this->getMock( __CLASS__, array( 'onSessionMetadata' ) );
+               $mockHook->expects( $this->any() )->method( 'onSessionMetadata' )
+                       ->will( $this->returnCallback(
+                               function ( SessionBackend $backend, array &$metadata, array $requests ) {
+                                       $metadata['userId']++;
+                               }
+                       ) );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $mockHook ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $backend->dirty();
+               try {
+                       $backend->save();
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \UnexpectedValueException $ex ) {
+                       $this->assertSame(
+                               'SessionMetadata hook changed metadata key "userId"',
+                               $ex->getMessage()
+                       );
+               }
+
+               // SessionManager::preventSessionsForUser
+               \TestingAccessWrapper::newFromObject( $this->manager )->preventUsers = array(
+                       $user->getName() => true,
+               );
+               $this->provider = $neverProvider;
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $neverHook ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               \TestingAccessWrapper::newFromObject( $backend )->persist = true;
+               $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+               \TestingAccessWrapper::newFromObject( $backend )->metaDirty = true;
+               \TestingAccessWrapper::newFromObject( $backend )->dataDirty = true;
+               $backend->save();
+               $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );
+       }
+
+       public function testRenew() {
+               $user = User::newFromName( 'UTSysop' );
+               $this->store = new TestBagOStuff();
+               $testData = array( 'foo' => 'foo!', 'bar', array( 'baz', null ) );
+
+               // Not persistent
+               $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) );
+               $this->provider->expects( $this->never() )->method( 'persistSession' );
+               $this->onSessionMetadataCalled = false;
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               $wrap = \TestingAccessWrapper::newFromObject( $backend );
+               $this->assertFalse( $backend->isPersistent(), 'sanity check' );
+               $wrap->metaDirty = false;
+               $wrap->dataDirty = false;
+               $wrap->forcePersist = false;
+               $wrap->expires = 0;
+               $backend->renew();
+               $this->assertTrue( $this->onSessionMetadataCalled );
+               $blob = $this->store->getSession( self::SESSIONID );
+               $this->assertInternalType( 'array', $blob );
+               $this->assertArrayHasKey( 'metadata', $blob );
+               $metadata = $blob['metadata'];
+               $this->assertInternalType( 'array', $metadata );
+               $this->assertArrayHasKey( '???', $metadata );
+               $this->assertSame( '!!!', $metadata['???'] );
+               $this->assertNotEquals( 0, $wrap->expires );
+
+               // Persistent
+               $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) );
+               $this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' );
+               $this->onSessionMetadataCalled = false;
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               $wrap = \TestingAccessWrapper::newFromObject( $backend );
+               $wrap->persist = true;
+               $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+               $wrap->metaDirty = false;
+               $wrap->dataDirty = false;
+               $wrap->forcePersist = false;
+               $wrap->expires = 0;
+               $backend->renew();
+               $this->assertTrue( $this->onSessionMetadataCalled );
+               $blob = $this->store->getSession( self::SESSIONID );
+               $this->assertInternalType( 'array', $blob );
+               $this->assertArrayHasKey( 'metadata', $blob );
+               $metadata = $blob['metadata'];
+               $this->assertInternalType( 'array', $metadata );
+               $this->assertArrayHasKey( '???', $metadata );
+               $this->assertSame( '!!!', $metadata['???'] );
+               $this->assertNotEquals( 0, $wrap->expires );
+
+               // Not persistent, not expiring
+               $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) );
+               $this->provider->expects( $this->never() )->method( 'persistSession' );
+               $this->onSessionMetadataCalled = false;
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               $wrap = \TestingAccessWrapper::newFromObject( $backend );
+               $this->assertFalse( $backend->isPersistent(), 'sanity check' );
+               $wrap->metaDirty = false;
+               $wrap->dataDirty = false;
+               $wrap->forcePersist = false;
+               $expires = time() + $wrap->lifetime + 100;
+               $wrap->expires = $expires;
+               $backend->renew();
+               $this->assertFalse( $this->onSessionMetadataCalled );
+               $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );
+               $this->assertEquals( $expires, $wrap->expires );
+       }
+
+       public function onSessionMetadata( SessionBackend $backend, array &$metadata, array $requests ) {
+               $this->onSessionMetadataCalled = true;
+               $metadata['???'] = '!!!';
+       }
+
+       public function testResetIdOfGlobalSession() {
+               if ( !PHPSessionHandler::isInstalled() ) {
+                       PHPSessionHandler::install( SessionManager::singleton() );
+               }
+               if ( !PHPSessionHandler::isEnabled() ) {
+                       $rProp = new \ReflectionProperty( 'MediaWiki\\Session\\PHPSessionHandler', 'instance' );
+                       $rProp->setAccessible( true );
+                       $handler = \TestingAccessWrapper::newFromObject( $rProp->getValue() );
+                       $resetHandler = new \ScopedCallback( function () use ( $handler ) {
+                               session_write_close();
+                               $handler->enable = false;
+                       } );
+                       $handler->enable = true;
+               }
+
+               $backend = $this->getBackend( User::newFromName( 'UTSysop' ) );
+               \TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = true;
+
+               TestUtils::setSessionManagerSingleton( $this->manager );
+
+               $manager = \TestingAccessWrapper::newFromObject( $this->manager );
+               $request = \RequestContext::getMain()->getRequest();
+               $manager->globalSession = $backend->getSession( $request );
+               $manager->globalSessionRequest = $request;
+
+               session_id( self::SESSIONID );
+               \MediaWiki\quietCall( 'session_start' );
+               $backend->resetId();
+               $this->assertNotEquals( self::SESSIONID, $backend->getId() );
+               $this->assertSame( $backend->getId(), session_id() );
+               session_write_close();
+
+               session_id( '' );
+               $this->assertNotSame( $backend->getId(), session_id(), 'sanity check' );
+               $backend->persist();
+               $this->assertSame( $backend->getId(), session_id() );
+               session_write_close();
+       }
+
+       public function testGetAllowedUserRights() {
+               $this->provider = $this->getMockBuilder( 'DummySessionProvider' )
+                       ->setMethods( array( 'getAllowedUserRights' ) )
+                       ->getMock();
+               $this->provider->expects( $this->any() )->method( 'getAllowedUserRights' )
+                       ->will( $this->returnValue( array( 'foo', 'bar' ) ) );
+
+               $backend = $this->getBackend();
+               $this->assertSame( array( 'foo', 'bar' ), $backend->getAllowedUserRights() );
+       }
+
+}
diff --git a/tests/phpunit/includes/session/SessionIdTest.php b/tests/phpunit/includes/session/SessionIdTest.php
new file mode 100644 (file)
index 0000000..2b06d97
--- /dev/null
@@ -0,0 +1,22 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+
+/**
+ * @group Session
+ * @covers MediaWiki\Session\SessionId
+ */
+class SessionIdTest extends MediaWikiTestCase {
+
+       public function testEverything() {
+               $id = new SessionId( 'foo' );
+               $this->assertSame( 'foo', $id->getId() );
+               $this->assertSame( 'foo', (string)$id );
+               $id->setId( 'bar' );
+               $this->assertSame( 'bar', $id->getId() );
+               $this->assertSame( 'bar', (string)$id );
+       }
+
+}
diff --git a/tests/phpunit/includes/session/SessionInfoTest.php b/tests/phpunit/includes/session/SessionInfoTest.php
new file mode 100644 (file)
index 0000000..b411f3c
--- /dev/null
@@ -0,0 +1,328 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LogLevel;
+use MediaWikiTestCase;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\SessionInfo
+ */
+class SessionInfoTest extends MediaWikiTestCase {
+
+       public function testBasics() {
+               $anonInfo = UserInfo::newAnonymous();
+               $userInfo = UserInfo::newFromName( 'UTSysop', true );
+               $unverifiedUserInfo = UserInfo::newFromName( 'UTSysop', false );
+
+               try {
+                       new SessionInfo( SessionInfo::MIN_PRIORITY - 1, array() );
+                       $this->fail( 'Expected exception not thrown', 'priority < min' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid priority', $ex->getMessage(), 'priority < min' );
+               }
+
+               try {
+                       new SessionInfo( SessionInfo::MAX_PRIORITY + 1, array() );
+                       $this->fail( 'Expected exception not thrown', 'priority > max' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid priority', $ex->getMessage(), 'priority > max' );
+               }
+
+               try {
+                       new SessionInfo( SessionInfo::MIN_PRIORITY, array( 'id' => 'ABC?' ) );
+                       $this->fail( 'Expected exception not thrown', 'bad session ID' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid session ID', $ex->getMessage(), 'bad session ID' );
+               }
+
+               try {
+                       new SessionInfo( SessionInfo::MIN_PRIORITY, array( 'userInfo' => new \stdClass ) );
+                       $this->fail( 'Expected exception not thrown', 'bad userInfo' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid userInfo', $ex->getMessage(), 'bad userInfo' );
+               }
+
+               try {
+                       new SessionInfo( SessionInfo::MIN_PRIORITY, array() );
+                       $this->fail( 'Expected exception not thrown', 'no provider, no id' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Must supply an ID when no provider is given', $ex->getMessage(),
+                               'no provider, no id' );
+               }
+
+               try {
+                       new SessionInfo( SessionInfo::MIN_PRIORITY, array( 'copyFrom' => new \stdClass ) );
+                       $this->fail( 'Expected exception not thrown', 'bad copyFrom' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid copyFrom', $ex->getMessage(),
+                               'bad copyFrom' );
+               }
+
+               $manager = new SessionManager();
+               $provider = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' )
+                       ->setMethods( array( 'persistsSessionId', 'canChangeUser', '__toString' ) )
+                       ->getMockForAbstractClass();
+               $provider->setManager( $manager );
+               $provider->expects( $this->any() )->method( 'persistsSessionId' )
+                       ->will( $this->returnValue( true ) );
+               $provider->expects( $this->any() )->method( 'canChangeUser' )
+                       ->will( $this->returnValue( true ) );
+               $provider->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'Mock' ) );
+
+               $provider2 = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' )
+                       ->setMethods( array( 'persistsSessionId', 'canChangeUser', '__toString' ) )
+                       ->getMockForAbstractClass();
+               $provider2->setManager( $manager );
+               $provider2->expects( $this->any() )->method( 'persistsSessionId' )
+                       ->will( $this->returnValue( true ) );
+               $provider2->expects( $this->any() )->method( 'canChangeUser' )
+                       ->will( $this->returnValue( true ) );
+               $provider2->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'Mock2' ) );
+
+               try {
+                       new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                               'provider' => $provider,
+                               'userInfo' => $anonInfo,
+                               'metadata' => 'foo',
+                       ) );
+                       $this->fail( 'Expected exception not thrown', 'bad metadata' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid metadata', $ex->getMessage(), 'bad metadata' );
+               }
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'provider' => $provider,
+                       'userInfo' => $anonInfo
+               ) );
+               $this->assertSame( $provider, $info->getProvider() );
+               $this->assertNotNull( $info->getId() );
+               $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
+               $this->assertSame( $anonInfo, $info->getUserInfo() );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertFalse( $info->wasPersisted() );
+               $this->assertFalse( $info->wasRemembered() );
+               $this->assertFalse( $info->forceHTTPS() );
+               $this->assertNull( $info->getProviderMetadata() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'provider' => $provider,
+                       'userInfo' => $unverifiedUserInfo,
+                       'metadata' => array( 'Foo' ),
+               ) );
+               $this->assertSame( $provider, $info->getProvider() );
+               $this->assertNotNull( $info->getId() );
+               $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
+               $this->assertSame( $unverifiedUserInfo, $info->getUserInfo() );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertFalse( $info->wasPersisted() );
+               $this->assertFalse( $info->wasRemembered() );
+               $this->assertFalse( $info->forceHTTPS() );
+               $this->assertSame( array( 'Foo' ), $info->getProviderMetadata() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'provider' => $provider,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertSame( $provider, $info->getProvider() );
+               $this->assertNotNull( $info->getId() );
+               $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
+               $this->assertSame( $userInfo, $info->getUserInfo() );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertFalse( $info->wasPersisted() );
+               $this->assertTrue( $info->wasRemembered() );
+               $this->assertFalse( $info->forceHTTPS() );
+               $this->assertNull( $info->getProviderMetadata() );
+
+               $id = $manager->generateSessionId();
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'persisted' => true,
+                       'userInfo' => $anonInfo
+               ) );
+               $this->assertSame( $provider, $info->getProvider() );
+               $this->assertSame( $id, $info->getId() );
+               $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
+               $this->assertSame( $anonInfo, $info->getUserInfo() );
+               $this->assertFalse( $info->isIdSafe() );
+               $this->assertTrue( $info->wasPersisted() );
+               $this->assertFalse( $info->wasRemembered() );
+               $this->assertFalse( $info->forceHTTPS() );
+               $this->assertNull( $info->getProviderMetadata() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertSame( $provider, $info->getProvider() );
+               $this->assertSame( $id, $info->getId() );
+               $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
+               $this->assertSame( $userInfo, $info->getUserInfo() );
+               $this->assertFalse( $info->isIdSafe() );
+               $this->assertFalse( $info->wasPersisted() );
+               $this->assertTrue( $info->wasRemembered() );
+               $this->assertFalse( $info->forceHTTPS() );
+               $this->assertNull( $info->getProviderMetadata() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'id' => $id,
+                       'persisted' => true,
+                       'userInfo' => $userInfo,
+                       'metadata' => array( 'Foo' ),
+               ) );
+               $this->assertSame( $id, $info->getId() );
+               $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
+               $this->assertSame( $userInfo, $info->getUserInfo() );
+               $this->assertFalse( $info->isIdSafe() );
+               $this->assertTrue( $info->wasPersisted() );
+               $this->assertFalse( $info->wasRemembered() );
+               $this->assertFalse( $info->forceHTTPS() );
+               $this->assertNull( $info->getProviderMetadata() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'id' => $id,
+                       'remembered' => true,
+                       'userInfo' => $userInfo,
+               ) );
+               $this->assertFalse( $info->wasRemembered(), 'no provider' );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'remembered' => true,
+               ) );
+               $this->assertFalse( $info->wasRemembered(), 'no user' );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'remembered' => true,
+                       'userInfo' => $anonInfo,
+               ) );
+               $this->assertFalse( $info->wasRemembered(), 'anonymous user' );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'remembered' => true,
+                       'userInfo' => $unverifiedUserInfo,
+               ) );
+               $this->assertFalse( $info->wasRemembered(), 'unverified user' );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'remembered' => false,
+                       'userInfo' => $userInfo,
+               ) );
+               $this->assertFalse( $info->wasRemembered(), 'specific override' );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'id' => $id,
+                       'idIsSafe' => true,
+               ) );
+               $this->assertSame( $id, $info->getId() );
+               $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
+               $this->assertTrue( $info->isIdSafe() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'id' => $id,
+                       'forceHTTPS' => 1,
+               ) );
+               $this->assertTrue( $info->forceHTTPS() );
+
+               $fromInfo = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'id' => $id . 'A',
+                       'provider' => $provider,
+                       'userInfo' => $userInfo,
+                       'idIsSafe' => true,
+                       'persisted' => true,
+                       'remembered' => true,
+                       'forceHTTPS' => true,
+                       'metadata' => array( 'foo!' ),
+               ) );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 4, array(
+                       'copyFrom' => $fromInfo,
+               ) );
+               $this->assertSame( $id . 'A', $info->getId() );
+               $this->assertSame( SessionInfo::MIN_PRIORITY + 4, $info->getPriority() );
+               $this->assertSame( $provider, $info->getProvider() );
+               $this->assertSame( $userInfo, $info->getUserInfo() );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertTrue( $info->wasPersisted() );
+               $this->assertTrue( $info->wasRemembered() );
+               $this->assertTrue( $info->forceHTTPS() );
+               $this->assertSame( array( 'foo!' ), $info->getProviderMetadata() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 4, array(
+                       'id' => $id . 'X',
+                       'provider' => $provider2,
+                       'userInfo' => $unverifiedUserInfo,
+                       'idIsSafe' => false,
+                       'persisted' => false,
+                       'remembered' => false,
+                       'forceHTTPS' => false,
+                       'metadata' => null,
+                       'copyFrom' => $fromInfo,
+               ) );
+               $this->assertSame( $id . 'X', $info->getId() );
+               $this->assertSame( SessionInfo::MIN_PRIORITY + 4, $info->getPriority() );
+               $this->assertSame( $provider2, $info->getProvider() );
+               $this->assertSame( $unverifiedUserInfo, $info->getUserInfo() );
+               $this->assertFalse( $info->isIdSafe() );
+               $this->assertFalse( $info->wasPersisted() );
+               $this->assertFalse( $info->wasRemembered() );
+               $this->assertFalse( $info->forceHTTPS() );
+               $this->assertNull( $info->getProviderMetadata() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'id' => $id,
+               ) );
+               $this->assertSame(
+                       '[' . SessionInfo::MIN_PRIORITY . "]null<null>$id",
+                       (string)$info,
+                       'toString'
+               );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'persisted' => true,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertSame(
+                       '[' . SessionInfo::MIN_PRIORITY . "]Mock<+:{$userInfo->getId()}:UTSysop>$id",
+                       (string)$info,
+                       'toString'
+               );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'persisted' => true,
+                       'userInfo' => $unverifiedUserInfo
+               ) );
+               $this->assertSame(
+                       '[' . SessionInfo::MIN_PRIORITY . "]Mock<-:{$userInfo->getId()}:UTSysop>$id",
+                       (string)$info,
+                       'toString'
+               );
+       }
+
+       public function testCompare() {
+               $id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+               $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, array( 'id' => $id ) );
+               $info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, array( 'id' => $id ) );
+
+               $this->assertTrue( SessionInfo::compare( $info1, $info2 ) < 0, '<' );
+               $this->assertTrue( SessionInfo::compare( $info2, $info1 ) > 0, '>' );
+               $this->assertTrue( SessionInfo::compare( $info1, $info1 ) === 0, '==' );
+       }
+}
diff --git a/tests/phpunit/includes/session/SessionManagerTest.php b/tests/phpunit/includes/session/SessionManagerTest.php
new file mode 100644 (file)
index 0000000..d8d5b4b
--- /dev/null
@@ -0,0 +1,1677 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LogLevel;
+use MediaWikiTestCase;
+use User;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\SessionManager
+ */
+class SessionManagerTest extends MediaWikiTestCase {
+
+       protected $config, $logger, $store;
+
+       protected function getManager() {
+               \ObjectCache::$instances['testSessionStore'] = new TestBagOStuff();
+               $this->config = new \HashConfig( array(
+                       'LanguageCode' => 'en',
+                       'SessionCacheType' => 'testSessionStore',
+                       'ObjectCacheSessionExpiry' => 100,
+                       'SessionProviders' => array(
+                               array( 'class' => 'DummySessionProvider' ),
+                       )
+               ) );
+               $this->logger = new \TestLogger( false, function ( $m ) {
+                       return substr( $m, 0, 15 ) === 'SessionBackend ' ? null : $m;
+               } );
+               $this->store = new TestBagOStuff();
+
+               return new SessionManager( array(
+                       'config' => $this->config,
+                       'logger' => $this->logger,
+                       'store' => $this->store,
+               ) );
+       }
+
+       protected function objectCacheDef( $object ) {
+               return array( 'factory' => function () use ( $object ) {
+                       return $object;
+               } );
+       }
+
+       public function testSingleton() {
+               $reset = TestUtils::setSessionManagerSingleton( null );
+
+               $singleton = SessionManager::singleton();
+               $this->assertInstanceOf( 'MediaWiki\\Session\\SessionManager', $singleton );
+               $this->assertSame( $singleton, SessionManager::singleton() );
+       }
+
+       public function testGetGlobalSession() {
+               $context = \RequestContext::getMain();
+
+               if ( !PHPSessionHandler::isInstalled() ) {
+                       PHPSessionHandler::install( SessionManager::singleton() );
+               }
+               $rProp = new \ReflectionProperty( 'MediaWiki\\Session\\PHPSessionHandler', 'instance' );
+               $rProp->setAccessible( true );
+               $handler = \TestingAccessWrapper::newFromObject( $rProp->getValue() );
+               $oldEnable = $handler->enable;
+               $reset[] = new \ScopedCallback( function () use ( $handler, $oldEnable ) {
+                       if ( $handler->enable ) {
+                               session_write_close();
+                       }
+                       $handler->enable = $oldEnable;
+               } );
+               $reset[] = TestUtils::setSessionManagerSingleton( $this->getManager() );
+
+               $handler->enable = true;
+               $request = new \FauxRequest();
+               $context->setRequest( $request );
+               $id = $request->getSession()->getId();
+
+               session_id( '' );
+               $session = SessionManager::getGlobalSession();
+               $this->assertSame( $id, $session->getId() );
+
+               session_id( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' );
+               $session = SessionManager::getGlobalSession();
+               $this->assertSame( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', $session->getId() );
+               $this->assertSame( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', $request->getSession()->getId() );
+
+               session_write_close();
+               $handler->enable = false;
+               $request = new \FauxRequest();
+               $context->setRequest( $request );
+               $id = $request->getSession()->getId();
+
+               session_id( '' );
+               $session = SessionManager::getGlobalSession();
+               $this->assertSame( $id, $session->getId() );
+
+               session_id( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' );
+               $session = SessionManager::getGlobalSession();
+               $this->assertSame( $id, $session->getId() );
+               $this->assertSame( $id, $request->getSession()->getId() );
+       }
+
+       public function testConstructor() {
+               $manager = \TestingAccessWrapper::newFromObject( $this->getManager() );
+               $this->assertSame( $this->config, $manager->config );
+               $this->assertSame( $this->logger, $manager->logger );
+               $this->assertSame( $this->store, $manager->store );
+
+               $manager = \TestingAccessWrapper::newFromObject( new SessionManager() );
+               $this->assertSame( \RequestContext::getMain()->getConfig(), $manager->config );
+
+               $manager = \TestingAccessWrapper::newFromObject( new SessionManager( array(
+                       'config' => $this->config,
+               ) ) );
+               $this->assertSame( \ObjectCache::$instances['testSessionStore'], $manager->store );
+
+               foreach ( array(
+                       'config' => '$options[\'config\'] must be an instance of Config',
+                       'logger' => '$options[\'logger\'] must be an instance of LoggerInterface',
+                       'store' => '$options[\'store\'] must be an instance of BagOStuff',
+               ) as $key => $error ) {
+                       try {
+                               new SessionManager( array( $key => new \stdClass ) );
+                               $this->fail( 'Expected exception not thrown' );
+                       } catch ( \InvalidArgumentException $ex ) {
+                               $this->assertSame( $error, $ex->getMessage() );
+                       }
+               }
+       }
+
+       public function testGetSessionForRequest() {
+               $manager = $this->getManager();
+               $request = new \FauxRequest();
+
+               $id1 = '';
+               $id2 = '';
+               $idEmpty = 'empty-session-------------------';
+
+               $providerBuilder = $this->getMockBuilder( 'DummySessionProvider' )
+                       ->setMethods(
+                               array( 'provideSessionInfo', 'newSessionInfo', '__toString', 'describe' )
+                       );
+
+               $provider1 = $providerBuilder->getMock();
+               $provider1->expects( $this->any() )->method( 'provideSessionInfo' )
+                       ->with( $this->identicalTo( $request ) )
+                       ->will( $this->returnCallback( function ( $request ) {
+                               return $request->info1;
+                       } ) );
+               $provider1->expects( $this->any() )->method( 'newSessionInfo' )
+                       ->will( $this->returnCallback( function () use ( $idEmpty, $provider1 ) {
+                               return new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                                       'provider' => $provider1,
+                                       'id' => $idEmpty,
+                                       'persisted' => true,
+                                       'idIsSafe' => true,
+                               ) );
+                       } ) );
+               $provider1->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'Provider1' ) );
+               $provider1->expects( $this->any() )->method( 'describe' )
+                       ->will( $this->returnValue( '#1 sessions' ) );
+
+               $provider2 = $providerBuilder->getMock();
+               $provider2->expects( $this->any() )->method( 'provideSessionInfo' )
+                       ->with( $this->identicalTo( $request ) )
+                       ->will( $this->returnCallback( function ( $request ) {
+                               return $request->info2;
+                       } ) );
+               $provider2->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'Provider2' ) );
+               $provider2->expects( $this->any() )->method( 'describe' )
+                       ->will( $this->returnValue( '#2 sessions' ) );
+
+               $this->config->set( 'SessionProviders', array(
+                       $this->objectCacheDef( $provider1 ),
+                       $this->objectCacheDef( $provider2 ),
+               ) );
+
+               // No provider returns info
+               $request->info1 = null;
+               $request->info2 = null;
+               $session = $manager->getSessionForRequest( $request );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertSame( $idEmpty, $session->getId() );
+
+               // Both providers return info, picks best one
+               $request->info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, array(
+                       'provider' => $provider1,
+                       'id' => ( $id1 = $manager->generateSessionId() ),
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $request->info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, array(
+                       'provider' => $provider2,
+                       'id' => ( $id2 = $manager->generateSessionId() ),
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $session = $manager->getSessionForRequest( $request );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertSame( $id2, $session->getId() );
+
+               $request->info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, array(
+                       'provider' => $provider1,
+                       'id' => ( $id1 = $manager->generateSessionId() ),
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $request->info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, array(
+                       'provider' => $provider2,
+                       'id' => ( $id2 = $manager->generateSessionId() ),
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $session = $manager->getSessionForRequest( $request );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertSame( $id1, $session->getId() );
+
+               // Tied priorities
+               $request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, array(
+                       'provider' => $provider1,
+                       'id' => ( $id1 = $manager->generateSessionId() ),
+                       'persisted' => true,
+                       'userInfo' => UserInfo::newAnonymous(),
+                       'idIsSafe' => true,
+               ) );
+               $request->info2 = new SessionInfo( SessionInfo::MAX_PRIORITY, array(
+                       'provider' => $provider2,
+                       'id' => ( $id2 = $manager->generateSessionId() ),
+                       'persisted' => true,
+                       'userInfo' => UserInfo::newAnonymous(),
+                       'idIsSafe' => true,
+               ) );
+               try {
+                       $manager->getSessionForRequest( $request );
+                       $this->fail( 'Expcected exception not thrown' );
+               } catch ( \OverFlowException $ex ) {
+                       $this->assertStringStartsWith(
+                               'Multiple sessions for this request tied for top priority: ',
+                               $ex->getMessage()
+                       );
+                       $this->assertCount( 2, $ex->sessionInfos );
+                       $this->assertContains( $request->info1, $ex->sessionInfos );
+                       $this->assertContains( $request->info2, $ex->sessionInfos );
+               }
+
+               // Bad provider
+               $request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, array(
+                       'provider' => $provider2,
+                       'id' => ( $id1 = $manager->generateSessionId() ),
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $request->info2 = null;
+               try {
+                       $manager->getSessionForRequest( $request );
+                       $this->fail( 'Expcected exception not thrown' );
+               } catch ( \UnexpectedValueException $ex ) {
+                       $this->assertSame(
+                               'Provider1 returned session info for a different provider: ' . $request->info1,
+                               $ex->getMessage()
+                       );
+               }
+
+               // Unusable session info
+               $this->logger->setCollect( true );
+               $request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, array(
+                       'provider' => $provider1,
+                       'id' => ( $id1 = $manager->generateSessionId() ),
+                       'persisted' => true,
+                       'userInfo' => UserInfo::newFromName( 'UTSysop', false ),
+                       'idIsSafe' => true,
+               ) );
+               $request->info2 = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider2,
+                       'id' => ( $id2 = $manager->generateSessionId() ),
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $session = $manager->getSessionForRequest( $request );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertSame( $id2, $session->getId() );
+               $this->logger->setCollect( false );
+
+               // Unpersisted session ID
+               $request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, array(
+                       'provider' => $provider1,
+                       'id' => ( $id1 = $manager->generateSessionId() ),
+                       'persisted' => false,
+                       'userInfo' => UserInfo::newFromName( 'UTSysop', true ),
+                       'idIsSafe' => true,
+               ) );
+               $request->info2 = null;
+               $session = $manager->getSessionForRequest( $request );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertSame( $id1, $session->getId() );
+               $session->persist();
+               $this->assertTrue( $session->isPersistent(), 'sanity check' );
+       }
+
+       public function testGetSessionById() {
+               $manager = $this->getManager();
+               try {
+                       $manager->getSessionById( 'bad' );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid session ID', $ex->getMessage() );
+               }
+
+               // Unknown session ID
+               $id = $manager->generateSessionId();
+               $session = $manager->getSessionById( $id, true );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertSame( $id, $session->getId() );
+
+               $id = $manager->generateSessionId();
+               $this->assertNull( $manager->getSessionById( $id, false ) );
+
+               // Known but unloadable session ID
+               $this->logger->setCollect( true );
+               $id = $manager->generateSessionId();
+               $this->store->setSession( $id, array( 'metadata' => array(
+                       'userId' => User::idFromName( 'UTSysop' ),
+                       'userToken' => 'bad',
+               ) ) );
+
+               $this->assertNull( $manager->getSessionById( $id, true ) );
+               $this->assertNull( $manager->getSessionById( $id, false ) );
+               $this->logger->setCollect( false );
+
+               // Known session ID
+               $this->store->setSession( $id, array() );
+               $session = $manager->getSessionById( $id, false );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertSame( $id, $session->getId() );
+       }
+
+       public function testGetEmptySession() {
+               $manager = $this->getManager();
+               $pmanager = \TestingAccessWrapper::newFromObject( $manager );
+               $request = new \FauxRequest();
+
+               $providerBuilder = $this->getMockBuilder( 'DummySessionProvider' )
+                       ->setMethods( array( 'provideSessionInfo', 'newSessionInfo', '__toString' ) );
+
+               $expectId = null;
+               $info1 = null;
+               $info2 = null;
+
+               $provider1 = $providerBuilder->getMock();
+               $provider1->expects( $this->any() )->method( 'provideSessionInfo' )
+                       ->will( $this->returnValue( null ) );
+               $provider1->expects( $this->any() )->method( 'newSessionInfo' )
+                       ->with( $this->callback( function ( $id ) use ( &$expectId ) {
+                               return $id === $expectId;
+                       } ) )
+                       ->will( $this->returnCallback( function () use ( &$info1 ) {
+                               return $info1;
+                       } ) );
+               $provider1->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'MockProvider1' ) );
+
+               $provider2 = $providerBuilder->getMock();
+               $provider2->expects( $this->any() )->method( 'provideSessionInfo' )
+                       ->will( $this->returnValue( null ) );
+               $provider2->expects( $this->any() )->method( 'newSessionInfo' )
+                       ->with( $this->callback( function ( $id ) use ( &$expectId ) {
+                               return $id === $expectId;
+                       } ) )
+                       ->will( $this->returnCallback( function () use ( &$info2 ) {
+                               return $info2;
+                       } ) );
+               $provider1->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'MockProvider2' ) );
+
+               $this->config->set( 'SessionProviders', array(
+                       $this->objectCacheDef( $provider1 ),
+                       $this->objectCacheDef( $provider2 ),
+               ) );
+
+               // No info
+               $expectId = null;
+               $info1 = null;
+               $info2 = null;
+               try {
+                       $manager->getEmptySession();
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \UnexpectedValueException $ex ) {
+                       $this->assertSame(
+                               'No provider could provide an empty session!',
+                               $ex->getMessage()
+                       );
+               }
+
+               // Info
+               $expectId = null;
+               $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider1,
+                       'id' => 'empty---------------------------',
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $info2 = null;
+               $session = $manager->getEmptySession();
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertSame( 'empty---------------------------', $session->getId() );
+
+               // Info, explicitly
+               $expectId = 'expected------------------------';
+               $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider1,
+                       'id' => $expectId,
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $info2 = null;
+               $session = $pmanager->getEmptySessionInternal( null, $expectId );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertSame( $expectId, $session->getId() );
+
+               // Wrong ID
+               $expectId = 'expected-----------------------2';
+               $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider1,
+                       'id' => "un$expectId",
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $info2 = null;
+               try {
+                       $pmanager->getEmptySessionInternal( null, $expectId );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \UnexpectedValueException $ex ) {
+                       $this->assertSame(
+                               'MockProvider1 returned empty session info with a wrong id: ' .
+                                       "un$expectId != $expectId",
+                               $ex->getMessage()
+                       );
+               }
+
+               // Unsafe ID
+               $expectId = 'expected-----------------------2';
+               $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider1,
+                       'id' => $expectId,
+                       'persisted' => true,
+               ) );
+               $info2 = null;
+               try {
+                       $pmanager->getEmptySessionInternal( null, $expectId );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \UnexpectedValueException $ex ) {
+                       $this->assertSame(
+                               'MockProvider1 returned empty session info with id flagged unsafe',
+                               $ex->getMessage()
+                       );
+               }
+
+               // Wrong provider
+               $expectId = null;
+               $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider2,
+                       'id' => 'empty---------------------------',
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $info2 = null;
+               try {
+                       $manager->getEmptySession();
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \UnexpectedValueException $ex ) {
+                       $this->assertSame(
+                               'MockProvider1 returned an empty session info for a different provider: ' . $info1,
+                               $ex->getMessage()
+                       );
+               }
+
+               // Highest priority wins
+               $expectId = null;
+               $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, array(
+                       'provider' => $provider1,
+                       'id' => 'empty1--------------------------',
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $info2 = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider2,
+                       'id' => 'empty2--------------------------',
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $session = $manager->getEmptySession();
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertSame( 'empty1--------------------------', $session->getId() );
+
+               $expectId = null;
+               $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, array(
+                       'provider' => $provider1,
+                       'id' => 'empty1--------------------------',
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, array(
+                       'provider' => $provider2,
+                       'id' => 'empty2--------------------------',
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $session = $manager->getEmptySession();
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertSame( 'empty2--------------------------', $session->getId() );
+
+               // Tied priorities throw an exception
+               $expectId = null;
+               $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider1,
+                       'id' => 'empty1--------------------------',
+                       'persisted' => true,
+                       'userInfo' => UserInfo::newAnonymous(),
+                       'idIsSafe' => true,
+               ) );
+               $info2 = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider2,
+                       'id' => 'empty2--------------------------',
+                       'persisted' => true,
+                       'userInfo' => UserInfo::newAnonymous(),
+                       'idIsSafe' => true,
+               ) );
+               try {
+                       $manager->getEmptySession();
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \UnexpectedValueException $ex ) {
+                       $this->assertStringStartsWith(
+                               'Multiple empty sessions tied for top priority: ',
+                               $ex->getMessage()
+                       );
+               }
+
+               // Bad id
+               try {
+                       $pmanager->getEmptySessionInternal( null, 'bad' );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid session ID', $ex->getMessage() );
+               }
+
+               // Session already exists
+               $expectId = 'expected-----------------------3';
+               $this->store->setSessionMeta( $expectId, array(
+                       'provider' => 'MockProvider2',
+                       'userId' => 0,
+                       'userName' => null,
+                       'userToken' => null,
+               ) );
+               try {
+                       $pmanager->getEmptySessionInternal( null, $expectId );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Session ID already exists', $ex->getMessage() );
+               }
+       }
+
+       public function testGetVaryHeaders() {
+               $manager = $this->getManager();
+
+               $providerBuilder = $this->getMockBuilder( 'DummySessionProvider' )
+                       ->setMethods( array( 'getVaryHeaders', '__toString' ) );
+
+               $provider1 = $providerBuilder->getMock();
+               $provider1->expects( $this->once() )->method( 'getVaryHeaders' )
+                       ->will( $this->returnValue( array(
+                               'Foo' => null,
+                               'Bar' => array( 'X', 'Bar1' ),
+                               'Quux' => null,
+                       ) ) );
+               $provider1->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'MockProvider1' ) );
+
+               $provider2 = $providerBuilder->getMock();
+               $provider2->expects( $this->once() )->method( 'getVaryHeaders' )
+                       ->will( $this->returnValue( array(
+                               'Baz' => null,
+                               'Bar' => array( 'X', 'Bar2' ),
+                               'Quux' => array( 'Quux' ),
+                       ) ) );
+               $provider2->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'MockProvider2' ) );
+
+               $this->config->set( 'SessionProviders', array(
+                       $this->objectCacheDef( $provider1 ),
+                       $this->objectCacheDef( $provider2 ),
+               ) );
+
+               $expect = array(
+                       'Foo' => array(),
+                       'Bar' => array( 'X', 'Bar1', 3 => 'Bar2' ),
+                       'Quux' => array( 'Quux' ),
+                       'Baz' => array(),
+                       'Quux' => array( 'Quux' ),
+               );
+
+               $this->assertEquals( $expect, $manager->getVaryHeaders() );
+
+               // Again, to ensure it's cached
+               $this->assertEquals( $expect, $manager->getVaryHeaders() );
+       }
+
+       public function testGetVaryCookies() {
+               $manager = $this->getManager();
+
+               $providerBuilder = $this->getMockBuilder( 'DummySessionProvider' )
+                       ->setMethods( array( 'getVaryCookies', '__toString' ) );
+
+               $provider1 = $providerBuilder->getMock();
+               $provider1->expects( $this->once() )->method( 'getVaryCookies' )
+                       ->will( $this->returnValue( array( 'Foo', 'Bar' ) ) );
+               $provider1->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'MockProvider1' ) );
+
+               $provider2 = $providerBuilder->getMock();
+               $provider2->expects( $this->once() )->method( 'getVaryCookies' )
+                       ->will( $this->returnValue( array( 'Foo', 'Baz' ) ) );
+               $provider2->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'MockProvider2' ) );
+
+               $this->config->set( 'SessionProviders', array(
+                       $this->objectCacheDef( $provider1 ),
+                       $this->objectCacheDef( $provider2 ),
+               ) );
+
+               $expect = array( 'Foo', 'Bar', 'Baz' );
+
+               $this->assertEquals( $expect, $manager->getVaryCookies() );
+
+               // Again, to ensure it's cached
+               $this->assertEquals( $expect, $manager->getVaryCookies() );
+       }
+
+       public function testGetProviders() {
+               $realManager = $this->getManager();
+               $manager = \TestingAccessWrapper::newFromObject( $realManager );
+
+               $this->config->set( 'SessionProviders', array(
+                       array( 'class' => 'DummySessionProvider' ),
+               ) );
+               $providers = $manager->getProviders();
+               $this->assertArrayHasKey( 'DummySessionProvider', $providers );
+               $provider = \TestingAccessWrapper::newFromObject( $providers['DummySessionProvider'] );
+               $this->assertSame( $manager->logger, $provider->logger );
+               $this->assertSame( $manager->config, $provider->config );
+               $this->assertSame( $realManager, $provider->getManager() );
+
+               $this->config->set( 'SessionProviders', array(
+                       array( 'class' => 'DummySessionProvider' ),
+                       array( 'class' => 'DummySessionProvider' ),
+               ) );
+               $manager->sessionProviders = null;
+               try {
+                       $manager->getProviders();
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \UnexpectedValueException $ex ) {
+                       $this->assertSame(
+                               'Duplicate provider name "DummySessionProvider"',
+                               $ex->getMessage()
+                       );
+               }
+       }
+
+       public function testShutdown() {
+               $manager = \TestingAccessWrapper::newFromObject( $this->getManager() );
+               $manager->setLogger( new \Psr\Log\NullLogger() );
+
+               $mock = $this->getMock( 'stdClass', array( 'save' ) );
+               $mock->expects( $this->once() )->method( 'save' );
+
+               $manager->allSessionBackends = array( $mock );
+               $manager->shutdown();
+       }
+
+       public function testGetSessionFromInfo() {
+               $manager = \TestingAccessWrapper::newFromObject( $this->getManager() );
+               $request = new \FauxRequest();
+
+               $id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $manager->getProvider( 'DummySessionProvider' ),
+                       'id' => $id,
+                       'persisted' => true,
+                       'userInfo' => UserInfo::newFromName( 'UTSysop', true ),
+                       'idIsSafe' => true,
+               ) );
+               \TestingAccessWrapper::newFromObject( $info )->idIsSafe = true;
+               $session1 = \TestingAccessWrapper::newFromObject(
+                       $manager->getSessionFromInfo( $info, $request )
+               );
+               $session2 = \TestingAccessWrapper::newFromObject(
+                       $manager->getSessionFromInfo( $info, $request )
+               );
+
+               $this->assertSame( $session1->backend, $session2->backend );
+               $this->assertNotEquals( $session1->index, $session2->index );
+               $this->assertSame( $session1->getSessionId(), $session2->getSessionId() );
+               $this->assertSame( $id, $session1->getId() );
+
+               \TestingAccessWrapper::newFromObject( $info )->idIsSafe = false;
+               $session3 = $manager->getSessionFromInfo( $info, $request );
+               $this->assertNotSame( $id, $session3->getId() );
+       }
+
+       public function testBackendRegistration() {
+               $manager = $this->getManager();
+
+               $session = $manager->getSessionForRequest( new \FauxRequest );
+               $backend = \TestingAccessWrapper::newFromObject( $session )->backend;
+               $sessionId = $session->getSessionId();
+               $id = (string)$sessionId;
+
+               $this->assertSame( $sessionId, $manager->getSessionById( $id, true )->getSessionId() );
+
+               $manager->changeBackendId( $backend );
+               $this->assertSame( $sessionId, $session->getSessionId() );
+               $this->assertNotEquals( $id, (string)$sessionId );
+               $id = (string)$sessionId;
+
+               $this->assertSame( $sessionId, $manager->getSessionById( $id, true )->getSessionId() );
+
+               // Destruction of the session here causes the backend to be deregistered
+               $session = null;
+
+               try {
+                       $manager->changeBackendId( $backend );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'Backend was not registered with this SessionManager', $ex->getMessage()
+                       );
+               }
+
+               try {
+                       $manager->deregisterSessionBackend( $backend );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'Backend was not registered with this SessionManager', $ex->getMessage()
+                       );
+               }
+
+               $session = $manager->getSessionById( $id, true );
+               $this->assertSame( $sessionId, $session->getSessionId() );
+       }
+
+       public function testGenerateSessionId() {
+               $manager = $this->getManager();
+
+               $id = $manager->generateSessionId();
+               $this->assertTrue( SessionManager::validateSessionId( $id ), "Generated ID: $id" );
+       }
+
+       public function testAutoCreateUser() {
+               global $wgGroupPermissions;
+
+               $that = $this;
+
+               \ObjectCache::$instances[__METHOD__] = new TestBagOStuff();
+               $this->setMwGlobals( array( 'wgMainCacheType' => __METHOD__ ) );
+
+               $this->stashMwGlobals( array( 'wgGroupPermissions' ) );
+               $wgGroupPermissions['*']['createaccount'] = true;
+               $wgGroupPermissions['*']['autocreateaccount'] = false;
+
+               // Replace the global singleton with one configured for testing
+               $manager = $this->getManager();
+               $reset = TestUtils::setSessionManagerSingleton( $manager );
+
+               $logger = new \TestLogger( true, function ( $m ) {
+                       if ( substr( $m, 0, 15 ) === 'SessionBackend ' ) {
+                               // Don't care.
+                               return null;
+                       }
+                       $m = str_replace( 'MediaWiki\Session\SessionManager::autoCreateUser: ', '', $m );
+                       $m = preg_replace( '/ - from: .*$/', ' - from: XXX', $m );
+                       return $m;
+               } );
+               $manager->setLogger( $logger );
+
+               $session = SessionManager::getGlobalSession();
+
+               // Can't create an already-existing user
+               $user = User::newFromName( 'UTSysop' );
+               $id = $user->getId();
+               $this->assertFalse( $manager->autoCreateUser( $user ) );
+               $this->assertSame( $id, $user->getId() );
+               $this->assertSame( 'UTSysop', $user->getName() );
+               $this->assertSame( array(), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Sanity check that creation works at all
+               $user = User::newFromName( 'UTSessionAutoCreate1' );
+               $this->assertSame( 0, $user->getId(), 'sanity check' );
+               $this->assertTrue( $manager->autoCreateUser( $user ) );
+               $this->assertNotEquals( 0, $user->getId() );
+               $this->assertSame( 'UTSessionAutoCreate1', $user->getName() );
+               $this->assertEquals(
+                       $user->getId(), User::idFromName( 'UTSessionAutoCreate1', User::READ_LATEST )
+               );
+               $this->assertSame( array(
+                       array( LogLevel::INFO, 'creating new user (UTSessionAutoCreate1) - from: XXX' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Check lack of permissions
+               $wgGroupPermissions['*']['createaccount'] = false;
+               $wgGroupPermissions['*']['autocreateaccount'] = false;
+               $user = User::newFromName( 'UTDoesNotExist' );
+               $this->assertFalse( $manager->autoCreateUser( $user ) );
+               $this->assertSame( 0, $user->getId() );
+               $this->assertNotSame( 'UTDoesNotExist', $user->getName() );
+               $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) );
+               $session->clear();
+               $this->assertSame( array(
+                       array( LogLevel::DEBUG, 'user is blocked from this wiki, blacklisting' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Check other permission
+               $wgGroupPermissions['*']['createaccount'] = false;
+               $wgGroupPermissions['*']['autocreateaccount'] = true;
+               $user = User::newFromName( 'UTSessionAutoCreate2' );
+               $this->assertSame( 0, $user->getId(), 'sanity check' );
+               $this->assertTrue( $manager->autoCreateUser( $user ) );
+               $this->assertNotEquals( 0, $user->getId() );
+               $this->assertSame( 'UTSessionAutoCreate2', $user->getName() );
+               $this->assertEquals(
+                       $user->getId(), User::idFromName( 'UTSessionAutoCreate2', User::READ_LATEST )
+               );
+               $this->assertSame( array(
+                       array( LogLevel::INFO, 'creating new user (UTSessionAutoCreate2) - from: XXX' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Test account-creation block
+               $anon = new User;
+               $block = new \Block( array(
+                       'address' => $anon->getName(),
+                       'user' => $id,
+                       'reason' => __METHOD__,
+                       'expiry' => time() + 100500,
+                       'createAccount' => true,
+               ) );
+               $block->insert();
+               $this->assertInstanceOf( 'Block', $anon->isBlockedFromCreateAccount(), 'sanity check' );
+               $reset2 = new \ScopedCallback( array( $block, 'delete' ) );
+               $user = User::newFromName( 'UTDoesNotExist' );
+               $this->assertFalse( $manager->autoCreateUser( $user ) );
+               $this->assertSame( 0, $user->getId() );
+               $this->assertNotSame( 'UTDoesNotExist', $user->getName() );
+               $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) );
+               \ScopedCallback::consume( $reset2 );
+               $session->clear();
+               $this->assertSame( array(
+                       array( LogLevel::DEBUG, 'user is blocked from this wiki, blacklisting' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Sanity check that creation still works
+               $user = User::newFromName( 'UTSessionAutoCreate3' );
+               $this->assertSame( 0, $user->getId(), 'sanity check' );
+               $this->assertTrue( $manager->autoCreateUser( $user ) );
+               $this->assertNotEquals( 0, $user->getId() );
+               $this->assertSame( 'UTSessionAutoCreate3', $user->getName() );
+               $this->assertEquals(
+                       $user->getId(), User::idFromName( 'UTSessionAutoCreate3', User::READ_LATEST )
+               );
+               $this->assertSame( array(
+                       array( LogLevel::INFO, 'creating new user (UTSessionAutoCreate3) - from: XXX' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Test prevention by AuthPlugin
+               global $wgAuth;
+               $oldWgAuth = $wgAuth;
+               $mockWgAuth = $this->getMock( 'AuthPlugin', array( 'autoCreate' ) );
+               $mockWgAuth->expects( $this->once() )->method( 'autoCreate' )
+                       ->will( $this->returnValue( false ) );
+               $this->setMwGlobals( array(
+                       'wgAuth' => $mockWgAuth,
+               ) );
+               $user = User::newFromName( 'UTDoesNotExist' );
+               $this->assertFalse( $manager->autoCreateUser( $user ) );
+               $this->assertSame( 0, $user->getId() );
+               $this->assertNotSame( 'UTDoesNotExist', $user->getName() );
+               $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) );
+               $this->setMwGlobals( array(
+                       'wgAuth' => $oldWgAuth,
+               ) );
+               $session->clear();
+               $this->assertSame( array(
+                       array( LogLevel::DEBUG, 'denied by AuthPlugin' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Test prevention by wfReadOnly()
+               $this->setMwGlobals( array(
+                       'wgReadOnly' => 'Because',
+               ) );
+               $user = User::newFromName( 'UTDoesNotExist' );
+               $this->assertFalse( $manager->autoCreateUser( $user ) );
+               $this->assertSame( 0, $user->getId() );
+               $this->assertNotSame( 'UTDoesNotExist', $user->getName() );
+               $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) );
+               $this->setMwGlobals( array(
+                       'wgReadOnly' => false,
+               ) );
+               $session->clear();
+               $this->assertSame( array(
+                       array( LogLevel::DEBUG, 'denied by wfReadOnly()' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Test prevention by a previous session
+               $session->set( 'MWSession::AutoCreateBlacklist', 'test' );
+               $user = User::newFromName( 'UTDoesNotExist' );
+               $this->assertFalse( $manager->autoCreateUser( $user ) );
+               $this->assertSame( 0, $user->getId() );
+               $this->assertNotSame( 'UTDoesNotExist', $user->getName() );
+               $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) );
+               $session->clear();
+               $this->assertSame( array(
+                       array( LogLevel::DEBUG, 'blacklisted in session (test)' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Test uncreatable name
+               $user = User::newFromName( 'UTDoesNotExist@' );
+               $this->assertFalse( $manager->autoCreateUser( $user ) );
+               $this->assertSame( 0, $user->getId() );
+               $this->assertNotSame( 'UTDoesNotExist@', $user->getName() );
+               $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) );
+               $session->clear();
+               $this->assertSame( array(
+                       array( LogLevel::DEBUG, 'Invalid username, blacklisting' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Test AbortAutoAccount hook
+               $mock = $this->getMock( __CLASS__, array( 'onAbortAutoAccount' ) );
+               $mock->expects( $this->once() )->method( 'onAbortAutoAccount' )
+                       ->will( $this->returnCallback( function ( User $user, &$msg ) {
+                               $msg = 'No way!';
+                               return false;
+                       } ) );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'AbortAutoAccount' => array( $mock ) ) );
+               $user = User::newFromName( 'UTDoesNotExist' );
+               $this->assertFalse( $manager->autoCreateUser( $user ) );
+               $this->assertSame( 0, $user->getId() );
+               $this->assertNotSame( 'UTDoesNotExist', $user->getName() );
+               $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'AbortAutoAccount' => array() ) );
+               $session->clear();
+               $this->assertSame( array(
+                       array( LogLevel::DEBUG, 'denied by hook: No way!' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Test AbortAutoAccount hook screwing up the name
+               $mock = $this->getMock( 'stdClass', array( 'onAbortAutoAccount' ) );
+               $mock->expects( $this->once() )->method( 'onAbortAutoAccount' )
+                       ->will( $this->returnCallback( function ( User $user ) {
+                               $user->setName( 'UTDoesNotExistEither' );
+                       } ) );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'AbortAutoAccount' => array( $mock ) ) );
+               try {
+                       $user = User::newFromName( 'UTDoesNotExist' );
+                       $manager->autoCreateUser( $user );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \UnexpectedValueException $ex ) {
+                       $this->assertSame(
+                               'AbortAutoAccount hook tried to change the user name',
+                               $ex->getMessage()
+                       );
+               }
+               $this->assertSame( 0, $user->getId() );
+               $this->assertNotSame( 'UTDoesNotExist', $user->getName() );
+               $this->assertNotSame( 'UTDoesNotExistEither', $user->getName() );
+               $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) );
+               $this->assertEquals( 0, User::idFromName( 'UTDoesNotExistEither', User::READ_LATEST ) );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'AbortAutoAccount' => array() ) );
+               $session->clear();
+               $this->assertSame( array(), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Test for "exception backoff"
+               $user = User::newFromName( 'UTDoesNotExist' );
+               $cache = \ObjectCache::getLocalClusterInstance();
+               $backoffKey = wfMemcKey( 'MWSession', 'autocreate-failed', md5( $user->getName() ) );
+               $cache->set( $backoffKey, 1, 60 * 10 );
+               $this->assertFalse( $manager->autoCreateUser( $user ) );
+               $this->assertSame( 0, $user->getId() );
+               $this->assertNotSame( 'UTDoesNotExist', $user->getName() );
+               $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) );
+               $cache->delete( $backoffKey );
+               $session->clear();
+               $this->assertSame( array(
+                       array( LogLevel::DEBUG, 'denied by prior creation attempt failures' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Sanity check that creation still works, and test completion hook
+               $cb = $this->callback( function ( User $user ) use ( $that ) {
+                       $that->assertNotEquals( 0, $user->getId() );
+                       $that->assertSame( 'UTSessionAutoCreate4', $user->getName() );
+                       $that->assertEquals(
+                               $user->getId(), User::idFromName( 'UTSessionAutoCreate4', User::READ_LATEST )
+                       );
+                       return true;
+               } );
+               $mock = $this->getMock( 'stdClass',
+                       array( 'onAuthPluginAutoCreate', 'onLocalUserCreated' ) );
+               $mock->expects( $this->once() )->method( 'onAuthPluginAutoCreate' )
+                       ->with( $cb );
+               $mock->expects( $this->once() )->method( 'onLocalUserCreated' )
+                       ->with( $cb, $this->identicalTo( true ) );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array(
+                       'AuthPluginAutoCreate' => array( $mock ),
+                       'LocalUserCreated' => array( $mock ),
+               ) );
+               $user = User::newFromName( 'UTSessionAutoCreate4' );
+               $this->assertSame( 0, $user->getId(), 'sanity check' );
+               $this->assertTrue( $manager->autoCreateUser( $user ) );
+               $this->assertNotEquals( 0, $user->getId() );
+               $this->assertSame( 'UTSessionAutoCreate4', $user->getName() );
+               $this->assertEquals(
+                       $user->getId(),
+                       User::idFromName( 'UTSessionAutoCreate4', User::READ_LATEST )
+               );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array(
+                       'AuthPluginAutoCreate' => array(),
+                       'LocalUserCreated' => array(),
+               ) );
+               $this->assertSame( array(
+                       array( LogLevel::INFO, 'creating new user (UTSessionAutoCreate4) - from: XXX' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+       }
+
+       public function onAbortAutoAccount( User $user, &$msg ) {
+       }
+
+       public function testPreventSessionsForUser() {
+               $manager = $this->getManager();
+
+               $providerBuilder = $this->getMockBuilder( 'DummySessionProvider' )
+                       ->setMethods( array( 'preventSessionsForUser', '__toString' ) );
+
+               $provider1 = $providerBuilder->getMock();
+               $provider1->expects( $this->once() )->method( 'preventSessionsForUser' )
+                       ->with( $this->equalTo( 'UTSysop' ) );
+               $provider1->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'MockProvider1' ) );
+
+               $this->config->set( 'SessionProviders', array(
+                       $this->objectCacheDef( $provider1 ),
+               ) );
+
+               $user = User::newFromName( 'UTSysop' );
+               $token = $user->getToken( true );
+
+               $this->assertFalse( $manager->isUserSessionPrevented( 'UTSysop' ) );
+               $manager->preventSessionsForUser( 'UTSysop' );
+               $this->assertNotEquals( $token, User::newFromName( 'UTSysop' )->getToken() );
+               $this->assertTrue( $manager->isUserSessionPrevented( 'UTSysop' ) );
+       }
+
+       public function testLoadSessionInfoFromStore() {
+               $manager = $this->getManager();
+               $logger = new \TestLogger( true, function ( $m ) {
+                       return preg_replace(
+                               '/^Session \[\d+\]\w+<(?:null|anon|[+-]:\d+:\w+)>\w+: /', 'Session X: ', $m
+                       );
+               } );
+               $manager->setLogger( $logger );
+               $request = new \FauxRequest();
+
+               // TestingAccessWrapper can't handle methods with reference arguments, sigh.
+               $rClass = new \ReflectionClass( $manager );
+               $rMethod = $rClass->getMethod( 'loadSessionInfoFromStore' );
+               $rMethod->setAccessible( true );
+               $loadSessionInfoFromStore = function ( &$info ) use ( $rMethod, $manager, $request ) {
+                       return $rMethod->invokeArgs( $manager, array( &$info, $request ) );
+               };
+
+               $userInfo = UserInfo::newFromName( 'UTSysop', true );
+               $unverifiedUserInfo = UserInfo::newFromName( 'UTSysop', false );
+
+               $id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+               $metadata = array(
+                       'userId' => $userInfo->getId(),
+                       'userName' => $userInfo->getName(),
+                       'userToken' => $userInfo->getToken( true ),
+                       'provider' => 'Mock',
+               );
+
+               $builder = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' )
+                       ->setMethods( array( '__toString', 'mergeMetadata', 'refreshSessionInfo' ) );
+
+               $provider = $builder->getMockForAbstractClass();
+               $provider->setManager( $manager );
+               $provider->expects( $this->any() )->method( 'persistsSessionId' )
+                       ->will( $this->returnValue( true ) );
+               $provider->expects( $this->any() )->method( 'canChangeUser' )
+                       ->will( $this->returnValue( true ) );
+               $provider->expects( $this->any() )->method( 'refreshSessionInfo' )
+                       ->will( $this->returnValue( true ) );
+               $provider->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'Mock' ) );
+               $provider->expects( $this->any() )->method( 'mergeMetadata' )
+                       ->will( $this->returnCallback( function ( $a, $b ) {
+                               if ( $b === array( 'Throw' ) ) {
+                                       throw new \UnexpectedValueException( 'no merge!' );
+                               }
+                               return array( 'Merged' );
+                       } ) );
+
+               $provider2 = $builder->getMockForAbstractClass();
+               $provider2->setManager( $manager );
+               $provider2->expects( $this->any() )->method( 'persistsSessionId' )
+                       ->will( $this->returnValue( false ) );
+               $provider2->expects( $this->any() )->method( 'canChangeUser' )
+                       ->will( $this->returnValue( false ) );
+               $provider2->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'Mock2' ) );
+               $provider2->expects( $this->any() )->method( 'refreshSessionInfo' )
+                       ->will( $this->returnCallback( function ( $info, $request, &$metadata ) {
+                               $metadata['changed'] = true;
+                               return true;
+                       } ) );
+
+               $provider3 = $builder->getMockForAbstractClass();
+               $provider3->setManager( $manager );
+               $provider3->expects( $this->any() )->method( 'persistsSessionId' )
+                       ->will( $this->returnValue( true ) );
+               $provider3->expects( $this->any() )->method( 'canChangeUser' )
+                       ->will( $this->returnValue( true ) );
+               $provider3->expects( $this->once() )->method( 'refreshSessionInfo' )
+                       ->will( $this->returnValue( false ) );
+               $provider3->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'Mock3' ) );
+
+               \TestingAccessWrapper::newFromObject( $manager )->sessionProviders = array(
+                       (string)$provider => $provider,
+                       (string)$provider2 => $provider2,
+                       (string)$provider3 => $provider3,
+               );
+
+               // No metadata, basic usage
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertFalse( $info->isIdSafe() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertTrue( $info->isIdSafe(), 'sanity check' );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider2,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // Unverified user, no metadata
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $unverifiedUserInfo
+               ) );
+               $this->assertSame( $unverifiedUserInfo, $info->getUserInfo() );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: Unverified user provided and no metadata to auth it' )
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // No metadata, missing data
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: Null provider and no metadata' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+               ) );
+               $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\UserInfo', $info->getUserInfo() );
+               $this->assertTrue( $info->getUserInfo()->isVerified() );
+               $this->assertTrue( $info->getUserInfo()->isAnon() );
+               $this->assertFalse( $info->isIdSafe() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider2,
+                       'id' => $id,
+               ) );
+               $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::INFO, 'Session X: No user provided and provider cannot set user' )
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Incomplete/bad metadata
+               $this->store->setRawSession( $id, true );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: Bad data' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               $this->store->setRawSession( $id, array( 'data' => array() ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: Bad data structure' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               $this->store->deleteSession( $id );
+               $this->store->setRawSession( $id, array( 'metadata' => $metadata ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: Bad data structure' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               $this->store->setRawSession( $id, array( 'metadata' => $metadata, 'data' => true ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: Bad data structure' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               $this->store->setRawSession( $id, array( 'metadata' => true, 'data' => array() ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: Bad data structure' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               foreach ( $metadata as $key => $dummy ) {
+                       $tmp = $metadata;
+                       unset( $tmp[$key] );
+                       $this->store->setRawSession( $id, array( 'metadata' => $tmp, 'data' => array() ) );
+                       $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+                       $this->assertSame( array(
+                               array( LogLevel::WARNING, 'Session X: Bad metadata' ),
+                       ), $logger->getBuffer() );
+                       $logger->clearBuffer();
+               }
+
+               // Basic usage with metadata
+               $this->store->setRawSession( $id, array( 'metadata' => $metadata, 'data' => array() ) );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // Mismatched provider
+               $this->store->setSessionMeta( $id, array( 'provider' => 'Bad' ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: Wrong provider, Bad !== Mock' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Unknown provider
+               $this->store->setSessionMeta( $id, array( 'provider' => 'Bad' ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: Unknown provider, Bad' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Fill in provider
+               $this->store->setSessionMeta( $id, $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // Bad user metadata
+               $this->store->setSessionMeta( $id, array( 'userId' => -1, 'userToken' => null ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::ERROR, 'Session X: Invalid ID' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               $this->store->setSessionMeta(
+                       $id, array( 'userId' => 0, 'userName' => '<X>', 'userToken' => null ) + $metadata
+               );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::ERROR, 'Session X: Invalid user name' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Mismatched user by ID
+               $this->store->setSessionMeta(
+                       $id, array( 'userId' => $userInfo->getId() + 1, 'userToken' => null ) + $metadata
+               );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: User ID mismatch, 2 !== 1' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Mismatched user by name
+               $this->store->setSessionMeta(
+                       $id, array( 'userId' => 0, 'userName' => 'X', 'userToken' => null ) + $metadata
+               );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: User name mismatch, X !== UTSysop' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // ID matches, name doesn't
+               $this->store->setSessionMeta(
+                       $id, array( 'userId' => $userInfo->getId(), 'userName' => 'X', 'userToken' => null ) + $metadata
+               );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array(
+                               LogLevel::WARNING, 'Session X: User ID matched but name didn\'t (rename?), X !== UTSysop'
+                       ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Mismatched anon user
+               $this->store->setSessionMeta(
+                       $id, array( 'userId' => 0, 'userName' => null, 'userToken' => null ) + $metadata
+               );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array(
+                               LogLevel::WARNING, 'Session X: Metadata has an anonymous user, but a non-anon user was provided'
+                       ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Lookup user by ID
+               $this->store->setSessionMeta( $id, array( 'userToken' => null ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+               ) );
+               $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( $userInfo->getId(), $info->getUserInfo()->getId() );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // Lookup user by name
+               $this->store->setSessionMeta(
+                       $id, array( 'userId' => 0, 'userName' => 'UTSysop', 'userToken' => null ) + $metadata
+               );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+               ) );
+               $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( $userInfo->getId(), $info->getUserInfo()->getId() );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // Lookup anonymous user
+               $this->store->setSessionMeta(
+                       $id, array( 'userId' => 0, 'userName' => null, 'userToken' => null ) + $metadata
+               );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+               ) );
+               $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->getUserInfo()->isAnon() );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // Unverified user with metadata
+               $this->store->setSessionMeta( $id, $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $unverifiedUserInfo
+               ) );
+               $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->getUserInfo()->isVerified() );
+               $this->assertSame( $unverifiedUserInfo->getId(), $info->getUserInfo()->getId() );
+               $this->assertSame( $unverifiedUserInfo->getName(), $info->getUserInfo()->getName() );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // Unverified user with metadata
+               $this->store->setSessionMeta( $id, $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $unverifiedUserInfo
+               ) );
+               $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->getUserInfo()->isVerified() );
+               $this->assertSame( $unverifiedUserInfo->getId(), $info->getUserInfo()->getId() );
+               $this->assertSame( $unverifiedUserInfo->getName(), $info->getUserInfo()->getName() );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // Wrong token
+               $this->store->setSessionMeta( $id, array( 'userToken' => 'Bad' ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: User token mismatch' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Provider metadata
+               $this->store->setSessionMeta( $id, array( 'provider' => 'Mock2' ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider2,
+                       'id' => $id,
+                       'userInfo' => $userInfo,
+                       'metadata' => array( 'Info' ),
+               ) );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array( 'Info', 'changed' => true ), $info->getProviderMetadata() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               $this->store->setSessionMeta( $id, array( 'providerMetadata' => array( 'Saved' ) ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo,
+               ) );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array( 'Saved' ), $info->getProviderMetadata() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo,
+                       'metadata' => array( 'Info' ),
+               ) );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array( 'Merged' ), $info->getProviderMetadata() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo,
+                       'metadata' => array( 'Throw' ),
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: Metadata merge failed: no merge!' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Remember from session
+               $this->store->setSessionMeta( $id, $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+               ) );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertFalse( $info->wasRemembered() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               $this->store->setSessionMeta( $id, array( 'remember' => true ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+               ) );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->wasRemembered() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               $this->store->setSessionMeta( $id, array( 'remember' => false ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->wasRemembered() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // forceHTTPS from session
+               $this->store->setSessionMeta( $id, $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertFalse( $info->forceHTTPS() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               $this->store->setSessionMeta( $id, array( 'forceHTTPS' => true ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->forceHTTPS() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               $this->store->setSessionMeta( $id, array( 'forceHTTPS' => false ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo,
+                       'forceHTTPS' => true
+               ) );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->forceHTTPS() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // "Persist" flag from session
+               $this->store->setSessionMeta( $id, $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertFalse( $info->wasPersisted() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               $this->store->setSessionMeta( $id, array( 'persisted' => true ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->wasPersisted() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               $this->store->setSessionMeta( $id, array( 'persisted' => false ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo,
+                       'persisted' => true
+               ) );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->wasPersisted() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // Provider refreshSessionInfo() returning false
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider3,
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // Hook
+               $that = $this;
+               $called = false;
+               $data = array( 'foo' => 1 );
+               $this->store->setSession( $id, array( 'metadata' => $metadata, 'data' => $data ) );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array(
+                       'SessionCheckInfo' => array( function ( &$reason, $i, $r, $m, $d ) use (
+                               $that, $info, $metadata, $data, $request, &$called
+                       ) {
+                               $that->assertSame( $info->getId(), $i->getId() );
+                               $that->assertSame( $info->getProvider(), $i->getProvider() );
+                               $that->assertSame( $info->getUserInfo(), $i->getUserInfo() );
+                               $that->assertSame( $request, $r );
+                               $that->assertEquals( $metadata, $m );
+                               $that->assertEquals( $data, $d );
+                               $called = true;
+                               return false;
+                       } )
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $called );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: Hook aborted' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+       }
+
+}
diff --git a/tests/phpunit/includes/session/SessionProviderTest.php b/tests/phpunit/includes/session/SessionProviderTest.php
new file mode 100644 (file)
index 0000000..d7aebcd
--- /dev/null
@@ -0,0 +1,195 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\SessionProvider
+ */
+class SessionProviderTest extends MediaWikiTestCase {
+
+       public function testBasics() {
+               $manager = new SessionManager();
+               $logger = new \TestLogger();
+               $config = new \HashConfig();
+
+               $provider = $this->getMockForAbstractClass( 'MediaWiki\\Session\\SessionProvider' );
+               $priv = \TestingAccessWrapper::newFromObject( $provider );
+
+               $provider->setConfig( $config );
+               $this->assertSame( $config, $priv->config );
+               $provider->setLogger( $logger );
+               $this->assertSame( $logger, $priv->logger );
+               $provider->setManager( $manager );
+               $this->assertSame( $manager, $priv->manager );
+               $this->assertSame( $manager, $provider->getManager() );
+
+               $this->assertSame( array(), $provider->getVaryHeaders() );
+               $this->assertSame( array(), $provider->getVaryCookies() );
+               $this->assertSame( null, $provider->suggestLoginUsername( new \FauxRequest ) );
+
+               $this->assertSame( get_class( $provider ), (string)$provider );
+
+               $this->assertNull( $provider->whyNoSession() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+                       'provider' => $provider,
+               ) );
+               $metadata = array( 'foo' );
+               $this->assertTrue( $provider->refreshSessionInfo( $info, new \FauxRequest, $metadata ) );
+               $this->assertSame( array( 'foo' ), $metadata );
+       }
+
+       /**
+        * @dataProvider provideNewSessionInfo
+        * @param bool $persistId Return value for ->persistsSessionId()
+        * @param bool $persistUser Return value for ->persistsSessionUser()
+        * @param bool $ok Whether a SessionInfo is provided
+        */
+       public function testNewSessionInfo( $persistId, $persistUser, $ok ) {
+               $manager = new SessionManager();
+
+               $provider = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' )
+                       ->setMethods( array( 'canChangeUser', 'persistsSessionId' ) )
+                       ->getMockForAbstractClass();
+               $provider->expects( $this->any() )->method( 'persistsSessionId' )
+                       ->will( $this->returnValue( $persistId ) );
+               $provider->expects( $this->any() )->method( 'canChangeUser' )
+                       ->will( $this->returnValue( $persistUser ) );
+               $provider->setManager( $manager );
+
+               if ( $ok ) {
+                       $info = $provider->newSessionInfo();
+                       $this->assertNotNull( $info );
+                       $this->assertFalse( $info->wasPersisted() );
+                       $this->assertTrue( $info->isIdSafe() );
+
+                       $id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+                       $info = $provider->newSessionInfo( $id );
+                       $this->assertNotNull( $info );
+                       $this->assertSame( $id, $info->getId() );
+                       $this->assertFalse( $info->wasPersisted() );
+                       $this->assertTrue( $info->isIdSafe() );
+               } else {
+                       $this->assertNull( $provider->newSessionInfo() );
+               }
+       }
+
+       public function testMergeMetadata() {
+               $provider = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' )
+                       ->getMockForAbstractClass();
+
+               try {
+                       $provider->mergeMetadata(
+                               array( 'foo' => 1, 'baz' => 3 ),
+                               array( 'bar' => 2, 'baz' => '3' )
+                       );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \UnexpectedValueException $ex ) {
+                       $this->assertSame( 'Key "baz" changed', $ex->getMessage() );
+               }
+
+               $res = $provider->mergeMetadata(
+                       array( 'foo' => 1, 'baz' => 3 ),
+                       array( 'bar' => 2, 'baz' => 3 )
+               );
+               $this->assertSame( array( 'bar' => 2, 'baz' => 3 ), $res );
+       }
+
+       public static function provideNewSessionInfo() {
+               return array(
+                       array( false, false, false ),
+                       array( true, false, false ),
+                       array( false, true, false ),
+                       array( true, true, true ),
+               );
+       }
+
+       public function testImmutableSessions() {
+               $provider = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' )
+                       ->setMethods( array( 'canChangeUser', 'persistsSessionId' ) )
+                       ->getMockForAbstractClass();
+               $provider->expects( $this->any() )->method( 'canChangeUser' )
+                       ->will( $this->returnValue( true ) );
+               $provider->preventSessionsForUser( 'Foo' );
+
+               $provider = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' )
+                       ->setMethods( array( 'canChangeUser', 'persistsSessionId' ) )
+                       ->getMockForAbstractClass();
+               $provider->expects( $this->any() )->method( 'canChangeUser' )
+                       ->will( $this->returnValue( false ) );
+               try {
+                       $provider->preventSessionsForUser( 'Foo' );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \BadMethodCallException $ex ) {
+               }
+
+       }
+
+       public function testHashToSessionId() {
+               $config = new \HashConfig( array(
+                       'SecretKey' => 'Shhh!',
+               ) );
+
+               $provider = $this->getMockForAbstractClass( 'MediaWiki\\Session\\SessionProvider',
+                       array(), 'MockSessionProvider' );
+               $provider->setConfig( $config );
+               $priv = \TestingAccessWrapper::newFromObject( $provider );
+
+               $this->assertSame( 'eoq8cb1mg7j30ui5qolafps4hg29k5bb', $priv->hashToSessionId( 'foobar' ) );
+               $this->assertSame( '4do8j7tfld1g8tte9jqp3csfgmulaun9',
+                       $priv->hashToSessionId( 'foobar', 'secret' ) );
+
+               try {
+                       $priv->hashToSessionId( array() );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               '$data must be a string, array was passed',
+                               $ex->getMessage()
+                       );
+               }
+               try {
+                       $priv->hashToSessionId( '', false );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               '$key must be a string or null, boolean was passed',
+                               $ex->getMessage()
+                       );
+               }
+       }
+
+       public function testDescribe() {
+               $provider = $this->getMockForAbstractClass( 'MediaWiki\\Session\\SessionProvider',
+                       array(), 'MockSessionProvider' );
+
+               $this->assertSame(
+                       'MockSessionProvider sessions',
+                       $provider->describe( \Language::factory( 'en' ) )
+               );
+       }
+
+       public function testGetAllowedUserRights() {
+               $provider = $this->getMockForAbstractClass( 'MediaWiki\\Session\\SessionProvider' );
+               $backend = TestUtils::getDummySessionBackend();
+
+               try {
+                       $provider->getAllowedUserRights( $backend );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'Backend\'s provider isn\'t $this',
+                               $ex->getMessage()
+                       );
+               }
+
+               \TestingAccessWrapper::newFromObject( $backend )->provider = $provider;
+               $this->assertNull( $provider->getAllowedUserRights( $backend ) );
+       }
+
+}
diff --git a/tests/phpunit/includes/session/SessionTest.php b/tests/phpunit/includes/session/SessionTest.php
new file mode 100644 (file)
index 0000000..858996d
--- /dev/null
@@ -0,0 +1,249 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+use User;
+
+/**
+ * @group Session
+ * @covers MediaWiki\Session\Session
+ */
+class SessionTest extends MediaWikiTestCase {
+
+       public function testConstructor() {
+               $backend = TestUtils::getDummySessionBackend();
+               \TestingAccessWrapper::newFromObject( $backend )->requests = array( -1 => 'dummy' );
+               \TestingAccessWrapper::newFromObject( $backend )->id = new SessionId( 'abc' );
+
+               $session = new Session( $backend, 42 );
+               $priv = \TestingAccessWrapper::newFromObject( $session );
+               $this->assertSame( $backend, $priv->backend );
+               $this->assertSame( 42, $priv->index );
+
+               $request = new \FauxRequest();
+               $priv2 = \TestingAccessWrapper::newFromObject( $session->sessionWithRequest( $request ) );
+               $this->assertSame( $backend, $priv2->backend );
+               $this->assertNotSame( $priv->index, $priv2->index );
+               $this->assertSame( $request, $priv2->getRequest() );
+       }
+
+       /**
+        * @dataProvider provideMethods
+        * @param string $m Method to test
+        * @param array $args Arguments to pass to the method
+        * @param bool $index Whether the backend method gets passed the index
+        * @param bool $ret Whether the method returns a value
+        */
+       public function testMethods( $m, $args, $index, $ret ) {
+               $mock = $this->getMock( 'MediaWiki\\Session\\DummySessionBackend',
+                       array( $m, 'deregisterSession' ) );
+               $mock->expects( $this->once() )->method( 'deregisterSession' )
+                       ->with( $this->identicalTo( 42 ) );
+
+               $tmp = $mock->expects( $this->once() )->method( $m );
+               $expectArgs = array();
+               if ( $index ) {
+                       $expectArgs[] = $this->identicalTo( 42 );
+               }
+               foreach ( $args as $arg ) {
+                       $expectArgs[] = $this->identicalTo( $arg );
+               }
+               $tmp = call_user_func_array( array( $tmp, 'with' ), $expectArgs );
+
+               $retval = new \stdClass;
+               $tmp->will( $this->returnValue( $retval ) );
+
+               $session = TestUtils::getDummySession( $mock, 42 );
+
+               if ( $ret ) {
+                       $this->assertSame( $retval, call_user_func_array( array( $session, $m ), $args ) );
+               } else {
+                       $this->assertNull( call_user_func_array( array( $session, $m ), $args ) );
+               }
+
+               // Trigger Session destructor
+               $session = null;
+       }
+
+       public static function provideMethods() {
+               return array(
+                       array( 'getId', array(), false, true ),
+                       array( 'getSessionId', array(), false, true ),
+                       array( 'resetId', array(), false, true ),
+                       array( 'getProvider', array(), false, true ),
+                       array( 'isPersistent', array(), false, true ),
+                       array( 'persist', array(), false, false ),
+                       array( 'shouldRememberUser', array(), false, true ),
+                       array( 'setRememberUser', array( true ), false, false ),
+                       array( 'getRequest', array(), true, true ),
+                       array( 'getUser', array(), false, true ),
+                       array( 'getAllowedUserRights', array(), false, true ),
+                       array( 'canSetUser', array(), false, true ),
+                       array( 'setUser', array( new \stdClass ), false, false ),
+                       array( 'suggestLoginUsername', array(), true, true ),
+                       array( 'shouldForceHTTPS', array(), false, true ),
+                       array( 'setForceHTTPS', array( true ), false, false ),
+                       array( 'getLoggedOutTimestamp', array(), false, true ),
+                       array( 'setLoggedOutTimestamp', array( 123 ), false, false ),
+                       array( 'getProviderMetadata', array(), false, true ),
+                       array( 'save', array(), false, false ),
+                       array( 'delaySave', array(), false, true ),
+                       array( 'renew', array(), false, false ),
+               );
+       }
+
+       public function testDataAccess() {
+               $session = TestUtils::getDummySession();
+               $backend = \TestingAccessWrapper::newFromObject( $session )->backend;
+
+               $this->assertEquals( 1, $session->get( 'foo' ) );
+               $this->assertEquals( 'zero', $session->get( 0 ) );
+               $this->assertFalse( $backend->dirty );
+
+               $this->assertEquals( null, $session->get( 'null' ) );
+               $this->assertEquals( 'default', $session->get( 'null', 'default' ) );
+               $this->assertFalse( $backend->dirty );
+
+               $session->set( 'foo', 55 );
+               $this->assertEquals( 55, $backend->data['foo'] );
+               $this->assertTrue( $backend->dirty );
+               $backend->dirty = false;
+
+               $session->set( 1, 'one' );
+               $this->assertEquals( 'one', $backend->data[1] );
+               $this->assertTrue( $backend->dirty );
+               $backend->dirty = false;
+
+               $session->set( 1, 'one' );
+               $this->assertFalse( $backend->dirty );
+
+               $this->assertTrue( $session->exists( 'foo' ) );
+               $this->assertTrue( $session->exists( 1 ) );
+               $this->assertFalse( $session->exists( 'null' ) );
+               $this->assertFalse( $session->exists( 100 ) );
+               $this->assertFalse( $backend->dirty );
+
+               $session->remove( 'foo' );
+               $this->assertArrayNotHasKey( 'foo', $backend->data );
+               $this->assertTrue( $backend->dirty );
+               $backend->dirty = false;
+               $session->remove( 1 );
+               $this->assertArrayNotHasKey( 1, $backend->data );
+               $this->assertTrue( $backend->dirty );
+               $backend->dirty = false;
+
+               $session->remove( 101 );
+               $this->assertFalse( $backend->dirty );
+
+               $backend->data = array( 'a', 'b', '?' => 'c' );
+               $this->assertSame( 3, $session->count() );
+               $this->assertSame( 3, count( $session ) );
+               $this->assertFalse( $backend->dirty );
+
+               $data = array();
+               foreach ( $session as $key => $value ) {
+                       $data[$key] = $value;
+               }
+               $this->assertEquals( $backend->data, $data );
+               $this->assertFalse( $backend->dirty );
+
+               $this->assertEquals( $backend->data, iterator_to_array( $session ) );
+               $this->assertFalse( $backend->dirty );
+       }
+
+       public function testClear() {
+               $session = TestUtils::getDummySession();
+               $priv = \TestingAccessWrapper::newFromObject( $session );
+
+               $backend = $this->getMock(
+                       'MediaWiki\\Session\\DummySessionBackend', array( 'canSetUser', 'setUser', 'save' )
+               );
+               $backend->expects( $this->once() )->method( 'canSetUser' )
+                       ->will( $this->returnValue( true ) );
+               $backend->expects( $this->once() )->method( 'setUser' )
+                       ->with( $this->callback( function ( $user ) {
+                               return $user instanceof User && $user->isAnon();
+                       } ) );
+               $backend->expects( $this->once() )->method( 'save' );
+               $priv->backend = $backend;
+               $session->clear();
+               $this->assertSame( array(), $backend->data );
+               $this->assertTrue( $backend->dirty );
+
+               $backend = $this->getMock(
+                       'MediaWiki\\Session\\DummySessionBackend', array( 'canSetUser', 'setUser', 'save' )
+               );
+               $backend->data = array();
+               $backend->expects( $this->once() )->method( 'canSetUser' )
+                       ->will( $this->returnValue( true ) );
+               $backend->expects( $this->once() )->method( 'setUser' )
+                       ->with( $this->callback( function ( $user ) {
+                               return $user instanceof User && $user->isAnon();
+                       } ) );
+               $backend->expects( $this->once() )->method( 'save' );
+               $priv->backend = $backend;
+               $session->clear();
+               $this->assertFalse( $backend->dirty );
+
+               $backend = $this->getMock(
+                       'MediaWiki\\Session\\DummySessionBackend', array( 'canSetUser', 'setUser', 'save' )
+               );
+               $backend->expects( $this->once() )->method( 'canSetUser' )
+                       ->will( $this->returnValue( false ) );
+               $backend->expects( $this->never() )->method( 'setUser' );
+               $backend->expects( $this->once() )->method( 'save' );
+               $priv->backend = $backend;
+               $session->clear();
+               $this->assertSame( array(), $backend->data );
+               $this->assertTrue( $backend->dirty );
+       }
+
+       public function testTokens() {
+               $rc = new \ReflectionClass( 'MediaWiki\\Session\\Session' );
+               if ( !method_exists( $rc, 'newInstanceWithoutConstructor' ) ) {
+                       $this->markTestSkipped(
+                               'ReflectionClass::newInstanceWithoutConstructor isn\'t available'
+                       );
+               }
+
+               // Instead of actually constructing the Session, we use reflection to
+               // bypass the constructor and plug a mock SessionBackend into the
+               // private fields to avoid having to actually create a SessionBackend.
+               $backend = new DummySessionBackend;
+               $session = $rc->newInstanceWithoutConstructor();
+               $priv = \TestingAccessWrapper::newFromObject( $session );
+               $priv->backend = $backend;
+               $priv->index = 42;
+
+               $token = \TestingAccessWrapper::newFromObject( $session->getToken() );
+               $this->assertArrayHasKey( 'wsTokenSecrets', $backend->data );
+               $this->assertArrayHasKey( 'default', $backend->data['wsTokenSecrets'] );
+               $secret = $backend->data['wsTokenSecrets']['default'];
+               $this->assertSame( $secret, $token->secret );
+               $this->assertSame( '', $token->salt );
+               $this->assertTrue( $token->wasNew() );
+
+               $token = \TestingAccessWrapper::newFromObject( $session->getToken( 'foo' ) );
+               $this->assertSame( $secret, $token->secret );
+               $this->assertSame( 'foo', $token->salt );
+               $this->assertFalse( $token->wasNew() );
+
+               $backend->data['wsTokenSecrets']['secret'] = 'sekret';
+               $token = \TestingAccessWrapper::newFromObject(
+                       $session->getToken( array( 'bar', 'baz' ), 'secret' )
+               );
+               $this->assertSame( 'sekret', $token->secret );
+               $this->assertSame( 'bar|baz', $token->salt );
+               $this->assertFalse( $token->wasNew() );
+
+               $session->resetToken( 'secret' );
+               $this->assertArrayHasKey( 'wsTokenSecrets', $backend->data );
+               $this->assertArrayHasKey( 'default', $backend->data['wsTokenSecrets'] );
+               $this->assertArrayNotHasKey( 'secret', $backend->data['wsTokenSecrets'] );
+
+               $session->resetAllTokens();
+               $this->assertArrayNotHasKey( 'wsTokenSecrets', $backend->data );
+
+       }
+}
diff --git a/tests/phpunit/includes/session/TestBagOStuff.php b/tests/phpunit/includes/session/TestBagOStuff.php
new file mode 100644 (file)
index 0000000..8d1b544
--- /dev/null
@@ -0,0 +1,90 @@
+<?php
+
+namespace MediaWiki\Session;
+
+/**
+ * BagOStuff with utility functions for MediaWiki\\Session\\* testing
+ */
+class TestBagOStuff extends \CachedBagOStuff {
+
+       public function __construct() {
+               parent::__construct( new \HashBagOStuff );
+       }
+
+       /**
+        * @param string $id Session ID
+        * @param array $data Session data
+        * @param int $expiry Expiry
+        * @param User $user User for metadata
+        */
+       public function setSessionData( $id, array $data, $expiry = 0, User $user = null ) {
+               $this->setSession( $id, array( 'data' => $data ), $expiry, $user );
+       }
+
+       /**
+        * @param string $id Session ID
+        * @param array $metadata Session metadata
+        * @param int $expiry Expiry
+        */
+       public function setSessionMeta( $id, array $metadata, $expiry = 0 ) {
+               $this->setSession( $id, array( 'metadata' => $metadata ), $expiry );
+       }
+
+       /**
+        * @param string $id Session ID
+        * @param array $blob Session metadata and data
+        * @param int $expiry Expiry
+        * @param User $user User for metadata
+        */
+       public function setSession( $id, array $blob, $expiry = 0, User $user = null ) {
+               $blob += array(
+                       'data' => array(),
+                       'metadata' => array(),
+               );
+               $blob['metadata'] += array(
+                       'userId' => $user ? $user->getId() : 0,
+                       'userName' => $user ? $user->getName() : null,
+                       'userToken' => $user ? $user->getToken( true ) : null,
+                       'provider' => 'DummySessionProvider',
+               );
+
+               $this->setRawSession( $id, $blob, $expiry, $user );
+       }
+
+       /**
+        * @param string $id Session ID
+        * @param array|mixed $blob Session metadata and data
+        * @param int $expiry Expiry
+        */
+       public function setRawSession( $id, $blob, $expiry = 0 ) {
+               if ( $expiry <= 0 ) {
+                       $expiry = \RequestContext::getMain()->getConfig()->get( 'ObjectCacheSessionExpiry' );
+               }
+
+               $this->set( wfMemcKey( 'MWSession', $id ), $blob, $expiry );
+       }
+
+       /**
+        * @param string $id Session ID
+        * @return mixed
+        */
+       public function getSession( $id ) {
+               return $this->get( wfMemcKey( 'MWSession', $id ) );
+       }
+
+       /**
+        * @param string $id Session ID
+        * @return mixed
+        */
+       public function getSessionFromBackend( $id ) {
+               return $this->backend->get( wfMemcKey( 'MWSession', $id ) );
+       }
+
+       /**
+        * @param string $id Session ID
+        */
+       public function deleteSession( $id ) {
+               $this->delete( wfMemcKey( 'MWSession', $id ) );
+       }
+
+}
diff --git a/tests/phpunit/includes/session/TestUtils.php b/tests/phpunit/includes/session/TestUtils.php
new file mode 100644 (file)
index 0000000..1619983
--- /dev/null
@@ -0,0 +1,99 @@
+<?php
+
+namespace MediaWiki\Session;
+
+/**
+ * Utility functions for Session unit tests
+ */
+class TestUtils {
+
+       /**
+        * Override the singleton for unit testing
+        * @param SessionManager|null $manager
+        * @return \\ScopedCallback|null
+        */
+       public static function setSessionManagerSingleton( SessionManager $manager = null ) {
+               session_write_close();
+
+               $rInstance = new \ReflectionProperty(
+                       'MediaWiki\\Session\\SessionManager', 'instance'
+               );
+               $rInstance->setAccessible( true );
+               $rGlobalSession = new \ReflectionProperty(
+                       'MediaWiki\\Session\\SessionManager', 'globalSession'
+               );
+               $rGlobalSession->setAccessible( true );
+               $rGlobalSessionRequest = new \ReflectionProperty(
+                       'MediaWiki\\Session\\SessionManager', 'globalSessionRequest'
+               );
+               $rGlobalSessionRequest->setAccessible( true );
+
+               $oldInstance = $rInstance->getValue();
+
+               $reset = array(
+                       array( $rInstance, $oldInstance ),
+                       array( $rGlobalSession, $rGlobalSession->getValue() ),
+                       array( $rGlobalSessionRequest, $rGlobalSessionRequest->getValue() ),
+               );
+
+               $rInstance->setValue( $manager );
+               $rGlobalSession->setValue( null );
+               $rGlobalSessionRequest->setValue( null );
+               if ( $manager && PHPSessionHandler::isInstalled() ) {
+                       PHPSessionHandler::install( $manager );
+               }
+
+               return new \ScopedCallback( function () use ( &$reset, $oldInstance ) {
+                       foreach ( $reset as &$arr ) {
+                               $arr[0]->setValue( $arr[1] );
+                       }
+                       if ( $oldInstance && PHPSessionHandler::isInstalled() ) {
+                               PHPSessionHandler::install( $oldInstance );
+                       }
+               } );
+       }
+
+       /**
+        * If you need a SessionBackend for testing but don't want to create a real
+        * one, use this.
+        * @return SessionBackend Unconfigured! Use reflection to set any private
+        *  fields necessary.
+        */
+       public static function getDummySessionBackend() {
+               $rc = new \ReflectionClass( 'MediaWiki\\Session\\SessionBackend' );
+               if ( !method_exists( $rc, 'newInstanceWithoutConstructor' ) ) {
+                       \PHPUnit_Framework_Assert::markTestSkipped(
+                               'ReflectionClass::newInstanceWithoutConstructor isn\'t available'
+                       );
+               }
+
+               return $rc->newInstanceWithoutConstructor();
+       }
+
+       /**
+        * If you need a Session for testing but don't want to create a backend to
+        * construct one, use this.
+        * @param object $backend Object to serve as the SessionBackend
+        * @param int $index Index
+        * @return Session
+        */
+       public static function getDummySession( $backend = null, $index = -1 ) {
+               $rc = new \ReflectionClass( 'MediaWiki\\Session\\Session' );
+               if ( !method_exists( $rc, 'newInstanceWithoutConstructor' ) ) {
+                       \PHPUnit_Framework_Assert::markTestSkipped(
+                               'ReflectionClass::newInstanceWithoutConstructor isn\'t available'
+                       );
+               }
+
+               if ( $backend === null ) {
+                       $backend = new DummySessionBackend;
+               }
+
+               $session = $rc->newInstanceWithoutConstructor();
+               $priv = \TestingAccessWrapper::newFromObject( $session );
+               $priv->backend = $backend;
+               $priv->index = $index;
+               return $session;
+       }
+
+}
diff --git a/tests/phpunit/includes/session/TokenTest.php b/tests/phpunit/includes/session/TokenTest.php
new file mode 100644 (file)
index 0000000..113f409
--- /dev/null
@@ -0,0 +1,66 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+
+/**
+ * @group Session
+ * @covers MediaWiki\Session\Token
+ */
+class TokenTest extends MediaWikiTestCase {
+
+       public function testBasics() {
+               $token = $this->getMockBuilder( 'MediaWiki\\Session\\Token' )
+                       ->setMethods( array( 'toStringAtTimestamp' ) )
+                       ->setConstructorArgs( array( 'sekret', 'salty', true ) )
+                       ->getMock();
+               $token->expects( $this->any() )->method( 'toStringAtTimestamp' )
+                       ->will( $this->returnValue( 'faketoken+\\' ) );
+
+               $this->assertSame( 'faketoken+\\', $token->toString() );
+               $this->assertSame( 'faketoken+\\', (string)$token );
+               $this->assertTrue( $token->wasNew() );
+
+               $token = new Token( 'sekret', 'salty', false );
+               $this->assertFalse( $token->wasNew() );
+       }
+
+       public function testToStringAtTimestamp() {
+               $token = \TestingAccessWrapper::newFromObject( new Token( 'sekret', 'salty', false ) );
+
+               $this->assertSame(
+                       'd9ade0c7d4349e9df9094e61c33a5a0d5644fde2+\\',
+                       $token->toStringAtTimestamp( 1447362018 )
+               );
+               $this->assertSame(
+                       'ee2f7a2488dea9176c224cfb400d43be5644fdea+\\',
+                       $token->toStringAtTimestamp( 1447362026 )
+               );
+       }
+
+       public function testGetTimestamp() {
+               $this->assertSame(
+                       1447362018, Token::getTimestamp( 'd9ade0c7d4349e9df9094e61c33a5a0d5644fde2+\\' )
+               );
+               $this->assertSame(
+                       1447362026, Token::getTimestamp( 'ee2f7a2488dea9176c224cfb400d43be5644fdea+\\' )
+               );
+               $this->assertNull( Token::getTimestamp( 'ee2f7a2488dea9176c224cfb400d43be5644fdea-\\' ) );
+               $this->assertNull( Token::getTimestamp( 'ee2f7a2488dea9176c224cfb400d43be+\\' ) );
+
+               $this->assertNull( Token::getTimestamp( 'ee2f7a2488dea9x76c224cfb400d43be5644fdea+\\' ) );
+       }
+
+       public function testMatch() {
+               $token = \TestingAccessWrapper::newFromObject( new Token( 'sekret', 'salty', false ) );
+
+               $test = $token->toStringAtTimestamp( time() - 10 );
+               $this->assertTrue( $token->match( $test ) );
+               $this->assertTrue( $token->match( $test, 12 ) );
+               $this->assertFalse( $token->match( $test, 8 ) );
+
+               $this->assertFalse( $token->match( 'ee2f7a2488dea9176c224cfb400d43be5644fdea-\\' ) );
+       }
+
+}
diff --git a/tests/phpunit/includes/session/UserInfoTest.php b/tests/phpunit/includes/session/UserInfoTest.php
new file mode 100644 (file)
index 0000000..c38edd6
--- /dev/null
@@ -0,0 +1,186 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+use User;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\UserInfo
+ */
+class UserInfoTest extends MediaWikiTestCase {
+
+       public function testNewAnonymous() {
+               $userinfo = UserInfo::newAnonymous();
+
+               $this->assertTrue( $userinfo->isAnon() );
+               $this->assertTrue( $userinfo->isVerified() );
+               $this->assertSame( 0, $userinfo->getId() );
+               $this->assertSame( null, $userinfo->getName() );
+               $this->assertSame( '', $userinfo->getToken() );
+               $this->assertNotNull( $userinfo->getUser() );
+               $this->assertSame( $userinfo, $userinfo->verified() );
+               $this->assertSame( '<anon>', (string)$userinfo );
+       }
+
+       public function testNewFromId() {
+               $id = wfGetDB( DB_MASTER )->selectField( 'user', 'MAX(user_id)' ) + 1;
+               try {
+                       UserInfo::newFromId( $id );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid ID', $ex->getMessage() );
+               }
+
+               $user = User::newFromName( 'UTSysop' );
+               $userinfo = UserInfo::newFromId( $user->getId() );
+               $this->assertFalse( $userinfo->isAnon() );
+               $this->assertFalse( $userinfo->isVerified() );
+               $this->assertSame( $user->getId(), $userinfo->getId() );
+               $this->assertSame( $user->getName(), $userinfo->getName() );
+               $this->assertSame( $user->getToken( true ), $userinfo->getToken() );
+               $this->assertInstanceOf( 'User', $userinfo->getUser() );
+               $userinfo2 = $userinfo->verified();
+               $this->assertNotSame( $userinfo2, $userinfo );
+               $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo );
+
+               $this->assertFalse( $userinfo2->isAnon() );
+               $this->assertTrue( $userinfo2->isVerified() );
+               $this->assertSame( $user->getId(), $userinfo2->getId() );
+               $this->assertSame( $user->getName(), $userinfo2->getName() );
+               $this->assertSame( $user->getToken( true ), $userinfo2->getToken() );
+               $this->assertInstanceOf( 'User', $userinfo2->getUser() );
+               $this->assertSame( $userinfo2, $userinfo2->verified() );
+               $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 );
+
+               $userinfo = UserInfo::newFromId( $user->getId(), true );
+               $this->assertTrue( $userinfo->isVerified() );
+               $this->assertSame( $userinfo, $userinfo->verified() );
+       }
+
+       public function testNewFromName() {
+               try {
+                       UserInfo::newFromName( '<bad name>' );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid user name', $ex->getMessage() );
+               }
+
+               // User name that exists
+               $user = User::newFromName( 'UTSysop' );
+               $userinfo = UserInfo::newFromName( $user->getName() );
+               $this->assertFalse( $userinfo->isAnon() );
+               $this->assertFalse( $userinfo->isVerified() );
+               $this->assertSame( $user->getId(), $userinfo->getId() );
+               $this->assertSame( $user->getName(), $userinfo->getName() );
+               $this->assertSame( $user->getToken( true ), $userinfo->getToken() );
+               $this->assertInstanceOf( 'User', $userinfo->getUser() );
+               $userinfo2 = $userinfo->verified();
+               $this->assertNotSame( $userinfo2, $userinfo );
+               $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo );
+
+               $this->assertFalse( $userinfo2->isAnon() );
+               $this->assertTrue( $userinfo2->isVerified() );
+               $this->assertSame( $user->getId(), $userinfo2->getId() );
+               $this->assertSame( $user->getName(), $userinfo2->getName() );
+               $this->assertSame( $user->getToken( true ), $userinfo2->getToken() );
+               $this->assertInstanceOf( 'User', $userinfo2->getUser() );
+               $this->assertSame( $userinfo2, $userinfo2->verified() );
+               $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 );
+
+               $userinfo = UserInfo::newFromName( $user->getName(), true );
+               $this->assertTrue( $userinfo->isVerified() );
+               $this->assertSame( $userinfo, $userinfo->verified() );
+
+               // User name that does not exist should still be non-anon
+               $user = User::newFromName( 'DoesNotExist' );
+               $this->assertSame( 0, $user->getId(), 'sanity check' );
+               $userinfo = UserInfo::newFromName( $user->getName() );
+               $this->assertFalse( $userinfo->isAnon() );
+               $this->assertFalse( $userinfo->isVerified() );
+               $this->assertSame( $user->getId(), $userinfo->getId() );
+               $this->assertSame( $user->getName(), $userinfo->getName() );
+               $this->assertSame( '', $userinfo->getToken() );
+               $this->assertInstanceOf( 'User', $userinfo->getUser() );
+               $userinfo2 = $userinfo->verified();
+               $this->assertNotSame( $userinfo2, $userinfo );
+               $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo );
+
+               $this->assertFalse( $userinfo2->isAnon() );
+               $this->assertTrue( $userinfo2->isVerified() );
+               $this->assertSame( $user->getId(), $userinfo2->getId() );
+               $this->assertSame( $user->getName(), $userinfo2->getName() );
+               $this->assertSame( '', $userinfo2->getToken() );
+               $this->assertInstanceOf( 'User', $userinfo2->getUser() );
+               $this->assertSame( $userinfo2, $userinfo2->verified() );
+               $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 );
+
+               $userinfo = UserInfo::newFromName( $user->getName(), true );
+               $this->assertTrue( $userinfo->isVerified() );
+               $this->assertSame( $userinfo, $userinfo->verified() );
+       }
+
+       public function testNewFromUser() {
+               // User that exists
+               $user = User::newFromName( 'UTSysop' );
+               $userinfo = UserInfo::newFromUser( $user );
+               $this->assertFalse( $userinfo->isAnon() );
+               $this->assertFalse( $userinfo->isVerified() );
+               $this->assertSame( $user->getId(), $userinfo->getId() );
+               $this->assertSame( $user->getName(), $userinfo->getName() );
+               $this->assertSame( $user->getToken( true ), $userinfo->getToken() );
+               $this->assertSame( $user, $userinfo->getUser() );
+               $userinfo2 = $userinfo->verified();
+               $this->assertNotSame( $userinfo2, $userinfo );
+               $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo );
+
+               $this->assertFalse( $userinfo2->isAnon() );
+               $this->assertTrue( $userinfo2->isVerified() );
+               $this->assertSame( $user->getId(), $userinfo2->getId() );
+               $this->assertSame( $user->getName(), $userinfo2->getName() );
+               $this->assertSame( $user->getToken( true ), $userinfo2->getToken() );
+               $this->assertSame( $user, $userinfo2->getUser() );
+               $this->assertSame( $userinfo2, $userinfo2->verified() );
+               $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 );
+
+               $userinfo = UserInfo::newFromUser( $user, true );
+               $this->assertTrue( $userinfo->isVerified() );
+               $this->assertSame( $userinfo, $userinfo->verified() );
+
+               // User name that does not exist should still be non-anon
+               $user = User::newFromName( 'DoesNotExist' );
+               $this->assertSame( 0, $user->getId(), 'sanity check' );
+               $userinfo = UserInfo::newFromUser( $user );
+               $this->assertFalse( $userinfo->isAnon() );
+               $this->assertFalse( $userinfo->isVerified() );
+               $this->assertSame( $user->getId(), $userinfo->getId() );
+               $this->assertSame( $user->getName(), $userinfo->getName() );
+               $this->assertSame( '', $userinfo->getToken() );
+               $this->assertSame( $user, $userinfo->getUser() );
+               $userinfo2 = $userinfo->verified();
+               $this->assertNotSame( $userinfo2, $userinfo );
+               $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo );
+
+               $this->assertFalse( $userinfo2->isAnon() );
+               $this->assertTrue( $userinfo2->isVerified() );
+               $this->assertSame( $user->getId(), $userinfo2->getId() );
+               $this->assertSame( $user->getName(), $userinfo2->getName() );
+               $this->assertSame( '', $userinfo2->getToken() );
+               $this->assertSame( $user, $userinfo2->getUser() );
+               $this->assertSame( $userinfo2, $userinfo2->verified() );
+               $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 );
+
+               $userinfo = UserInfo::newFromUser( $user, true );
+               $this->assertTrue( $userinfo->isVerified() );
+               $this->assertSame( $userinfo, $userinfo->verified() );
+
+               // Anonymous user gives anon
+               $userinfo = UserInfo::newFromUser( new User, false );
+               $this->assertTrue( $userinfo->isVerified() );
+               $this->assertSame( 0, $userinfo->getId() );
+               $this->assertSame( null, $userinfo->getName() );
+       }
+
+}
index 7834d9b..fb14c65 100644 (file)
@@ -15,7 +15,6 @@ class UploadFromUrlTest extends ApiTestCase {
                        'wgEnableUploads' => true,
                        'wgAllowCopyUploads' => true,
                ) );
-               wfSetupSession();
 
                if ( wfLocalFile( 'UploadFromUrlTest.png' )->exists() ) {
                        $this->deleteFile( 'UploadFromUrlTest.png' );
@@ -25,15 +24,12 @@ class UploadFromUrlTest extends ApiTestCase {
        protected function doApiRequest( array $params, array $unused = null,
                $appendModule = false, User $user = null
        ) {
-               $sessionId = session_id();
-               session_write_close();
+               global $wgRequest;
 
-               $req = new FauxRequest( $params, true, $_SESSION );
+               $req = new FauxRequest( $params, true, $wgRequest->getSession() );
                $module = new ApiMain( $req, true );
                $module->execute();
 
-               wfSetupSession( $sessionId );
-
                return array(
                        $module->getResult()->getResultData( null, array( 'Strip' => 'all' ) ),
                        $req
diff --git a/tests/phpunit/includes/user/BotPasswordTest.php b/tests/phpunit/includes/user/BotPasswordTest.php
new file mode 100644 (file)
index 0000000..c118803
--- /dev/null
@@ -0,0 +1,379 @@
+<?php
+
+use MediaWiki\Session\SessionManager;
+
+/**
+ * @covers BotPassword
+ * @group Database
+ */
+class BotPasswordTest extends MediaWikiTestCase {
+       protected function setUp() {
+               parent::setUp();
+
+               $this->setMwGlobals( array(
+                       'wgEnableBotPasswords' => true,
+                       'wgBotPasswordsDatabase' => false,
+                       'wgCentralIdLookupProvider' => 'BotPasswordTest OkMock',
+                       'wgGrantPermissions' => array(
+                               'test' => array( 'read' => true ),
+                       ),
+                       'wgUserrightsInterwikiDelimiter' => '@',
+               ) );
+
+               $mock1 = $this->getMockForAbstractClass( 'CentralIdLookup' );
+               $mock1->expects( $this->any() )->method( 'isAttached' )
+                       ->will( $this->returnValue( true ) );
+               $mock1->expects( $this->any() )->method( 'lookupUserNames' )
+                       ->will( $this->returnValue( array( 'UTSysop' => 42, 'UTDummy' => 43, 'UTInvalid' => 0 ) ) );
+               $mock1->expects( $this->never() )->method( 'lookupCentralIds' );
+
+               $mock2 = $this->getMockForAbstractClass( 'CentralIdLookup' );
+               $mock2->expects( $this->any() )->method( 'isAttached' )
+                       ->will( $this->returnValue( false ) );
+               $mock2->expects( $this->any() )->method( 'lookupUserNames' )
+                       ->will( $this->returnArgument( 0 ) );
+               $mock2->expects( $this->never() )->method( 'lookupCentralIds' );
+
+               $this->mergeMwGlobalArrayValue( 'wgCentralIdLookupProviders', array(
+                       'BotPasswordTest OkMock' => array( 'factory' => function () use ( $mock1 ) {
+                               return $mock1;
+                       } ),
+                       'BotPasswordTest FailMock' => array( 'factory' => function () use ( $mock2 ) {
+                               return $mock2;
+                       } ),
+               ) );
+
+               CentralIdLookup::resetCache();
+       }
+
+       public function addDBData() {
+               $passwordFactory = new \PasswordFactory();
+               $passwordFactory->init( \RequestContext::getMain()->getConfig() );
+               // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only
+               $passwordFactory->setDefaultType( 'A' );
+               $pwhash = $passwordFactory->newFromPlaintext( 'foobaz' );
+
+               $dbw = wfGetDB( DB_MASTER );
+               $dbw->delete(
+                       'bot_passwords',
+                       array( 'bp_user' => array( 42, 43 ), 'bp_app_id' => 'BotPassword' ),
+                       __METHOD__
+               );
+               $dbw->insert(
+                       'bot_passwords',
+                       array(
+                               array(
+                                       'bp_user' => 42,
+                                       'bp_app_id' => 'BotPassword',
+                                       'bp_password' => $pwhash->toString(),
+                                       'bp_token' => 'token!',
+                                       'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}',
+                                       'bp_grants' => '["test"]',
+                               ),
+                               array(
+                                       'bp_user' => 43,
+                                       'bp_app_id' => 'BotPassword',
+                                       'bp_password' => $pwhash->toString(),
+                                       'bp_token' => 'token!',
+                                       'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}',
+                                       'bp_grants' => '["test"]',
+                               ),
+                       ),
+                       __METHOD__
+               );
+       }
+
+       public function testBasics() {
+               $user = User::newFromName( 'UTSysop' );
+               $bp = BotPassword::newFromUser( $user, 'BotPassword' );
+               $this->assertInstanceOf( 'BotPassword', $bp );
+               $this->assertTrue( $bp->isSaved() );
+               $this->assertSame( 42, $bp->getUserCentralId() );
+               $this->assertSame( 'BotPassword', $bp->getAppId() );
+               $this->assertSame( 'token!', trim( $bp->getToken(), " \0" ) );
+               $this->assertEquals( '{"IPAddresses":["127.0.0.0/8"]}', $bp->getRestrictions()->toJson() );
+               $this->assertSame( array( 'test' ), $bp->getGrants() );
+
+               $this->assertNull( BotPassword::newFromUser( $user, 'DoesNotExist' ) );
+
+               $this->setMwGlobals( array(
+                       'wgCentralIdLookupProvider' => 'BotPasswordTest FailMock'
+               ) );
+               $this->assertNull( BotPassword::newFromUser( $user, 'BotPassword' ) );
+
+               $this->assertSame( '@', BotPassword::getSeparator() );
+               $this->setMwGlobals( array(
+                       'wgUserrightsInterwikiDelimiter' => '#',
+               ) );
+               $this->assertSame( '#', BotPassword::getSeparator() );
+       }
+
+       public function testUnsaved() {
+               $user = User::newFromName( 'UTSysop' );
+               $bp = BotPassword::newUnsaved( array(
+                       'user' => $user,
+                       'appId' => 'DoesNotExist'
+               ) );
+               $this->assertInstanceOf( 'BotPassword', $bp );
+               $this->assertFalse( $bp->isSaved() );
+               $this->assertSame( 42, $bp->getUserCentralId() );
+               $this->assertSame( 'DoesNotExist', $bp->getAppId() );
+               $this->assertEquals( MWRestrictions::newDefault(), $bp->getRestrictions() );
+               $this->assertSame( array(), $bp->getGrants() );
+
+               $bp = BotPassword::newUnsaved( array(
+                       'username' => 'UTDummy',
+                       'appId' => 'DoesNotExist2',
+                       'restrictions' => MWRestrictions::newFromJson( '{"IPAddresses":["127.0.0.0/8"]}' ),
+                       'grants' => array( 'test' ),
+               ) );
+               $this->assertInstanceOf( 'BotPassword', $bp );
+               $this->assertFalse( $bp->isSaved() );
+               $this->assertSame( 43, $bp->getUserCentralId() );
+               $this->assertSame( 'DoesNotExist2', $bp->getAppId() );
+               $this->assertEquals( '{"IPAddresses":["127.0.0.0/8"]}', $bp->getRestrictions()->toJson() );
+               $this->assertSame( array( 'test' ), $bp->getGrants() );
+
+               $user = User::newFromName( 'UTSysop' );
+               $bp = BotPassword::newUnsaved( array(
+                       'centralId' => 45,
+                       'appId' => 'DoesNotExist'
+               ) );
+               $this->assertInstanceOf( 'BotPassword', $bp );
+               $this->assertFalse( $bp->isSaved() );
+               $this->assertSame( 45, $bp->getUserCentralId() );
+               $this->assertSame( 'DoesNotExist', $bp->getAppId() );
+
+               $user = User::newFromName( 'UTSysop' );
+               $bp = BotPassword::newUnsaved( array(
+                       'user' => $user,
+                       'appId' => 'BotPassword'
+               ) );
+               $this->assertInstanceOf( 'BotPassword', $bp );
+               $this->assertFalse( $bp->isSaved() );
+
+               $this->assertNull( BotPassword::newUnsaved( array(
+                       'user' => $user,
+                       'appId' => '',
+               ) ) );
+               $this->assertNull( BotPassword::newUnsaved( array(
+                       'user' => $user,
+                       'appId' => str_repeat( 'X', BotPassword::APPID_MAXLENGTH + 1 ),
+               ) ) );
+               $this->assertNull( BotPassword::newUnsaved( array(
+                       'user' => 'UTSysop',
+                       'appId' => 'Ok',
+               ) ) );
+               $this->assertNull( BotPassword::newUnsaved( array(
+                       'username' => 'UTInvalid',
+                       'appId' => 'Ok',
+               ) ) );
+               $this->assertNull( BotPassword::newUnsaved( array(
+                       'appId' => 'Ok',
+               ) ) );
+       }
+
+       public function testGetPassword() {
+               $bp = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
+
+               $password = $bp->getPassword();
+               $this->assertInstanceOf( 'Password', $password );
+               $this->assertTrue( $password->equals( 'foobaz' ) );
+
+               $bp->centralId = 44;
+               $password = $bp->getPassword();
+               $this->assertInstanceOf( 'InvalidPassword', $password );
+
+               $bp = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
+               $dbw = wfGetDB( DB_MASTER );
+               $dbw->update(
+                       'bot_passwords',
+                       array( 'bp_password' => 'garbage' ),
+                       array( 'bp_user' => 42, 'bp_app_id' => 'BotPassword' ),
+                       __METHOD__
+               );
+               $password = $bp->getPassword();
+               $this->assertInstanceOf( 'InvalidPassword', $password );
+       }
+
+       public function testInvalidateAllPasswordsForUser() {
+               $bp1 = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
+               $bp2 = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 43, 'BotPassword' ) );
+
+               $this->assertNotInstanceOf( 'InvalidPassword', $bp1->getPassword(), 'sanity check' );
+               $this->assertNotInstanceOf( 'InvalidPassword', $bp2->getPassword(), 'sanity check' );
+               BotPassword::invalidateAllPasswordsForUser( 'UTSysop' );
+               $this->assertInstanceOf( 'InvalidPassword', $bp1->getPassword() );
+               $this->assertNotInstanceOf( 'InvalidPassword', $bp2->getPassword() );
+
+               $bp = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
+               $this->assertInstanceOf( 'InvalidPassword', $bp->getPassword() );
+       }
+
+       public function testRemoveAllPasswordsForUser() {
+               $this->assertNotNull( BotPassword::newFromCentralId( 42, 'BotPassword' ), 'sanity check' );
+               $this->assertNotNull( BotPassword::newFromCentralId( 43, 'BotPassword' ), 'sanity check' );
+
+               BotPassword::removeAllPasswordsForUser( 'UTSysop' );
+
+               $this->assertNull( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
+               $this->assertNotNull( BotPassword::newFromCentralId( 43, 'BotPassword' ) );
+       }
+
+       public function testLogin() {
+               // Test failure when bot passwords aren't enabled
+               $this->setMwGlobals( 'wgEnableBotPasswords', false );
+               $status = BotPassword::login( 'UTSysop@BotPassword', 'foobaz', new FauxRequest );
+               $this->assertEquals( Status::newFatal( 'botpasswords-disabled' ), $status );
+               $this->setMwGlobals( 'wgEnableBotPasswords', true );
+
+               // Test failure when BotPasswordSessionProvider isn't configured
+               $manager = new SessionManager( array(
+                       'logger' => new Psr\Log\NullLogger,
+                       'store' => new EmptyBagOStuff,
+               ) );
+               $reset = MediaWiki\Session\TestUtils::setSessionManagerSingleton( $manager );
+               $this->assertNull(
+                       $manager->getProvider( 'MediaWiki\\Session\\BotPasswordSessionProvider' ),
+                       'sanity check'
+               );
+               $status = BotPassword::login( 'UTSysop@BotPassword', 'foobaz', new FauxRequest );
+               $this->assertEquals( Status::newFatal( 'botpasswords-no-provider' ), $status );
+               ScopedCallback::consume( $reset );
+
+               // Now configure BotPasswordSessionProvider for further tests...
+               $mainConfig = RequestContext::getMain()->getConfig();
+               $config = new HashConfig( array(
+                       'SessionProviders' => $mainConfig->get( 'SessionProviders' ) + array(
+                               'MediaWiki\\Session\\BotPasswordSessionProvider' => array(
+                                       'class' => 'MediaWiki\\Session\\BotPasswordSessionProvider',
+                                       'args' => array( array( 'priority' => 40 ) ),
+                               )
+                       ),
+               ) );
+               $manager = new SessionManager( array(
+                       'config' => new MultiConfig( array( $config, RequestContext::getMain()->getConfig() ) ),
+                       'logger' => new Psr\Log\NullLogger,
+                       'store' => new EmptyBagOStuff,
+               ) );
+               $reset = MediaWiki\Session\TestUtils::setSessionManagerSingleton( $manager );
+
+               // No "@"-thing in the username
+               $status = BotPassword::login( 'UTSysop', 'foobaz', new FauxRequest );
+               $this->assertEquals( Status::newFatal( 'botpasswords-invalid-name', '@' ), $status );
+
+               // No base user
+               $status = BotPassword::login( 'UTDummy@BotPassword', 'foobaz', new FauxRequest );
+               $this->assertEquals( Status::newFatal( 'nosuchuser', 'UTDummy' ), $status );
+
+               // No bot password
+               $status = BotPassword::login( 'UTSysop@DoesNotExist', 'foobaz', new FauxRequest );
+               $this->assertEquals(
+                       Status::newFatal( 'botpasswords-not-exist', 'UTSysop', 'DoesNotExist' ),
+                       $status
+               );
+
+               // Failed restriction
+               $request = $this->getMock( 'FauxRequest', array( 'getIP' ) );
+               $request->expects( $this->any() )->method( 'getIP' )
+                       ->will( $this->returnValue( '10.0.0.1' ) );
+               $status = BotPassword::login( 'UTSysop@BotPassword', 'foobaz', $request );
+               $this->assertEquals( Status::newFatal( 'botpasswords-restriction-failed' ), $status );
+
+               // Wrong password
+               $status = BotPassword::login( 'UTSysop@BotPassword', 'UTSysopPassword', new FauxRequest );
+               $this->assertEquals( Status::newFatal( 'wrongpassword' ), $status );
+
+               // Success!
+               $request = new FauxRequest;
+               $this->assertNotInstanceOf(
+                       'MediaWiki\\Session\\BotPasswordSessionProvider',
+                       $request->getSession()->getProvider(),
+                       'sanity check'
+               );
+               $status = BotPassword::login( 'UTSysop@BotPassword', 'foobaz', $request );
+               $this->assertInstanceOf( 'Status', $status );
+               $this->assertTrue( $status->isGood() );
+               $session = $status->getValue();
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertInstanceOf(
+                       'MediaWiki\\Session\\BotPasswordSessionProvider', $session->getProvider()
+               );
+               $this->assertSame( $session->getId(), $request->getSession()->getId() );
+
+               ScopedCallback::consume( $reset );
+       }
+
+       /**
+        * @dataProvider provideSave
+        * @param string|null $password
+        */
+       public function testSave( $password ) {
+               $passwordFactory = new \PasswordFactory();
+               $passwordFactory->init( \RequestContext::getMain()->getConfig() );
+               // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only
+               $passwordFactory->setDefaultType( 'A' );
+
+               $bp = BotPassword::newUnsaved( array(
+                       'centralId' => 42,
+                       'appId' => 'TestSave',
+                       'restrictions' => MWRestrictions::newFromJson( '{"IPAddresses":["127.0.0.0/8"]}' ),
+                       'grants' => array( 'test' ),
+               ) );
+               $this->assertFalse( $bp->isSaved(), 'sanity check' );
+               $this->assertNull(
+                       BotPassword::newFromCentralId( 42, 'TestSave', BotPassword::READ_LATEST ), 'sanity check'
+               );
+
+               $pwhash = $password ? $passwordFactory->newFromPlaintext( $password ) : null;
+               $this->assertFalse( $bp->save( 'update', $pwhash ) );
+               $this->assertTrue( $bp->save( 'insert', $pwhash ) );
+               $bp2 = BotPassword::newFromCentralId( 42, 'TestSave', BotPassword::READ_LATEST );
+               $this->assertInstanceOf( 'BotPassword', $bp2 );
+               $this->assertEquals( $bp->getUserCentralId(), $bp2->getUserCentralId() );
+               $this->assertEquals( $bp->getAppId(), $bp2->getAppId() );
+               $this->assertEquals( $bp->getToken(), $bp2->getToken() );
+               $this->assertEquals( $bp->getRestrictions(), $bp2->getRestrictions() );
+               $this->assertEquals( $bp->getGrants(), $bp2->getGrants() );
+               $pw = TestingAccessWrapper::newFromObject( $bp )->getPassword();
+               if ( $password === null ) {
+                       $this->assertInstanceOf( 'InvalidPassword', $pw );
+               } else {
+                       $this->assertTrue( $pw->equals( $password ) );
+               }
+
+               $token = $bp->getToken();
+               $this->assertFalse( $bp->save( 'insert' ) );
+               $this->assertTrue( $bp->save( 'update' ) );
+               $this->assertNotEquals( $token, $bp->getToken() );
+               $bp2 = BotPassword::newFromCentralId( 42, 'TestSave', BotPassword::READ_LATEST );
+               $this->assertInstanceOf( 'BotPassword', $bp2 );
+               $this->assertEquals( $bp->getToken(), $bp2->getToken() );
+               $pw = TestingAccessWrapper::newFromObject( $bp )->getPassword();
+               if ( $password === null ) {
+                       $this->assertInstanceOf( 'InvalidPassword', $pw );
+               } else {
+                       $this->assertTrue( $pw->equals( $password ) );
+               }
+
+               $pwhash = $passwordFactory->newFromPlaintext( 'XXX' );
+               $token = $bp->getToken();
+               $this->assertTrue( $bp->save( 'update', $pwhash ) );
+               $this->assertNotEquals( $token, $bp->getToken() );
+               $pw = TestingAccessWrapper::newFromObject( $bp )->getPassword();
+               $this->assertTrue( $pw->equals( 'XXX' ) );
+
+               $this->assertTrue( $bp->delete() );
+               $this->assertFalse( $bp->isSaved() );
+               $this->assertNull( BotPassword::newFromCentralId( 42, 'TestSave', BotPassword::READ_LATEST ) );
+
+               $this->assertFalse( $bp->save( 'foobar' ) );
+       }
+
+       public static function provideSave() {
+               return array(
+                       array( null ),
+                       array( 'foobar' ),
+               );
+       }
+}
index 45c4b8c..aadc5c9 100644 (file)
@@ -446,89 +446,4 @@ class UserTest extends MediaWikiTestCase {
                $this->assertGreaterThan(
                        $touched, $user->getDBTouched(), "user_touched increased with casOnTouched() #2" );
        }
-
-       public static function setExtendedLoginCookieDataProvider() {
-               $data = array();
-               $now = time();
-
-               $secondsInDay = 86400;
-
-               // Arbitrary durations, in units of days, to ensure it chooses the
-               // right one.  There is a 5-minute grace period (see testSetExtendedLoginCookie)
-               // to work around slow tests, since we're not currently mocking time() for PHP.
-
-               $durationOne = $secondsInDay * 5;
-               $durationTwo = $secondsInDay * 29;
-               $durationThree = $secondsInDay * 17;
-
-               // If $wgExtendedLoginCookieExpiration is null, then the expiry passed to
-               // set cookie is time() + $wgCookieExpiration
-               $data[] = array(
-                       null,
-                       $durationOne,
-                       $now + $durationOne,
-               );
-
-               // If $wgExtendedLoginCookieExpiration isn't null, then the expiry passed to
-               // set cookie is $now + $wgExtendedLoginCookieExpiration
-               $data[] = array(
-                       $durationTwo,
-                       $durationThree,
-                       $now + $durationTwo,
-               );
-
-               return $data;
-       }
-
-       /**
-        * @dataProvider setExtendedLoginCookieDataProvider
-        * @covers User::getRequest
-        * @covers User::setCookie
-        * @backupGlobals enabled
-        */
-       public function testSetExtendedLoginCookie(
-               $extendedLoginCookieExpiration,
-               $cookieExpiration,
-               $expectedExpiry
-       ) {
-               $this->setMwGlobals( array(
-                       'wgExtendedLoginCookieExpiration' => $extendedLoginCookieExpiration,
-                       'wgCookieExpiration' => $cookieExpiration,
-               ) );
-
-               $response = $this->getMock( 'WebResponse' );
-               $setcookieSpy = $this->any();
-               $response->expects( $setcookieSpy )
-                       ->method( 'setcookie' );
-
-               $request = new MockWebRequest( $response );
-               $user = new UserProxy( User::newFromSession( $request ) );
-               $user->setExtendedLoginCookie( 'name', 'value', true );
-
-               $setcookieInvocations = $setcookieSpy->getInvocations();
-               $setcookieInvocation = end( $setcookieInvocations );
-               $actualExpiry = $setcookieInvocation->parameters[2];
-
-               // TODO: ± 600 seconds compensates for
-               // slow-running tests. However, the dependency on the time
-               // function should be removed.  This requires some way
-               // to mock/isolate User->setExtendedLoginCookie's call to time()
-               $this->assertEquals( $expectedExpiry, $actualExpiry, '', 600 );
-       }
-}
-
-class UserProxy extends User {
-
-       /**
-        * @var User
-        */
-       protected $user;
-
-       public function __construct( User $user ) {
-               $this->user = $user;
-       }
-
-       public function setExtendedLoginCookie( $name, $value, $secure ) {
-               $this->user->setExtendedLoginCookie( $name, $value, $secure );
-       }
 }
diff --git a/tests/phpunit/mocks/session/DummySessionBackend.php b/tests/phpunit/mocks/session/DummySessionBackend.php
new file mode 100644 (file)
index 0000000..f96e61c
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+
+namespace MediaWiki\Session;
+
+/**
+ * Dummy session backend
+ *
+ * This isn't a real backend, but implements some methods that SessionBackend
+ * does so tests can run.
+ */
+class DummySessionBackend {
+       public $data = array(
+               'foo' => 1,
+               'bar' => 2,
+               0 => 'zero',
+       );
+       public $dirty = false;
+
+       public function &getData() {
+               return $this->data;
+       }
+
+       public function dirty() {
+               $this->dirty = true;
+       }
+
+       public function deregisterSession( $index ) {
+       }
+}
diff --git a/tests/phpunit/mocks/session/DummySessionProvider.php b/tests/phpunit/mocks/session/DummySessionProvider.php
new file mode 100644 (file)
index 0000000..4468191
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+use MediaWiki\Session\SessionProvider;
+use MediaWiki\Session\SessionInfo;
+use MediaWiki\Session\SessionBackend;
+use MediaWiki\Session\UserInfo;
+
+/**
+ * Dummy session provider
+ *
+ * An implementation of a session provider that doesn't actually do anything.
+ */
+class DummySessionProvider extends SessionProvider {
+
+       const ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+
+       public function provideSessionInfo( WebRequest $request ) {
+               return new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $this,
+                       'id' => self::ID,
+                       'persisted' => true,
+                       'userInfo' => UserInfo::newAnonymous(),
+               ) );
+       }
+
+       public function newSessionInfo( $id = null ) {
+               return new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'id' => $id,
+                       'idIsSafe' => true,
+                       'provider' => $this,
+                       'persisted' => false,
+                       'userInfo' => UserInfo::newAnonymous(),
+               ) );
+       }
+
+       public function persistsSessionId() {
+               return true;
+       }
+
+       public function canChangeUser() {
+               return $this->persistsSessionId();
+       }
+
+       public function persistSession( SessionBackend $session, WebRequest $request ) {
+       }
+
+       public function unpersistSession( WebRequest $request ) {
+       }
+
+       public function immutableSessionCouldExistForUser( $user ) {
+               return false;
+       }
+
+       public function preventImmutableSessionsForUser( $user ) {
+       }
+
+       public function suggestLoginUsername( WebRequest $request ) {
+               return $request->getCookie( 'UserName' );
+       }
+
+}
index f080593..0ae0b21 100755 (executable)
@@ -74,6 +74,7 @@ class PHPUnitMaintClass extends Maintenance {
                global $wgLanguageConverterCacheType, $wgUseDatabaseMessages;
                global $wgLocaltimezone, $wgLocalisationCacheConf;
                global $wgDevelopmentWarnings;
+               global $wgSessionProviders;
                global $wgJobTypeConf;
 
                // Inject test autoloader
@@ -109,6 +110,19 @@ class PHPUnitMaintClass extends Maintenance {
 
                $wgLocalisationCacheConf['storeClass'] = 'LCStoreNull';
 
+               // Generic MediaWiki\Session\SessionManager configuration for tests
+               // We use CookieSessionProvider because things might be expecting
+               // cookies to show up in a FauxRequest somewhere.
+               $wgSessionProviders = array(
+                       array(
+                               'class' => 'MediaWiki\\Session\\CookieSessionProvider',
+                               'args' => array( array(
+                                       'priority' => 30,
+                                       'callUserSetCookiesHook' => true,
+                               ) ),
+                       ),
+               );
+
                // Bug 44192 Do not attempt to send a real e-mail
                Hooks::clear( 'AlternateUserMailer' );
                Hooks::register(
index d2f96dc..5cf75bd 100644 (file)
@@ -33,6 +33,9 @@ class ApiDocumentationTest extends MediaWikiTestCase {
                if ( !self::$main ) {
                        self::$main = new ApiMain( RequestContext::getMain() );
                        self::$main->getContext()->setLanguage( 'en' );
+                       self::$main->getContext()->setTitle(
+                               Title::makeTitle( NS_SPECIAL, 'Badtitle/dummy title for ApiDocumentationTest' )
+                       );
                }
                return self::$main;
        }