Merge "Move LoadMonitor to Rdbms namespace"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Thu, 2 Feb 2017 20:15:58 +0000 (20:15 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Thu, 2 Feb 2017 20:15:58 +0000 (20:15 +0000)
70 files changed:
RELEASE-NOTES-1.29
autoload.php
composer.json
docs/hooks.txt
includes/DefaultSettings.php
includes/EditPage.php
includes/MagicWord.php
includes/api/ApiPurge.php
includes/api/ApiQueryAllUsers.php
includes/changes/ChangesList.php
includes/changes/OldChangesList.php
includes/changes/RecentChange.php
includes/db/DatabaseOracle.php
includes/deferred/LinksUpdate.php
includes/diff/DifferenceEngine.php
includes/export/XmlDumpWriter.php
includes/filerepo/file/LocalFile.php
includes/htmlform/HTMLForm.php
includes/htmlform/fields/HTMLUsersMultiselectField.php [new file with mode: 0644]
includes/installer/MysqlUpdater.php
includes/libs/rdbms/database/Database.php
includes/libs/rdbms/database/DatabasePostgres.php
includes/libs/replacers/Replacer.php
includes/page/ImagePage.php
includes/page/WikiPage.php
includes/parser/DateFormatter.php
includes/parser/LinkHolderArray.php
includes/parser/Parser.php
includes/rcfeed/FormattedRCFeed.php [new file with mode: 0644]
includes/rcfeed/RCFeed.php [new file with mode: 0644]
includes/rcfeed/RCFeedEngine.php
includes/rcfeed/RedisPubSubFeedEngine.php
includes/rcfeed/UDPRCFeedEngine.php
includes/resourceloader/ResourceLoader.php
includes/skins/BaseTemplate.php
includes/specials/SpecialMovepage.php
includes/specials/SpecialUserrights.php
includes/specials/SpecialWantedpages.php
includes/specials/pagers/ActiveUsersPager.php
includes/specials/pagers/ContribsPager.php
includes/specials/pagers/NewPagesPager.php
includes/widget/UsersMultiselectWidget.php [new file with mode: 0644]
languages/Language.php
languages/i18n/en.json
languages/i18n/qqq.json
languages/messages/MessagesJv.php
maintenance/archives/patch-user_groups-primary-key.sql [new file with mode: 0644]
maintenance/archives/patch-user_groups-ug_expiry.sql
resources/Resources.php
resources/lib/oojs-ui/i18n/fr.json
resources/lib/oojs-ui/i18n/io.json [new file with mode: 0644]
resources/lib/oojs-ui/i18n/sr-ec.json
resources/lib/oojs-ui/oojs-ui-apex.js
resources/lib/oojs-ui/oojs-ui-core-apex.css
resources/lib/oojs-ui/oojs-ui-core-mediawiki.css
resources/lib/oojs-ui/oojs-ui-core.js
resources/lib/oojs-ui/oojs-ui-mediawiki.js
resources/lib/oojs-ui/oojs-ui-toolbars-apex.css
resources/lib/oojs-ui/oojs-ui-toolbars-mediawiki.css
resources/lib/oojs-ui/oojs-ui-toolbars.js
resources/lib/oojs-ui/oojs-ui-widgets-apex.css
resources/lib/oojs-ui/oojs-ui-widgets-mediawiki.css
resources/lib/oojs-ui/oojs-ui-widgets.js
resources/lib/oojs-ui/oojs-ui-windows-apex.css
resources/lib/oojs-ui/oojs-ui-windows-mediawiki.css
resources/lib/oojs-ui/oojs-ui-windows.js
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less
resources/src/mediawiki.widgets/mw.widgets.UsersMultiselectWidget.js [new file with mode: 0644]
resources/src/mediawiki/api.js
tests/phpunit/includes/rcfeed/RCFeedIntegrationTest.php

index 8fa29c1..c04c8f7 100644 (file)
@@ -96,6 +96,7 @@ production.
   no longer returns a 'message' on success.
 * Added action=validatepassword to validate passwords for the account creation
   and password change forms.
+* action=purge now requires a POST.
 
 === Action API internal changes in 1.29 ===
 * New methods were added to ApiBase to handle errors and warnings using i18n
@@ -136,6 +137,7 @@ changes to languages because of Phabricator reports.
   The new or reinstated language fallbacks are (after cs ↔ sk in 1.28):
   ca ↔ oc; hsb ↔ dsb; io → eo; mdf → ru; pnt → el; roa-tara → it; rup → ro;
   sh → bs, sr-el, hr.
+* (T155957) Talk Namespaces for Javanese language (jv) have been updated.
 
 ==== No fallback for Ukrainian ====
 * (T39314) The fallback from Ukrainian to Russian was removed. The Ukrainian
index 3a4ce62..b871535 100644 (file)
@@ -506,6 +506,7 @@ $wgAutoloadLocalClasses = [
        'FormSpecialPage' => __DIR__ . '/includes/specialpage/FormSpecialPage.php',
        'FormatJson' => __DIR__ . '/includes/json/FormatJson.php',
        'FormatMetadata' => __DIR__ . '/includes/media/FormatMetadata.php',
+       'FormattedRCFeed' => __DIR__ . '/includes/rcfeed/FormattedRCFeed.php',
        'FormlessAction' => __DIR__ . '/includes/actions/FormlessAction.php',
        'GIFHandler' => __DIR__ . '/includes/media/GIF.php',
        'GIFMetadataExtractor' => __DIR__ . '/includes/media/GIFMetadataExtractor.php',
@@ -567,6 +568,7 @@ $wgAutoloadLocalClasses = [
        'HTMLTextFieldWithButton' => __DIR__ . '/includes/htmlform/fields/HTMLTextFieldWithButton.php',
        'HTMLTitleTextField' => __DIR__ . '/includes/htmlform/fields/HTMLTitleTextField.php',
        'HTMLUserTextField' => __DIR__ . '/includes/htmlform/fields/HTMLUserTextField.php',
+       'HTMLUsersMultiselectField' => __DIR__ . '/includes/htmlform/fields/HTMLUsersMultiselectField.php',
        'HTTPFileStreamer' => __DIR__ . '/includes/libs/filebackend/HTTPFileStreamer.php',
        'HWLDFWordAccumulator' => __DIR__ . '/includes/diff/DairikiDiff.php',
        'HashBagOStuff' => __DIR__ . '/includes/libs/objectcache/HashBagOStuff.php',
@@ -937,6 +939,7 @@ $wgAutoloadLocalClasses = [
        'MediaWiki\\Widget\\Search\\SimpleSearchResultWidget' => __DIR__ . '/includes/widget/search/SimpleSearchResultWidget.php',
        'MediaWiki\\Widget\\TitleInputWidget' => __DIR__ . '/includes/widget/TitleInputWidget.php',
        'MediaWiki\\Widget\\UserInputWidget' => __DIR__ . '/includes/widget/UserInputWidget.php',
+       'MediaWiki\\Widget\\UsersMultiselectWidget' => __DIR__ . '/includes/widget/UsersMultiselectWidget.php',
        'MemCachedClientforWiki' => __DIR__ . '/includes/compat/MemcachedClientCompat.php',
        'MemcLockManager' => __DIR__ . '/includes/libs/lockmanager/MemcLockManager.php',
        'MemcachedBagOStuff' => __DIR__ . '/includes/libs/objectcache/MemcachedBagOStuff.php',
@@ -1142,6 +1145,7 @@ $wgAutoloadLocalClasses = [
        'RCCacheEntry' => __DIR__ . '/includes/changes/RCCacheEntry.php',
        'RCCacheEntryFactory' => __DIR__ . '/includes/changes/RCCacheEntryFactory.php',
        'RCDatabaseLogEntry' => __DIR__ . '/includes/logging/LogEntry.php',
+       'RCFeed' => __DIR__ . '/includes/rcfeed/RCFeed.php',
        'RCFeedEngine' => __DIR__ . '/includes/rcfeed/RCFeedEngine.php',
        'RCFeedFormatter' => __DIR__ . '/includes/rcfeed/RCFeedFormatter.php',
        'RESTBagOStuff' => __DIR__ . '/includes/libs/objectcache/RESTBagOStuff.php',
index 3b72bfd..5966f4b 100644 (file)
@@ -25,7 +25,7 @@
                "ext-xml": "*",
                "liuggio/statsd-php-client": "1.0.18",
                "mediawiki/at-ease": "1.1.0",
-               "oojs/oojs-ui": "0.18.4",
+               "oojs/oojs-ui": "0.19.0",
                "oyejorge/less.php": "1.7.0.10",
                "php": ">=5.5.9",
                "psr/log": "1.0.0",
index 27773f6..1459b89 100644 (file)
@@ -212,9 +212,13 @@ related to a particular event, like so:
                # ...
                function protect() {
                        global $wgUser;
-                       if ( Hooks::run( 'ArticleProtect', array( &$this, &$wgUser ) ) ) {
+
+                       // Avoid PHP 7.1 warning from passing $this by reference
+                       $article = $this;
+
+                       if ( Hooks::run( 'ArticleProtect', [ &$article, &$wgUser ] ) ) {
                                # protect the article
-                               Hooks::run( 'ArticleProtectComplete', array( &$this, &$wgUser ) );
+                               Hooks::run( 'ArticleProtectComplete', [ &$article, &$wgUser ] );
                        }
                }
        }
index a7cbd96..c7c7fb7 100644 (file)
@@ -6656,51 +6656,64 @@ $wgRCLinkLimits = [ 50, 100, 250, 500 ];
 $wgRCLinkDays = [ 1, 3, 7, 14, 30 ];
 
 /**
- * Destinations to which notifications about recent changes
- * should be sent.
- *
- * As of MediaWiki 1.22, there are 2 supported 'engine' parameter option in core:
- *   * 'UDPRCFeedEngine', which is used to send recent changes over UDP to the
- *      specified server.
- *   * 'RedisPubSubFeedEngine', which is used to send recent changes to Redis.
- *
- * The common options are:
- *   * 'uri' -- the address to which the notices are to be sent.
- *   * 'formatter' -- the class name (implementing RCFeedFormatter) which will
- *     produce the text to send. This can also be an object of the class.
- *   * 'omit_bots' -- whether the bot edits should be in the feed
- *   * 'omit_anon' -- whether anonymous edits should be in the feed
- *   * 'omit_user' -- whether edits by registered users should be in the feed
- *   * 'omit_minor' -- whether minor edits should be in the feed
- *   * 'omit_patrolled' -- whether patrolled edits should be in the feed
- *
- *  The IRC-specific options are:
- *   * 'add_interwiki_prefix' -- whether the titles should be prefixed with
- *     the first entry in the $wgLocalInterwikis array (or the value of
- *     $wgLocalInterwiki, if set)
- *
- *  The JSON-specific options are:
- *   * 'channel' -- if set, the 'channel' parameter is also set in JSON values.
+ * Configuration for feeds to which notifications about recent changes will be sent.
+ *
+ * The following feed classes are available by default:
+ * - 'UDPRCFeedEngine' - sends recent changes over UDP to the specified server.
+ * - 'RedisPubSubFeedEngine' - send recent changes to Redis.
+ *
+ * Only 'class' or 'uri' is required. If 'uri' is set instead of 'class', then
+ * RecentChange::getEngine() is used to determine the class. All options are
+ * passed to the constructor.
+ *
+ * Common options:
+ * - 'class' -- The class to use for this feed (must implement RCFeed).
+ * - 'omit_bots' -- Exclude bot edits from the feed. (default: false)
+ * - 'omit_anon' -- Exclude anonymous edits from the feed. (default: false)
+ * - 'omit_user' -- Exclude edits by registered users from the feed. (default: false)
+ * - 'omit_minor' -- Exclude minor edits from the feed. (default: false)
+ * - 'omit_patrolled' -- Exclude patrolled edits from the feed. (default: false)
+ *
+ * FormattedRCFeed-specific options:
+ * - 'uri' -- [required] The address to which the messages are sent.
+ *   The uri scheme of this string will be looked up in $wgRCEngines
+ *   to determine which RCFeedEngine class to use.
+ * - 'formatter' -- [required] The class (implementing RCFeedFormatter) which will
+ *   produce the text to send. This can also be an object of the class.
+ *   Formatters available by default: JSONRCFeedFormatter, XMLRCFeedFormatter,
+ *   IRCColourfulRCFeedFormatter.
+ *
+ * IRCColourfulRCFeedFormatter-specific options:
+ * - 'add_interwiki_prefix' -- whether the titles should be prefixed with
+ *   the first entry in the $wgLocalInterwikis array (or the value of
+ *   $wgLocalInterwiki, if set)
+ *
+ * JSONRCFeedFormatter-specific options:
+ * - 'channel' -- if set, the 'channel' parameter is also set in JSON values.
  *
  * @example $wgRCFeeds['example'] = [
+ *             'uri' => 'udp://localhost:1336',
  *             'formatter' => 'JSONRCFeedFormatter',
- *             'uri' => "udp://localhost:1336",
  *             'add_interwiki_prefix' => false,
  *             'omit_bots' => true,
  *     ];
- * @example $wgRCFeeds['exampleirc'] = [
+ * @example $wgRCFeeds['example'] = [
+ *             'uri' => 'udp://localhost:1338',
  *             'formatter' => 'IRCColourfulRCFeedFormatter',
- *             'uri' => "udp://localhost:1338",
  *             'add_interwiki_prefix' => false,
  *             'omit_bots' => true,
  *     ];
+ * @example $wgRCFeeds['example'] = [
+ *      'class' => 'ExampleRCFeed',
+ *     ];
  * @since 1.22
  */
 $wgRCFeeds = [];
 
 /**
- * Used by RecentChange::getEngine to find the correct engine to use for a given URI scheme.
- * Keys are scheme names, values are names of engine classes.
+ * Used by RecentChange::getEngine to find the correct engine for a given URI scheme.
+ * Keys are scheme names, values are names of FormattedRCFeed sub classes.
+ * @since 1.22
  */
 $wgRCEngines = [
        'redis' => 'RedisPubSubFeedEngine',
index 2b1bd7b..34062c0 100644 (file)
@@ -3543,15 +3543,7 @@ HTML
                // Avoid PHP 7.1 warning of passing $this by reference
                $editPage = $this;
                if ( Hooks::run( 'EditPageBeforeConflictDiff', [ &$editPage, &$wgOut ] ) ) {
-                       $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
-                       $stats->increment( 'edit.failures.conflict' );
-                       // Only include 'standard' namespaces to avoid creating unknown numbers of statsd metrics
-                       if (
-                               $this->mTitle->getNamespace() >= NS_MAIN &&
-                               $this->mTitle->getNamespace() <= NS_CATEGORY_TALK
-                       ) {
-                               $stats->increment( 'edit.failures.conflict.byNamespaceId.' . $this->mTitle->getNamespace() );
-                       }
+                       $this->incrementConflictStats();
 
                        $wgOut->wrapWikiMsg( '<h2>$1</h2>', "yourdiff" );
 
@@ -3571,6 +3563,18 @@ HTML
                }
        }
 
+       private function incrementConflictStats() {
+               $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
+               $stats->increment( 'edit.failures.conflict' );
+               // Only include 'standard' namespaces to avoid creating unknown numbers of statsd metrics
+               if (
+                       $this->mTitle->getNamespace() >= NS_MAIN &&
+                       $this->mTitle->getNamespace() <= NS_CATEGORY_TALK
+               ) {
+                       $stats->increment( 'edit.failures.conflict.byNamespaceId.' . $this->mTitle->getNamespace() );
+               }
+       }
+
        /**
         * @return string
         */
@@ -3683,8 +3687,6 @@ HTML
                global $wgOut, $wgRawHtml, $wgLang;
                global $wgAllowUserCss, $wgAllowUserJs;
 
-               $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
-
                if ( $wgRawHtml && !$this->mTokenOk ) {
                        // Could be an offsite preview attempt. This is very unsafe if
                        // HTML is enabled, as it could be an attack.
@@ -3697,7 +3699,7 @@ HTML
                                        $this->context->msg( 'session_fail_preview_html' )->text() . "</div>",
                                        true, /* interface */true );
                        }
-                       $stats->increment( 'edit.failures.session_loss' );
+                       $this->incrementEditFailureStats( 'session_loss' );
                        return $parsedNote;
                }
 
@@ -3721,15 +3723,15 @@ HTML
                        if ( $this->mTriedSave && !$this->mTokenOk ) {
                                if ( $this->mTokenOkExceptSuffix ) {
                                        $note = $this->context->msg( 'token_suffix_mismatch' )->plain();
-                                       $stats->increment( 'edit.failures.bad_token' );
+                                       $this->incrementEditFailureStats( 'bad_token' );
                                } else {
                                        $note = $this->context->msg( 'session_fail_preview' )->plain();
-                                       $stats->increment( 'edit.failures.session_loss' );
+                                       $this->incrementEditFailureStats( 'session_loss' );
                                }
                        } elseif ( $this->incompleteForm ) {
                                $note = $this->context->msg( 'edit_form_incomplete' )->plain();
                                if ( $this->mTriedSave ) {
-                                       $stats->increment( 'edit.failures.incomplete_form' );
+                                       $this->incrementEditFailureStats( 'incomplete_form' );
                                }
                        } else {
                                $note = $this->context->msg( 'previewnote' )->plain() . ' ' . $continueEditing;
@@ -3817,6 +3819,11 @@ HTML
                return $previewhead . $previewHTML . $this->previewTextAfterContent;
        }
 
+       private function incrementEditFailureStats( $failureType ) {
+               $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
+               $stats->increment( 'edit.failures.' . $failureType );
+       }
+
        /**
         * Get parser options for a preview
         * @return ParserOptions
index 5968e87..09317d7 100644 (file)
@@ -526,7 +526,7 @@ class MagicWord {
                $this->mFound = false;
                $text = preg_replace_callback(
                        $this->getRegex(),
-                       [ &$this, 'pregRemoveAndRecord' ],
+                       [ $this, 'pregRemoveAndRecord' ],
                        $text
                );
 
@@ -541,7 +541,7 @@ class MagicWord {
                $this->mFound = false;
                $text = preg_replace_callback(
                        $this->getRegexStart(),
-                       [ &$this, 'pregRemoveAndRecord' ],
+                       [ $this, 'pregRemoveAndRecord' ],
                        $text
                );
 
index 324d030..3124638 100644 (file)
@@ -37,11 +37,6 @@ class ApiPurge extends ApiBase {
         * Purges the cache of a page
         */
        public function execute() {
-               $main = $this->getMain();
-               if ( !$main->isInternalMode() && !$main->getRequest()->wasPosted() ) {
-                       $this->addDeprecation( 'apiwarn-deprecation-purge-get', 'purge-via-GET' );
-               }
-
                $params = $this->extractRequestParams();
 
                $continuationManager = new ApiContinuationManager( $this, [], [] );
@@ -60,12 +55,8 @@ class ApiPurge extends ApiBase {
                        ApiQueryBase::addTitleInfo( $r, $title );
                        $page = WikiPage::factory( $title );
                        if ( !$user->pingLimiter( 'purge' ) ) {
-                               $flags = WikiPage::PURGE_ALL;
-                               if ( !$this->getRequest()->wasPosted() ) {
-                                       $flags ^= WikiPage::PURGE_GLOBAL_PCACHE; // skip DB_MASTER write
-                               }
                                // Directly purge and skip the UI part of purge()
-                               $page->doPurge( $flags );
+                               $page->doPurge( WikiPage::PURGE_ALL );
                                $r['purged'] = true;
                        } else {
                                $this->addWarning( 'apierror-ratelimited' );
@@ -157,20 +148,7 @@ class ApiPurge extends ApiBase {
        }
 
        public function mustBePosted() {
-               // Anonymous users are not allowed a non-POST request
-               return !$this->getUser()->isAllowed( 'purge' );
-       }
-
-       protected function getHelpFlags() {
-               $flags = parent::getHelpFlags();
-
-               // Claim that we must be posted for the purposes of help and paraminfo.
-               // @todo Remove this when self::mustBePosted() is updated for T145649
-               if ( !in_array( 'mustbeposted', $flags, true ) ) {
-                       $flags[] = 'mustbeposted';
-               }
-
-               return $flags;
+               return true;
        }
 
        public function getAllowedParams( $flags = 0 ) {
index 1c3b171..136f60e 100644 (file)
@@ -147,7 +147,9 @@ class ApiQueryAllUsers extends ApiQueryBase {
                        $this->addJoinConds( [ 'ug1' => [ 'LEFT OUTER JOIN',
                                array_merge( [
                                        'ug1.ug_user=user_id',
-                                       'ug1.ug_expiry IS NULL OR ug1.ug_expiry >= ' . $db->addQuotes( $db->timestamp() )
+                                       $this->getConfig()->get( 'DisableUserGroupExpiry' ) ?
+                                               '1' :
+                                               'ug1.ug_expiry IS NULL OR ug1.ug_expiry >= ' . $db->addQuotes( $db->timestamp() )
                                ], $exclude )
                        ] ] );
                        $this->addWhere( 'ug1.ug_user IS NULL' );
@@ -163,7 +165,9 @@ class ApiQueryAllUsers extends ApiQueryBase {
                        $this->addFields( [ 'groups' =>
                                $db->buildGroupConcatField( '|', 'user_groups', 'ug_group', [
                                        'ug_user=user_id',
-                                       'ug_expiry IS NULL OR ug_expiry >= ' . $db->addQuotes( $db->timestamp() )
+                                       $this->getConfig()->get( 'DisableUserGroupExpiry' ) ?
+                                               '1' :
+                                               'ug_expiry IS NULL OR ug_expiry >= ' . $db->addQuotes( $db->timestamp() )
                                ] )
                        ] );
                }
index 77038ed..1e88e13 100644 (file)
@@ -444,8 +444,10 @@ class ChangesList extends ContextSource {
 
                # TODO: Deprecate the $s argument, it seems happily unused.
                $s = '';
+               # Avoid PHP 7.1 warning from passing $this by reference
+               $changesList = $this;
                Hooks::run( 'ChangesListInsertArticleLink',
-                       [ &$this, &$articlelink, &$s, &$rc, $unpatrolled, $watched ] );
+                       [ &$changesList, &$articlelink, &$s, &$rc, $unpatrolled, $watched ] );
 
                return "{$s} {$articlelink}";
        }
index 8eb06ce..d862ef4 100644 (file)
@@ -50,7 +50,9 @@ class OldChangesList extends ChangesList {
                                $rc->mAttribs['rc_namespace'] . '-' . $rc->mAttribs['rc_title'] );
                }
 
-               if ( !Hooks::run( 'OldChangesListRecentChangesLine', [ &$this, &$html, $rc, &$classes ] ) ) {
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $list = $this;
+               if ( !Hooks::run( 'OldChangesListRecentChangesLine', [ &$list, &$html, $rc, &$classes ] ) ) {
                        return false;
                }
 
index 13a5fc7..507e6c3 100644 (file)
@@ -329,7 +329,9 @@ class RecentChange {
                $this->mAttribs['rc_id'] = $dbw->insertId();
 
                # Notify extensions
-               Hooks::run( 'RecentChange_save', [ &$this ] );
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $rc = $this;
+               Hooks::run( 'RecentChange_save', [ &$rc ] );
 
                if ( count( $this->tags ) ) {
                        ChangeTags::addTags( $this->tags, $this->mAttribs['rc_id'],
@@ -389,8 +391,8 @@ class RecentChange {
 
                $performer = $this->getPerformer();
 
-               foreach ( $feeds as $feed ) {
-                       $feed += [
+               foreach ( $feeds as $params ) {
+                       $params += [
                                'omit_bots' => false,
                                'omit_anon' => false,
                                'omit_user' => false,
@@ -399,58 +401,44 @@ class RecentChange {
                        ];
 
                        if (
-                               ( $feed['omit_bots'] && $this->mAttribs['rc_bot'] ) ||
-                               ( $feed['omit_anon'] && $performer->isAnon() ) ||
-                               ( $feed['omit_user'] && !$performer->isAnon() ) ||
-                               ( $feed['omit_minor'] && $this->mAttribs['rc_minor'] ) ||
-                               ( $feed['omit_patrolled'] && $this->mAttribs['rc_patrolled'] ) ||
+                               ( $params['omit_bots'] && $this->mAttribs['rc_bot'] ) ||
+                               ( $params['omit_anon'] && $performer->isAnon() ) ||
+                               ( $params['omit_user'] && !$performer->isAnon() ) ||
+                               ( $params['omit_minor'] && $this->mAttribs['rc_minor'] ) ||
+                               ( $params['omit_patrolled'] && $this->mAttribs['rc_patrolled'] ) ||
                                $this->mAttribs['rc_type'] == RC_EXTERNAL
                        ) {
                                continue;
                        }
 
-                       $engine = self::getEngine( $feed['uri'] );
-
                        if ( isset( $this->mExtra['actionCommentIRC'] ) ) {
                                $actionComment = $this->mExtra['actionCommentIRC'];
                        } else {
                                $actionComment = null;
                        }
 
-                       /** @var $formatter RCFeedFormatter */
-                       $formatter = is_object( $feed['formatter'] ) ? $feed['formatter'] : new $feed['formatter']();
-                       $line = $formatter->getLine( $feed, $this, $actionComment );
-                       if ( !$line ) {
-                               // T109544
-                               // If a feed formatter returns null, this will otherwise cause an
-                               // error in at least RedisPubSubFeedEngine.
-                               // Not sure where/how this should best be handled.
-                               continue;
-                       }
-
-                       $engine->send( $feed, $line );
+                       $feed = RCFeed::factory( $params );
+                       $feed->notify( $this, $actionComment );
                }
        }
 
        /**
-        * Gets the stream engine object for a given URI from $wgRCEngines
-        *
+        * @since 1.22
+        * @deprecated since 1.29 Use RCFeed::factory() instead
         * @param string $uri URI to get the engine object for
-        * @throws MWException
         * @return RCFeedEngine The engine object
+        * @throws MWException
         */
        public static function getEngine( $uri ) {
+               // TODO: Merge into RCFeed::factory().
                global $wgRCEngines;
-
                $scheme = parse_url( $uri, PHP_URL_SCHEME );
                if ( !$scheme ) {
-                       throw new MWException( __FUNCTION__ . ": Invalid stream logger URI: '$uri'" );
+                       throw new MWException( "Invalid RCFeed uri: '$uri'" );
                }
-
                if ( !isset( $wgRCEngines[$scheme] ) ) {
-                       throw new MWException( __FUNCTION__ . ": Unknown stream logger URI scheme: $scheme" );
+                       throw new MWException( "Unknown RCFeedEngine scheme: '$scheme'" );
                }
-
                if ( defined( 'MW_PHPUNIT_TEST' ) && is_object( $wgRCEngines[$scheme] ) ) {
                        return $wgRCEngines[$scheme];
                }
index c3502f6..d8ed7a9 100644 (file)
@@ -668,7 +668,7 @@ class DatabaseOracle extends Database {
                list( $startOpts, $useIndex, $tailOpts, $ignoreIndex ) =
                        $this->makeSelectOptions( $selectOptions );
                if ( is_array( $srcTable ) ) {
-                       $srcTable = implode( ',', array_map( [ &$this, 'tableName' ], $srcTable ) );
+                       $srcTable = implode( ',', array_map( [ $this, 'tableName' ], $srcTable ) );
                } else {
                        $srcTable = $this->tableName( $srcTable );
                }
@@ -998,7 +998,7 @@ class DatabaseOracle extends Database {
        private function fieldInfoMulti( $table, $field ) {
                $field = strtoupper( $field );
                if ( is_array( $table ) ) {
-                       $table = array_map( [ &$this, 'tableNameInternal' ], $table );
+                       $table = array_map( [ $this, 'tableNameInternal' ], $table );
                        $tableWhere = 'IN (';
                        foreach ( $table as &$singleTable ) {
                                $singleTable = $this->removeIdentifierQuotes( $singleTable );
index 229a9a2..464c908 100644 (file)
@@ -154,7 +154,9 @@ class LinksUpdate extends DataUpdate implements EnqueueableDataUpdate {
 
                $this->mRecursive = $recursive;
 
-               Hooks::run( 'LinksUpdateConstructed', [ &$this ] );
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $linksUpdate = $this;
+               Hooks::run( 'LinksUpdateConstructed', [ &$linksUpdate ] );
        }
 
        /**
@@ -169,7 +171,9 @@ class LinksUpdate extends DataUpdate implements EnqueueableDataUpdate {
                        $scopedLock = self::acquirePageLock( $this->getDB(), $this->mId );
                }
 
-               Hooks::run( 'LinksUpdate', [ &$this ] );
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $linksUpdate = $this;
+               Hooks::run( 'LinksUpdate', [ &$linksUpdate ] );
                $this->doIncrementalUpdate();
 
                // Commit and release the lock (if set)
@@ -177,7 +181,9 @@ class LinksUpdate extends DataUpdate implements EnqueueableDataUpdate {
                // Run post-commit hooks without DBO_TRX
                $this->getDB()->onTransactionIdle(
                        function () {
-                               Hooks::run( 'LinksUpdateComplete', [ &$this, $this->ticket ] );
+                               // Avoid PHP 7.1 warning from passing $this by reference
+                               $linksUpdate = $this;
+                               Hooks::run( 'LinksUpdateComplete', [ &$linksUpdate, $this->ticket ] );
                        },
                        __METHOD__
                );
index 559a5ec..5367199 100644 (file)
@@ -762,8 +762,11 @@ class DifferenceEngine extends ContextSource {
 
                $difftext = $this->generateContentDiffBody( $this->mOldContent, $this->mNewContent );
 
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $diffEngine = $this;
+
                // Save to cache for 7 days
-               if ( !Hooks::run( 'AbortDiffCache', [ &$this ] ) ) {
+               if ( !Hooks::run( 'AbortDiffCache', [ &$diffEngine ] ) ) {
                        wfIncrStats( 'diff_cache.uncacheable' );
                } elseif ( $key !== false && $difftext !== false ) {
                        wfIncrStats( 'diff_cache.miss' );
@@ -982,7 +985,7 @@ class DifferenceEngine extends ContextSource {
        public function localiseLineNumbers( $text ) {
                return preg_replace_callback(
                        '/<!--LINE (\d+)-->/',
-                       [ &$this, 'localiseLineNumbersCb' ],
+                       [ $this, 'localiseLineNumbersCb' ],
                        $text
                );
        }
index 5be166b..52bf0f0 100644 (file)
@@ -269,7 +269,9 @@ class XmlDumpWriter {
                        $out .= "      <sha1/>\n";
                }
 
-               Hooks::run( 'XmlDumpWriterWriteRevision', [ &$this, &$out, $row, $text ] );
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $writer = $this;
+               Hooks::run( 'XmlDumpWriterWriteRevision', [ &$writer, &$out, $row, $text ] );
 
                $out .= "    </revision>\n";
 
index be0751f..8c088b9 100644 (file)
@@ -1070,7 +1070,9 @@ class LocalFile extends File {
                $opts['ORDER BY'] = "oi_timestamp $order";
                $opts['USE INDEX'] = [ 'oldimage' => 'oi_name_timestamp' ];
 
-               Hooks::run( 'LocalFile::getHistory', [ &$this, &$tables, &$fields,
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $localFile = $this;
+               Hooks::run( 'LocalFile::getHistory', [ &$localFile, &$tables, &$fields,
                        &$conds, &$opts, &$join_conds ] );
 
                $res = $dbr->select( $tables, $fields, $conds, __METHOD__, $opts, $join_conds );
index e627cfd..179f6af 100644 (file)
@@ -165,6 +165,7 @@ class HTMLForm extends ContextSource {
                'url' => 'HTMLTextField',
                'title' => 'HTMLTitleTextField',
                'user' => 'HTMLUserTextField',
+               'usersmultiselect' => 'HTMLUsersMultiselectField',
        ];
 
        public $mFieldData;
diff --git a/includes/htmlform/fields/HTMLUsersMultiselectField.php b/includes/htmlform/fields/HTMLUsersMultiselectField.php
new file mode 100644 (file)
index 0000000..8c1241d
--- /dev/null
@@ -0,0 +1,86 @@
+<?php
+
+use MediaWiki\Widget\UsersMultiselectWidget;
+
+/**
+ * Implements a capsule multiselect input field for user names.
+ *
+ * Besides the parameters recognized by HTMLUserTextField, additional recognized
+ * parameters are:
+ *  default - (optional) Array of usernames to use as preset data
+ *  placeholder - (optional) Custom placeholder message for input
+ *
+ * The result is the array of usernames
+ *
+ * @note This widget is not likely to remain functional in non-OOUI forms.
+ */
+class HTMLUsersMultiselectField extends HTMLUserTextField {
+
+       public function loadDataFromRequest( $request ) {
+               if ( !$request->getCheck( $this->mName ) ) {
+                       return $this->getDefault();
+               }
+
+               $usersArray = explode( "\n", $request->getText( $this->mName ) );
+               // Remove empty lines
+               $usersArray = array_values( array_filter( $usersArray, function( $username ) {
+                       return trim( $username ) !== '';
+               } ) );
+               return $usersArray;
+       }
+
+       public function validate( $value, $alldata ) {
+               if ( !$this->mParams['exists'] ) {
+                       return true;
+               }
+
+               if ( is_null( $value ) ) {
+                       return false;
+               }
+
+               foreach ( $value as $username ) {
+                       $result = parent::validate( $username, $alldata );
+                       if ( $result !== true ) {
+                               return $result;
+                       }
+               }
+
+               return true;
+       }
+
+       public function getInputHTML( $values ) {
+               $this->mParent->getOutput()->enableOOUI();
+               return $this->getInputOOUI( $values );
+       }
+
+       public function getInputOOUI( $values ) {
+               $params = [ 'name' => $this->mName ];
+
+               if ( isset( $this->mParams['default'] ) ) {
+                       $params['default'] = $this->mParams['default'];
+               }
+
+               if ( isset( $this->mParams['placeholder'] ) ) {
+                       $params['placeholder'] = $this->mParams['placeholder'];
+               } else {
+                       $params['placeholder'] = $this->msg( 'mw-widgets-usersmultiselect-placeholder' )
+                                                       ->inContentLanguage()
+                                                       ->plain();
+               }
+
+               if ( !is_null( $values ) ) {
+                       $params['default'] = $values;
+               }
+
+               return new UsersMultiselectWidget( $params );
+       }
+
+       protected function shouldInfuseOOUI() {
+               return true;
+       }
+
+       protected function getOOUIModules() {
+               return [ 'mediawiki.widgets.UsersMultiselectWidget' ];
+       }
+
+}
index 7a27f5f..9be6c3d 100644 (file)
@@ -294,6 +294,7 @@ class MysqlUpdater extends DatabaseUpdater {
 
                        // 1.29
                        [ 'addField', 'externallinks', 'el_index_60', 'patch-externallinks-el_index_60.sql' ],
+                       [ 'dropIndex', 'user_groups', 'ug_user_group', 'patch-user_groups-primary-key.sql' ],
                        [ 'addField', 'user_groups', 'ug_expiry', 'patch-user_groups-ug_expiry.sql' ],
                ];
        }
index 68d500b..d15d6f1 100644 (file)
@@ -2314,7 +2314,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        $selectOptions );
 
                if ( is_array( $srcTable ) ) {
-                       $srcTable = implode( ',', array_map( [ &$this, 'tableName' ], $srcTable ) );
+                       $srcTable = implode( ',', array_map( [ $this, 'tableName' ], $srcTable ) );
                } else {
                        $srcTable = $this->tableName( $srcTable );
                }
index 42113b0..75cc97c 100644 (file)
@@ -698,7 +698,7 @@ __INDEXATTR__;
                list( $startOpts, $useIndex, $tailOpts, $ignoreIndex ) =
                        $this->makeSelectOptions( $selectOptions );
                if ( is_array( $srcTable ) ) {
-                       $srcTable = implode( ',', array_map( [ &$this, 'tableName' ], $srcTable ) );
+                       $srcTable = implode( ',', array_map( [ $this, 'tableName' ], $srcTable ) );
                } else {
                        $srcTable = $this->tableName( $srcTable );
                }
@@ -1257,7 +1257,7 @@ SQL;
 
                if ( isset( $options['FOR UPDATE'] ) ) {
                        $postLimitTail .= ' FOR UPDATE OF ' .
-                               implode( ', ', array_map( [ &$this, 'tableName' ], $options['FOR UPDATE'] ) );
+                               implode( ', ', array_map( [ $this, 'tableName' ], $options['FOR UPDATE'] ) );
                } elseif ( isset( $noKeyOptions['FOR UPDATE'] ) ) {
                        $postLimitTail .= ' FOR UPDATE';
                }
index 3b97835..655e771 100644 (file)
@@ -27,7 +27,7 @@ abstract class Replacer {
         * @return array
         */
        public function cb() {
-               return [ &$this, 'replace' ];
+               return [ $this, 'replace' ];
        }
 
        /**
index b60b010..c75cfdd 100644 (file)
@@ -336,7 +336,7 @@ class ImagePage extends Article {
                        $filename = wfEscapeWikiText( $this->displayImg->getName() );
                        $linktext = $filename;
 
-                       // Use of &$this in hooks triggers warnings in PHP 7.1
+                       // Avoid PHP 7.1 warning from passing $this by reference
                        $imagePage = $this;
 
                        Hooks::run( 'ImageOpenShowImageInlineBefore', [ &$imagePage, &$out ] );
index 1c1412a..232f6cc 100644 (file)
@@ -323,7 +323,7 @@ class WikiPage implements Page, IDBAccessObject {
 
                $row = $dbr->selectRow( 'page', $fields, $conditions, __METHOD__, $options );
 
-               Hooks::run( 'ArticlePageDataAfter', [ &$this, &$row ] );
+               Hooks::run( 'ArticlePageDataAfter', [ &$wikiPage, &$row ] );
 
                return $row;
        }
index 08e3c77..76ee525 100644 (file)
@@ -197,7 +197,7 @@ class DateFormatter {
 
                        // Another horrible hack
                        $this->mLinked = $linked;
-                       $text = preg_replace_callback( $regex, [ &$this, 'replace' ], $text );
+                       $text = preg_replace_callback( $regex, [ $this, 'replace' ], $text );
                        unset( $this->mLinked );
                }
                return $text;
index e7712f2..d2a0a1a 100644 (file)
@@ -613,7 +613,7 @@ class LinkHolderArray {
        public function replaceText( $text ) {
                $text = preg_replace_callback(
                        '/<!--(LINK|IWLINK) (.*?)-->/',
-                       [ &$this, 'replaceTextCallback' ],
+                       [ $this, 'replaceTextCallback' ],
                        $text );
 
                return $text;
index 3ec0596..1d55c98 100644 (file)
@@ -330,7 +330,9 @@ class Parser {
                CoreTagHooks::register( $this );
                $this->initialiseVariables();
 
-               Hooks::run( 'ParserFirstCallInit', [ &$this ] );
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $parser = $this;
+               Hooks::run( 'ParserFirstCallInit', [ &$parser ] );
        }
 
        /**
@@ -381,7 +383,9 @@ class Parser {
 
                $this->mProfiler = new SectionProfiler();
 
-               Hooks::run( 'ParserClearState', [ &$this ] );
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $parser = $this;
+               Hooks::run( 'ParserClearState', [ &$parser ] );
        }
 
        /**
@@ -435,11 +439,13 @@ class Parser {
                        $this->mRevisionSize = null;
                }
 
-               Hooks::run( 'ParserBeforeStrip', [ &$this, &$text, &$this->mStripState ] );
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $parser = $this;
+               Hooks::run( 'ParserBeforeStrip', [ &$parser, &$text, &$this->mStripState ] );
                # No more strip!
-               Hooks::run( 'ParserAfterStrip', [ &$this, &$text, &$this->mStripState ] );
+               Hooks::run( 'ParserAfterStrip', [ &$parser, &$text, &$this->mStripState ] );
                $text = $this->internalParse( $text );
-               Hooks::run( 'ParserAfterParse', [ &$this, &$text, &$this->mStripState ] );
+               Hooks::run( 'ParserAfterParse', [ &$parser, &$text, &$this->mStripState ] );
 
                $text = $this->internalParseHalfParsed( $text, true, $linestart );
 
@@ -615,8 +621,10 @@ class Parser {
         * @return string UNSAFE half-parsed HTML
         */
        public function recursiveTagParse( $text, $frame = false ) {
-               Hooks::run( 'ParserBeforeStrip', [ &$this, &$text, &$this->mStripState ] );
-               Hooks::run( 'ParserAfterStrip', [ &$this, &$text, &$this->mStripState ] );
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $parser = $this;
+               Hooks::run( 'ParserBeforeStrip', [ &$parser, &$text, &$this->mStripState ] );
+               Hooks::run( 'ParserAfterStrip', [ &$parser, &$text, &$this->mStripState ] );
                $text = $this->internalParse( $text, false, $frame );
                return $text;
        }
@@ -663,8 +671,10 @@ class Parser {
                if ( $revid !== null ) {
                        $this->mRevisionId = $revid;
                }
-               Hooks::run( 'ParserBeforeStrip', [ &$this, &$text, &$this->mStripState ] );
-               Hooks::run( 'ParserAfterStrip', [ &$this, &$text, &$this->mStripState ] );
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $parser = $this;
+               Hooks::run( 'ParserBeforeStrip', [ &$parser, &$text, &$this->mStripState ] );
+               Hooks::run( 'ParserAfterStrip', [ &$parser, &$text, &$this->mStripState ] );
                $text = $this->replaceVariables( $text, $frame );
                $text = $this->mStripState->unstripBoth( $text );
                return $text;
@@ -1259,8 +1269,11 @@ class Parser {
 
                $origText = $text;
 
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $parser = $this;
+
                # Hook to suspend the parser in this state
-               if ( !Hooks::run( 'ParserBeforeInternalParse', [ &$this, &$text, &$this->mStripState ] ) ) {
+               if ( !Hooks::run( 'ParserBeforeInternalParse', [ &$parser, &$text, &$this->mStripState ] ) ) {
                        return $text;
                }
 
@@ -1280,16 +1293,16 @@ class Parser {
                        $text = $this->replaceVariables( $text );
                }
 
-               Hooks::run( 'InternalParseBeforeSanitize', [ &$this, &$text, &$this->mStripState ] );
+               Hooks::run( 'InternalParseBeforeSanitize', [ &$parser, &$text, &$this->mStripState ] );
                $text = Sanitizer::removeHTMLtags(
                        $text,
-                       [ &$this, 'attributeStripCallback' ],
+                       [ $this, 'attributeStripCallback' ],
                        false,
                        array_keys( $this->mTransparentTagHooks ),
                        [],
-                       [ &$this, 'addTrackingCategory' ]
+                       [ $this, 'addTrackingCategory' ]
                );
-               Hooks::run( 'InternalParseBeforeLinks', [ &$this, &$text, &$this->mStripState ] );
+               Hooks::run( 'InternalParseBeforeLinks', [ &$parser, &$text, &$this->mStripState ] );
 
                # Tables need to come after variable replacement for things to work
                # properly; putting them before other transformations should keep
@@ -1328,8 +1341,11 @@ class Parser {
        private function internalParseHalfParsed( $text, $isMain = true, $linestart = true ) {
                $text = $this->mStripState->unstripGeneral( $text );
 
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $parser = $this;
+
                if ( $isMain ) {
-                       Hooks::run( 'ParserAfterUnstrip', [ &$this, &$text ] );
+                       Hooks::run( 'ParserAfterUnstrip', [ &$parser, &$text ] );
                }
 
                # Clean up special characters, only run once, next-to-last before doBlockLevels
@@ -1368,7 +1384,7 @@ class Parser {
                $text = $this->mStripState->unstripNoWiki( $text );
 
                if ( $isMain ) {
-                       Hooks::run( 'ParserBeforeTidy', [ &$this, &$text ] );
+                       Hooks::run( 'ParserBeforeTidy', [ &$parser, &$text ] );
                }
 
                $text = $this->replaceTransparentTags( $text );
@@ -1409,7 +1425,7 @@ class Parser {
                }
 
                if ( $isMain ) {
-                       Hooks::run( 'ParserAfterTidy', [ &$this, &$text ] );
+                       Hooks::run( 'ParserAfterTidy', [ &$parser, &$text ] );
                }
 
                return $text;
@@ -1447,7 +1463,7 @@ class Parser {
                                        (?: [0-9]  $spdash? ){9} #  9 digits with opt. delimiters
                                        [0-9Xx]                  #  check digit
                                )\b
-                       )!xu", [ &$this, 'magicLinkCallback' ], $text );
+                       )!xu", [ $this, 'magicLinkCallback' ], $text );
                return $text;
        }
 
@@ -2486,18 +2502,21 @@ class Parser {
                                . ' called while parsing (no title set)' );
                }
 
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $parser = $this;
+
                /**
                 * Some of these require message or data lookups and can be
                 * expensive to check many times.
                 */
-               if ( Hooks::run( 'ParserGetVariableValueVarCache', [ &$this, &$this->mVarCache ] ) ) {
+               if ( Hooks::run( 'ParserGetVariableValueVarCache', [ &$parser, &$this->mVarCache ] ) ) {
                        if ( isset( $this->mVarCache[$index] ) ) {
                                return $this->mVarCache[$index];
                        }
                }
 
                $ts = wfTimestamp( TS_UNIX, $this->mOptions->getTimestamp() );
-               Hooks::run( 'ParserGetVariableValueTs', [ &$this, &$ts ] );
+               Hooks::run( 'ParserGetVariableValueTs', [ &$parser, &$ts ] );
 
                $pageLang = $this->getFunctionLang();
 
@@ -2810,7 +2829,7 @@ class Parser {
                                $ret = null;
                                Hooks::run(
                                        'ParserGetVariableValueSwitch',
-                                       [ &$this, &$this->mVarCache, &$index, &$ret, &$frame ]
+                                       [ &$parser, &$this->mVarCache, &$index, &$ret, &$frame ]
                                );
 
                                return $ret;
@@ -3354,7 +3373,10 @@ class Parser {
                        throw new MWException( "Tag hook for $function is not callable\n" );
                }
 
-               $allArgs = [ &$this ];
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $parser = $this;
+
+               $allArgs = [ &$parser ];
                if ( $flags & self::SFH_OBJECT_ARGS ) {
                        # Convert arguments to PPNodes and collect for appending to $allArgs
                        $funcArgs = [];
@@ -3863,7 +3885,9 @@ class Parser {
                                        throw new MWException( "Tag hook for $name is not callable\n" );
                                }
 
-                               $output = call_user_func_array( $callback, [ &$this, $frame, $content, $attributes ] );
+                               // Avoid PHP 7.1 warning from passing $this by reference
+                               $parser = $this;
+                               $output = call_user_func_array( $callback, [ &$parser, $frame, $content, $attributes ] );
                        } else {
                                $output = '<span class="error">Invalid tag extension name: ' .
                                        htmlspecialchars( $name ) . '</span>';
@@ -4966,7 +4990,9 @@ class Parser {
                }
                $ig->setAdditionalOptions( $params );
 
-               Hooks::run( 'BeforeParserrenderImageGallery', [ &$this, &$ig ] );
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $parser = $this;
+               Hooks::run( 'BeforeParserrenderImageGallery', [ &$parser, &$ig ] );
 
                $lines = StringUtils::explode( "\n", $text );
                foreach ( $lines as $line ) {
diff --git a/includes/rcfeed/FormattedRCFeed.php b/includes/rcfeed/FormattedRCFeed.php
new file mode 100644 (file)
index 0000000..d841681
--- /dev/null
@@ -0,0 +1,68 @@
+<?php
+/**
+ * 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
+ */
+
+/**
+ * Base class for RC feed engines that send messages in a freely configurable
+ * format to a uri-addressed engine set in $wgRCEngines.
+ * @since 1.29
+ */
+abstract class FormattedRCFeed extends RCFeed {
+       private $params;
+
+       /**
+        * @param array $params
+        *  - 'uri'
+        *  - 'formatter'
+        * @see $wgRCFeeds
+        */
+       public function __construct( array $params ) {
+               $this->params = $params;
+       }
+
+       /**
+        * Send some text to the specified feed.
+        *
+        * @param array $feed The feed, as configured in an associative array
+        * @param string $line The text to send
+        * @return bool Success
+        */
+       abstract public function send( array $feed, $line );
+
+       /**
+        * @param RecentChange $rc
+        * @param string|null $actionComment
+        * @return bool Success
+        */
+       public function notify( RecentChange $rc, $actionComment = null ) {
+               $params = $this->params;
+               /** @var $formatter RCFeedFormatter */
+               $formatter = is_object( $params['formatter'] ) ? $params['formatter'] : new $params['formatter'];
+
+               $line = $formatter->getLine( $params, $rc, $actionComment );
+               if ( !$line ) {
+                       // @codeCoverageIgnoreStart
+                       // T109544 - If a feed formatter returns null, this will otherwise cause an
+                       // error in at least RedisPubSubFeedEngine. Not sure best to handle this.
+                       return;
+                       // @codeCoverageIgnoreEnd
+               }
+               return $this->send( $params, $line );
+       }
+}
diff --git a/includes/rcfeed/RCFeed.php b/includes/rcfeed/RCFeed.php
new file mode 100644 (file)
index 0000000..7e9ce60
--- /dev/null
@@ -0,0 +1,59 @@
+<?php
+/**
+ * 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
+ */
+
+/**
+ * @see $wgRCFeeds
+ * @since 1.29
+ */
+abstract class RCFeed {
+       /**
+        * @param array $params
+        */
+       public function __construct( array $params = [] ) {
+       }
+
+       /**
+        * Dispatch the recent changes notification.
+        *
+        * @param RecentChange $rc
+        * @param string|null $actionComment
+        * @return bool Success
+        */
+       abstract public function notify( RecentChange $rc, $actionComment = null );
+
+       /**
+        * @param array $params
+        * @return RCFeed
+        * @throws Exception
+        */
+       final public static function factory( array $params ) {
+               if ( !isset( $params['class'] ) ) {
+                       if ( !isset( $params['uri'] ) ) {
+                               throw new Exception( "RCFeeds must have a 'class' or 'uri' set." );
+                       }
+                       return RecentChange::getEngine( $params['uri'] );
+               }
+               $class = $params['class'];
+               if ( !class_exists( $class ) ) {
+                       throw new Exception( "Unknown class '$class'." );
+               }
+               return new $class( $params );
+       }
+}
index 0b0cd86..49436fa 100644 (file)
@@ -1,5 +1,4 @@
 <?php
-
 /**
  * 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
  */
 
 /**
- * Interface for RC feed engines, which send formatted notifications
- *
+ * Backward-compatibility alias.
  * @since 1.22
+ * @deprecated since 1.29 Use FormattedRCFeed instead
  */
-interface RCFeedEngine {
-       /**
-        * Sends some text to the specified live feed.
-        *
-        * @see IRCColourfulRCFeedFormatter::cleanupForIRC
-        * @param array $feed The feed, as configured in an associative array
-        * @param string $line The text to send
-        * @return bool Success
-        */
-       public function send( array $feed, $line );
+abstract class RCFeedEngine extends FormattedRCFeed {
 }
index c10e959..4c011be 100644 (file)
@@ -1,5 +1,4 @@
 <?php
-
 /**
  * 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
@@ -20,7 +19,7 @@
  */
 
 /**
- * Emit a recent change notification via Redis Pub/Sub
+ * Send recent change notifications via Redis Pub/Sub
  *
  * If the feed URI contains a path component, it will be used to generate a
  * channel name by stripping the leading slash and replacing any remaining
  *
  * @since 1.22
  */
-class RedisPubSubFeedEngine implements RCFeedEngine {
+class RedisPubSubFeedEngine extends RCFeedEngine {
 
        /**
-        * @see RCFeedEngine::send
+        * @see FormattedRCFeed::send
         */
        public function send( array $feed, $line ) {
                $parsed = wfParseUrl( $feed['uri'] );
index 9afae66..61ced5f 100644 (file)
@@ -1,5 +1,4 @@
 <?php
-
 /**
  * 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
  */
 
 /**
- * Sends the notification to the specified host in a UDP packet.
+ * Send recent change notifications in a UDP packet.
  * @since 1.22
  */
-
-class UDPRCFeedEngine implements RCFeedEngine {
+class UDPRCFeedEngine extends RCFeedEngine {
        /**
         * @see RCFeedEngine::send
         */
index f0b48d5..a55cbc1 100644 (file)
@@ -255,7 +255,10 @@ class ResourceLoader implements LoggerAwareInterface {
                $this->register( include "$IP/resources/ResourcesOOUI.php" );
                // Register extension modules
                $this->register( $config->get( 'ResourceModules' ) );
-               Hooks::run( 'ResourceLoaderRegisterModules', [ &$this ] );
+
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $rl = $this;
+               Hooks::run( 'ResourceLoaderRegisterModules', [ &$rl ] );
 
                if ( $config->get( 'EnableJavaScriptTest' ) === true ) {
                        $this->registerTestModules();
@@ -404,7 +407,9 @@ class ResourceLoader implements LoggerAwareInterface {
                $testModules = [];
                $testModules['qunit'] = [];
                // Get other test suites (e.g. from extensions)
-               Hooks::run( 'ResourceLoaderTestModules', [ &$testModules, &$this ] );
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $rl = $this;
+               Hooks::run( 'ResourceLoaderTestModules', [ &$testModules, &$rl ] );
 
                // Add the testrunner (which configures QUnit) to the dependencies.
                // Since it must be ready before any of the test suites are executed.
index 65eb9b7..eef421c 100644 (file)
@@ -112,7 +112,9 @@ abstract class BaseTemplate extends QuickTemplate {
                        $toolbox['info']['id'] = 't-info';
                }
 
-               Hooks::run( 'BaseTemplateToolbox', [ &$this, &$toolbox ] );
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $template = $this;
+               Hooks::run( 'BaseTemplateToolbox', [ &$template, &$toolbox ] );
                return $toolbox;
        }
 
@@ -227,7 +229,9 @@ abstract class BaseTemplate extends QuickTemplate {
                        ob_start();
                        // We pass an extra 'true' at the end so extensions using BaseTemplateToolbox
                        // can abort and avoid outputting double toolbox links
-                       Hooks::run( 'SkinTemplateToolboxEnd', [ &$this, true ] );
+                       // Avoid PHP 7.1 warning from passing $this by reference
+                       $template = $this;
+                       Hooks::run( 'SkinTemplateToolboxEnd', [ &$template, true ] );
                        $hookContents = ob_get_contents();
                        ob_end_clean();
                        if ( !trim( $hookContents ) ) {
index 298d6c4..0281b15 100644 (file)
@@ -630,7 +630,9 @@ class MovePageForm extends UnlistedSpecialPage {
                        $newLink )->params( $oldText, $newText )->parseAsBlock() );
                $out->addWikiMsg( $msgName );
 
-               Hooks::run( 'SpecialMovepageAfterMove', [ &$this, &$ot, &$nt ] );
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $movePage = $this;
+               Hooks::run( 'SpecialMovepageAfterMove', [ &$movePage, &$ot, &$nt ] );
 
                # Now we move extra pages we've been asked to move: subpages and talk
                # pages.  First, if the old page or the new page is a talk page, we
index b0808f6..454d1e3 100644 (file)
@@ -257,6 +257,7 @@ class UserrightsPage extends SpecialPage {
                $addgroup = [];
                $groupExpiries = []; // associative array of (group name => expiry)
                $removegroup = [];
+               $existingUGMs = $user->getGroupMemberships();
 
                // This could possibly create a highly unlikely race condition if permissions are changed between
                //  when the form is loaded and when the form is saved. Ignoring it for the moment.
@@ -269,12 +270,14 @@ class UserrightsPage extends SpecialPage {
                                if ( $this->canProcessExpiries() ) {
                                        // read the expiry information from the request
                                        $expiryDropdown = $this->getRequest()->getVal( "wpExpiry-$group" );
+                                       if ( $expiryDropdown === 'existing' ) {
+                                               continue;
+                                       }
+
                                        if ( $expiryDropdown === 'other' ) {
                                                $expiryValue = $this->getRequest()->getVal( "wpExpiry-$group-other" );
-                                       } elseif ( $expiryDropdown !== 'existing' ) {
-                                               $expiryValue = $expiryDropdown;
                                        } else {
-                                               continue;
+                                               $expiryValue = $expiryDropdown;
                                        }
 
                                        // validate the expiry
@@ -288,6 +291,16 @@ class UserrightsPage extends SpecialPage {
                                        if ( $groupExpiries[$group] && $groupExpiries[$group] < wfTimestampNow() ) {
                                                return Status::newFatal( 'userrights-expiry-in-past', $group );
                                        }
+
+                                       // if the user can only add this group (not remove it), the expiry time
+                                       // cannot be brought forward (T156784)
+                                       if ( !$this->canRemove( $group ) &&
+                                               isset( $existingUGMs[$group] ) &&
+                                               ( $existingUGMs[$group]->getExpiry() ?: 'infinity' ) >
+                                                       ( $groupExpiries[$group] ?: 'infinity' )
+                                       ) {
+                                               return Status::newFatal( 'userrights-cannot-shorten-expiry', $group );
+                                       }
                                }
                        } else {
                                $removegroup[] = $group;
@@ -300,7 +313,8 @@ class UserrightsPage extends SpecialPage {
        }
 
        /**
-        * Save user groups changes in the database.
+        * Save user groups changes in the database. This function does not throw errors;
+        * instead, it ignores groups that the performer does not have permission to set.
         *
         * @param User|UserRightsProxy $user
         * @param array $add Array of groups to add
@@ -317,6 +331,7 @@ class UserrightsPage extends SpecialPage {
                // Validate input set...
                $isself = $user->getName() == $this->getUser()->getName();
                $groups = $user->getGroups();
+               $ugms = $user->getGroupMemberships();
                $changeable = $this->changeableGroups();
                $addable = array_merge( $changeable['add'], $isself ? $changeable['add-self'] : [] );
                $removable = array_merge( $changeable['remove'], $isself ? $changeable['remove-self'] : [] );
@@ -325,9 +340,19 @@ class UserrightsPage extends SpecialPage {
                        array_intersect( (array)$remove, $removable, $groups ) );
                $add = array_intersect( (array)$add, $addable );
 
-               // add only groups that are not already present or that need their expiry updated
+               // add only groups that are not already present or that need their expiry updated,
+               // UNLESS the user can only add this group (not remove it) and the expiry time
+               // is being brought forward (T156784)
                $add = array_filter( $add,
-                       function( $group ) use ( $groups, $groupExpiries ) {
+                       function( $group ) use ( $groups, $groupExpiries, $removable, $ugms ) {
+                               if ( isset( $groupExpiries[$group] ) &&
+                                       !in_array( $group, $removable ) &&
+                                       isset( $ugms[$group] ) &&
+                                       ( $ugms[$group]->getExpiry() ?: 'infinity' ) >
+                                               ( $groupExpiries[$group] ?: 'infinity' )
+                               ) {
+                                       return false;
+                               }
                                return !in_array( $group, $groups ) || array_key_exists( $group, $groupExpiries );
                        } );
 
@@ -757,22 +782,30 @@ class UserrightsPage extends SpecialPage {
 
                foreach ( $allgroups as $group ) {
                        $set = isset( $usergroups[$group] );
+                       // Users who can add the group, but not remove it, can only lengthen
+                       // expiries, not shorten them. So they should only see the expiry
+                       // dropdown if the group currently has a finite expiry
+                       $canOnlyLengthenExpiry = ( $set && $this->canAdd( $group ) &&
+                                !$this->canRemove( $group ) && $usergroups[$group]->getExpiry() );
                        // Should the checkbox be disabled?
-                       $disabled = !(
+                       $disabledCheckbox = !(
                                ( $set && $this->canRemove( $group ) ) ||
                                ( !$set && $this->canAdd( $group ) ) );
+                       // Should the expiry elements be disabled?
+                       $disabledExpiry = $disabledCheckbox && !$canOnlyLengthenExpiry;
                        // Do we need to point out that this action is irreversible?
-                       $irreversible = !$disabled && (
+                       $irreversible = !$disabledCheckbox && (
                                ( $set && !$this->canAdd( $group ) ) ||
                                ( !$set && !$this->canRemove( $group ) ) );
 
                        $checkbox = [
                                'set' => $set,
-                               'disabled' => $disabled,
+                               'disabled' => $disabledCheckbox,
+                               'disabled-expiry' => $disabledExpiry,
                                'irreversible' => $irreversible
                        ];
 
-                       if ( $disabled ) {
+                       if ( $disabledCheckbox && $disabledExpiry ) {
                                $columns['unchangeable'][$group] = $checkbox;
                        } else {
                                $columns['changeable'][$group] = $checkbox;
@@ -806,12 +839,14 @@ class UserrightsPage extends SpecialPage {
                                $member = UserGroupMembership::getGroupMemberName( $group, $user->getName() );
                                if ( $checkbox['irreversible'] ) {
                                        $text = $this->msg( 'userrights-irreversible-marker', $member )->text();
+                               } elseif ( $checkbox['disabled'] && !$checkbox['disabled-expiry'] ) {
+                                       $text = $this->msg( 'userrights-no-shorten-expiry-marker', $member )->text();
                                } else {
                                        $text = $member;
                                }
                                $checkboxHtml = Xml::checkLabel( $text, "wpGroup-" . $group,
                                        "wpGroup-" . $group, $checkbox['set'], $attr );
-                               $ret .= "\t\t" . ( $checkbox['disabled']
+                               $ret .= "\t\t" . ( ( $checkbox['disabled'] && $checkbox['disabled-expiry'] )
                                        ? Xml::tags( 'div', [ 'class' => 'mw-userrights-disabled' ], $checkboxHtml )
                                        : Xml::tags( 'div', [], $checkboxHtml )
                                ) . "\n";
@@ -824,9 +859,11 @@ class UserrightsPage extends SpecialPage {
                                                $usergroups[$group]->getExpiry() :
                                                null;
 
-                                       // If the user can't uncheck this checkbox, print the current expiry below
+                                       // If the user can't modify the expiry, print the current expiry below
                                        // it in plain text. Otherwise provide UI to set/change the expiry
-                                       if ( $checkbox['set'] && ( $checkbox['irreversible'] || $checkbox['disabled'] ) ) {
+                                       if ( $checkbox['set'] &&
+                                               ( $checkbox['irreversible'] || $checkbox['disabled-expiry'] )
+                                       ) {
                                                if ( $currentExpiry ) {
                                                        $expiryFormatted = $uiLanguage->userTimeAndDate( $currentExpiry, $uiUser );
                                                        $expiryFormattedD = $uiLanguage->userDate( $currentExpiry, $uiUser );
@@ -848,7 +885,7 @@ class UserrightsPage extends SpecialPage {
                                                        "mw-input-wpExpiry-$group", // forward compatibility with HTMLForm
                                                        $currentExpiry ? 'existing' : 'infinite'
                                                );
-                                               if ( $checkbox['disabled'] ) {
+                                               if ( $checkbox['disabled-expiry'] ) {
                                                        $expiryFormOptions->setAttribute( 'disabled', 'disabled' );
                                                }
 
@@ -883,11 +920,17 @@ class UserrightsPage extends SpecialPage {
 
                                                // Add custom expiry field
                                                $attribs = [ 'id' => "mw-input-wpExpiry-$group-other" ];
-                                               if ( $checkbox['disabled'] ) {
+                                               if ( $checkbox['disabled-expiry'] ) {
                                                        $attribs['disabled'] = 'disabled';
                                                }
                                                $expiryHtml .= Xml::input( "wpExpiry-$group-other", 30, '', $attribs );
 
+                                               // If the user group is set but the checkbox is disabled, mimic a
+                                               // checked checkbox in the form submission
+                                               if ( $checkbox['set'] && $checkbox['disabled'] ) {
+                                                       $expiryHtml .= Html::hidden( "wpGroup-$group", 1 );
+                                               }
+
                                                $expiryHtml .= Xml::closeElement( 'span' );
                                        }
 
index c37ecbd..8cea6cc 100644 (file)
@@ -85,7 +85,9 @@ class WantedPagesPage extends WantedQueryPage {
                        ]
                ];
                // Replacement for the WantedPages::getSQL hook
-               Hooks::run( 'WantedPages::getQueryInfo', [ &$this, &$query ] );
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $wantedPages = $this;
+               Hooks::run( 'WantedPages::getQueryInfo', [ &$wantedPages, &$query ] );
 
                return $query;
        }
index 1fec05d..0d6f493 100644 (file)
@@ -111,7 +111,9 @@ class ActiveUsersPager extends UsersPager {
                                        'user_groups', '1', [
                                                'ug_user = user_id',
                                                'ug_group' => $group,
-                                               'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() )
+                                               $this->getConfig()->get( 'DisableUserGroupExpiry' ) ?
+                                                       '1' :
+                                                       'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() )
                                        ]
                                ) . ')';
                        }
index 367d073..0c3a211 100644 (file)
@@ -200,7 +200,9 @@ class ContribsPager extends ReverseChronologicalPager {
                        $this->tagFilter
                );
 
-               Hooks::run( 'ContribsPager::getQueryInfo', [ &$this, &$queryInfo ] );
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $pager = $this;
+               Hooks::run( 'ContribsPager::getQueryInfo', [ &$pager, &$queryInfo ] );
 
                return $queryInfo;
        }
index e298f10..dafd244 100644 (file)
@@ -100,8 +100,10 @@ class NewPagesPager extends ReverseChronologicalPager {
                ];
                $join_conds = [ 'page' => [ 'INNER JOIN', 'page_id=rc_cur_id' ] ];
 
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $pager = $this;
                Hooks::run( 'SpecialNewpagesConditions',
-                       [ &$this, $this->opts, &$conds, &$tables, &$fields, &$join_conds ] );
+                       [ &$pager, $this->opts, &$conds, &$tables, &$fields, &$join_conds ] );
 
                $options = [];
 
diff --git a/includes/widget/UsersMultiselectWidget.php b/includes/widget/UsersMultiselectWidget.php
new file mode 100644 (file)
index 0000000..d24ab7b
--- /dev/null
@@ -0,0 +1,68 @@
+<?php
+/**
+ * MediaWiki Widgets – UsersMultiselectWidget class.
+ *
+ * @copyright 2017 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+namespace MediaWiki\Widget;
+
+use \OOUI\TextInputWidget;
+
+/**
+ * Widget to select multiple users.
+ */
+class UsersMultiselectWidget extends \OOUI\Widget {
+
+       protected $usersArray = [];
+       protected $inputName = null;
+       protected $inputPlaceholder = null;
+
+       /**
+        * @param array $config Configuration options
+        * @param array $config['users'] Array of usernames to use as preset data
+        * @param array $config['placeholder'] Placeholder message for input
+        * @param array $config['name'] Name attribute (used in forms)
+        */
+       public function __construct( array $config = [] ) {
+               parent::__construct( $config );
+
+               // Properties
+               if ( isset( $config['default'] ) ) {
+                       $this->usersArray = $config['default'];
+               }
+               if ( isset( $config['name'] ) ) {
+                       $this->inputName = $config['name'];
+               }
+               if ( isset( $config['placeholder'] ) ) {
+                       $this->inputPlaceholder = $config['placeholder'];
+               }
+
+               $textarea = new TextInputWidget( [
+                       'name' => $this->inputName,
+                       'multiline' => true,
+                       'value' => implode( "\n", $this->usersArray ),
+                       'rows' => 25,
+               ] );
+               $this->prependContent( $textarea );
+       }
+
+       protected function getJavaScriptClassName() {
+               return 'mw.widgets.UsersMultiselectWidget';
+       }
+
+       public function getConfig( &$config ) {
+               if ( $this->usersArray !== null ) {
+                       $config['data'] = $this->usersArray;
+               }
+               if ( $this->inputName !== null ) {
+                       $config['name'] = $this->inputName;
+               }
+               if ( $this->inputPlaceholder !== null ) {
+                       $config['placeholder'] = $this->inputPlaceholder;
+               }
+
+               return parent::getConfig( $config );
+       }
+
+}
index 68727bb..a1cc4bc 100644 (file)
@@ -4508,7 +4508,7 @@ class Language {
                # such as action=raw much more expensive than they need to be.
                # This will hopefully cover most cases.
                $talk = preg_replace_callback( '/{{grammar:(.*?)\|(.*?)}}/i',
-                       [ &$this, 'replaceGrammarInNamespace' ], $talk );
+                       [ $this, 'replaceGrammarInNamespace' ], $talk );
                return str_replace( ' ', '_', $talk );
        }
 
index 2db5f4c..c771447 100644 (file)
        "userrights-groupsmember": "Member of:",
        "userrights-groupsmember-auto": "Implicit member of:",
        "userrights-groupsmember-type": "$1",
-       "userrights-groups-help": "You may alter the groups this user is in:\n* A checked box means the user is in that group.\n* An unchecked box means the user is not in that group.\n* A * indicates that you cannot remove the group once you have added it, or vice versa.",
+       "userrights-groups-help": "You may alter the groups this user is in:\n* A checked box means the user is in that group.\n* An unchecked box means the user is not in that group.\n* A * indicates that you cannot remove the group once you have added it, or vice versa.\n* A # indicates that you can only put back the expiration time of this group; you cannot bring it forward.",
        "userrights-reason": "Reason:",
        "userrights-no-interwiki": "You do not have permission to edit user rights on other wikis.",
        "userrights-nodatabase": "Database $1 does not exist or is not local.",
        "userrights-changeable-col": "Groups you can change",
        "userrights-unchangeable-col": "Groups you cannot change",
        "userrights-irreversible-marker": "$1*",
+       "userrights-no-shorten-expiry-marker": "$1#",
        "userrights-expiry-current": "Expires $1",
        "userrights-expiry-none": "Does not expire",
        "userrights-expiry": "Expires:",
        "userrights-expiry-options": "1 day:1 day,1 week:1 week,1 month:1 month,3 months:3 months,6 months:6 months,1 year:1 year",
        "userrights-invalid-expiry": "The expiry time for group \"$1\" is invalid.",
        "userrights-expiry-in-past": "The expiry time for group \"$1\" is in the past.",
+       "userrights-cannot-shorten-expiry": "You cannot bring forward the expiry of group \"$1\". Only users with permission to add and remove this group can bring forward expiry times.",
        "userrights-conflict": "Conflict of user rights changes! Please review and confirm your changes.",
        "group": "Group:",
        "group-user": "Users",
        "uploaded-setting-handler-svg": "SVG that sets the \"handler\" attribute with remote/data/script is blocked. Found <code>$1=\"$2\"</code> in the uploaded SVG file.",
        "uploaded-remote-url-svg": "SVG that sets any style attribute with remote URL is blocked. Found <code>$1=\"$2\"</code> in the uploaded SVG file.",
        "uploaded-image-filter-svg": "Found image filter with URL: <code>&lt;$1 $2=\"$3\"&gt;</code> in the uploaded SVG file.",
-       "uploadscriptednamespace": "This SVG file contains an illegal namespace \"$1\".",
+       "uploadscriptednamespace": "This SVG file contains an illegal namespace \"<nowiki>$1</nowiki>\".",
        "uploadinvalidxml": "The XML in the uploaded file could not be parsed.",
        "uploadvirus": "The file contains a virus!\nDetails: $1",
        "uploadjava": "The file is a ZIP file that contains a Java .class file.\nUploading Java files is not allowed because they can cause security restrictions to be bypassed.",
        "mw-widgets-titleinput-description-new-page": "page does not exist yet",
        "mw-widgets-titleinput-description-redirect": "redirect to $1",
        "mw-widgets-categoryselector-add-category-placeholder": "Add a category...",
+       "mw-widgets-usersmultiselect-placeholder": "Add more...",
        "sessionmanager-tie": "Cannot combine multiple request authentication types: $1.",
        "sessionprovider-generic": "$1 sessions",
        "sessionprovider-mediawiki-session-cookiesessionprovider": "cookie-based sessions",
index b141ffc..d5c16a9 100644 (file)
        "userrights-changeable-col": "Used when editing user groups in [[Special:Userrights]].\n\nThe message is the head of a column of group assignments.\n\nParameters:\n* $1 - (Optional) for PLURAL use, the number of items in the column following the message. Avoid PLURAL, if your language can do without.",
        "userrights-unchangeable-col": "Used when editing user groups in [[Special:Userrights]]. The message is the head of a column of group assignments.\n\nParameters:\n* $1 - (Optional) for PLURAL use, the number of items in the column following the message. Avoid PLURAL, if your language allows that.",
        "userrights-irreversible-marker": "{{optional}}\nParameters:\n* $1 - group member",
+       "userrights-no-shorten-expiry-marker": "{{optional}}\nParameters:\n* $1 - group member",
        "userrights-expiry-current": "Indicates when a user's membership of a user group expires.\n\nParameters:\n* $1 - time and date of expiry\n* $2 - date of expiry\n* $3 - time of expiry\n{{Identical|Expire}}",
        "userrights-expiry-none": "Indicates that a user's membership of a user group lasts indefinitely, and does not expire.",
        "userrights-expiry": "Used as a label for a form element which can be used to select an expiry date/time.\n{{Identical|Expire}}",
        "userrights-expiry-options": "{{doc-important|Be careful: '''1 translation:1 english''', so the first part is the translation and the second part should stay in English.}}\nOptions for the duration of the user group membership. Example: See e.g. [[MediaWiki:Userrights-expiry-options/nl]] if you still don't know how to do it.\n\nSee also {{msg-mw|protect-expiry-options}}.",
        "userrights-invalid-expiry": "Error message on [[Special:UserRights]].\n\nParameters:\n* $1 - group name",
        "userrights-expiry-in-past": "Error message on [[Special:UserRights]] when the user types an expiry date that has already passed.\n\nParameters:\n* $1 - group name",
+       "userrights-cannot-shorten-expiry": "Error message on [[Special:UserRights]] when the user tries to move the expiry date to be closer to the present and they do not have permission to do so. \"Bring forward\" is a phrasal verb meaning \"move to an earlier time\".\n\nParameters:\n* $1 - group name",
        "userrights-conflict": "Shown on [[Special:UserRights]] if the target's rights have been changed since the form was loaded.",
        "group": "{{Identical|Group}}",
        "group-user": "{{doc-group|user}}\n{{Identical|User}}",
        "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.",
        "mw-widgets-categoryselector-add-category-placeholder": "Placeholder displayed in the category selector widget after the capsules of already added categories.",
+       "mw-widgets-usersmultiselect-placeholder": "Placeholder displayed in the input field, where new usernames are entered",
        "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}}.",
index 3b788a3..5488b9a 100644 (file)
@@ -13,26 +13,34 @@ $fallback = 'id';
 $namespaceNames = [
        NS_MEDIA            => 'Media',
        NS_SPECIAL          => 'Astamiwa',
-       NS_TALK             => 'Dhiskusi',
+       NS_TALK             => 'Parembugan',
        NS_USER             => 'Panganggo',
-       NS_USER_TALK        => 'Dhiskusi_Panganggo',
-       NS_PROJECT_TALK     => 'Dhiskusi_$1',
+       NS_USER_TALK        => 'Parembugan_Panganggo',
+       NS_PROJECT_TALK     => 'Parembugan_$1',
        NS_FILE             => 'Gambar',
-       NS_FILE_TALK        => 'Dhiskusi_Gambar',
+       NS_FILE_TALK        => 'Parembugan_Gambar',
        NS_MEDIAWIKI        => 'MediaWiki',
-       NS_MEDIAWIKI_TALK   => 'Dhiskusi_MediaWiki',
+       NS_MEDIAWIKI_TALK   => 'Parembugan_MediaWiki',
        NS_TEMPLATE         => 'Cithakan',
-       NS_TEMPLATE_TALK    => 'Dhiskusi_Cithakan',
+       NS_TEMPLATE_TALK    => 'Parembugan_Cithakan',
        NS_HELP             => 'Pitulung',
-       NS_HELP_TALK        => 'Dhiskusi_Pitulung',
+       NS_HELP_TALK        => 'Parembugan_Pitulung',
        NS_CATEGORY         => 'Kategori',
-       NS_CATEGORY_TALK    => 'Dhiskusi_Kategori',
+       NS_CATEGORY_TALK    => 'Parembugan_Kategori',
 ];
 
-$namespaceAliases = [
-       'Gambar_Dhiskusi' => NS_FILE_TALK,
+$namespaceAliases = [ // Kept former namespaces for backwards compatibility - T155957
+       'Cithakan_Dhiskusi'  => NS_TEMPLATE_TALK,
+       'Dhiskusi'           => NS_TALK,
+       'Dhiskusi_$1'        => NS_PROJECT_TALK,
+       'Dhiskusi_Cithakan'  => NS_TEMPLATE_TALK,
+       'Dhiskusi_Gambar'    => NS_FILE_TALK,
+       'Dhiskusi_Kategori'  => NS_CATEGORY_TALK,
+       'Dhiskusi_MediaWiki' => NS_MEDIAWIKI_TALK,
+       'Dhiskusi_Panganggo' => NS_USER_TALK,
+       'Dhiskusi_Pitulung'  => NS_HELP_TALK,
+       'Kategori_Dhiskusi'  => NS_CATEGORY_TALK,
        'MediaWiki_Dhiskusi' => NS_MEDIAWIKI_TALK,
-       'Cithakan_Dhiskusi' => NS_TEMPLATE_TALK,
-       'Pitulung_Dhiskusi' => NS_HELP_TALK,
-       'Kategori_Dhiskusi' => NS_CATEGORY_TALK,
+       'Pitulung_Dhiskusi'  => NS_HELP_TALK,
+       'Gambar_Dhiskusi'    => NS_FILE_TALK,
 ];
diff --git a/maintenance/archives/patch-user_groups-primary-key.sql b/maintenance/archives/patch-user_groups-primary-key.sql
new file mode 100644 (file)
index 0000000..e3c8735
--- /dev/null
@@ -0,0 +1,5 @@
+-- Convert unique index into a primary key on user_groups
+
+ALTER TABLE /*$wgDBprefix*/user_groups
+  DROP INDEX ug_user_group,
+  ADD PRIMARY KEY (ug_user, ug_group);
index 2ce2c9e..b329f94 100644 (file)
@@ -1,7 +1,5 @@
--- Primary key and expiry column in user_groups table
+-- Add expiry column in user_groups table
 
 ALTER TABLE /*$wgDBprefix*/user_groups
-  DROP INDEX ug_user_group,
-  ADD PRIMARY KEY (ug_user, ug_group),
   ADD COLUMN ug_expiry varbinary(14) NULL default NULL,
   ADD INDEX ug_expiry (ug_expiry);
index f14787f..70eef62 100644 (file)
@@ -2367,6 +2367,15 @@ return [
                ],
                'targets' => [ 'desktop', 'mobile' ],
        ],
+       'mediawiki.widgets.UsersMultiselectWidget' => [
+               'scripts' => [
+                       'resources/src/mediawiki.widgets/mw.widgets.UsersMultiselectWidget.js',
+               ],
+               'dependencies' => [
+                       'oojs-ui-widgets',
+               ],
+               'targets' => [ 'desktop', 'mobile' ],
+       ],
        'mediawiki.widgets.SearchInputWidget' => [
                'scripts' => [
                        'resources/src/mediawiki.widgets/mw.widgets.SearchInputWidget.js',
index 92015a4..a68c90f 100644 (file)
@@ -29,7 +29,8 @@
                        "SnowedEarth",
                        "Jdforrester",
                        "Wladek92",
-                       "Harmonia Amanda"
+                       "Harmonia Amanda",
+                       "The RedBurn"
                ]
        },
        "ooui-outline-control-move-down": "Descendre l’élément",
@@ -41,7 +42,7 @@
        "ooui-dialog-message-accept": "OK",
        "ooui-dialog-message-reject": "Annuler",
        "ooui-dialog-process-error": "Quelque chose s'est mal passé",
-       "ooui-dialog-process-dismiss": "Rejeter",
+       "ooui-dialog-process-dismiss": "Fermer",
        "ooui-dialog-process-retry": "Réessayer",
        "ooui-dialog-process-continue": "Continuer",
        "ooui-selectfile-button-select": "Sélectionner un fichier",
diff --git a/resources/lib/oojs-ui/i18n/io.json b/resources/lib/oojs-ui/i18n/io.json
new file mode 100644 (file)
index 0000000..7d7b1b8
--- /dev/null
@@ -0,0 +1,23 @@
+{
+       "@metadata": {
+               "authors": [
+                       "Idojc"
+               ]
+       },
+       "ooui-outline-control-move-down": "Movar elemento adsube",
+       "ooui-outline-control-move-up": "Movar elemento adsupere",
+       "ooui-outline-control-remove": "Forigar elemento",
+       "ooui-toolbar-more": "Plu multa",
+       "ooui-toolgroup-expand": "Plu multa",
+       "ooui-toolgroup-collapse": "Min multa",
+       "ooui-dialog-message-accept": "Aplikar",
+       "ooui-dialog-message-reject": "Anular",
+       "ooui-dialog-process-error": "Ulo faliis",
+       "ooui-dialog-process-dismiss": "Celar",
+       "ooui-dialog-process-retry": "Riprobar",
+       "ooui-dialog-process-continue": "Durar",
+       "ooui-selectfile-button-select": "Selektar dokumento",
+       "ooui-selectfile-not-supported": "Dokumento-selekto ne esas suportata",
+       "ooui-selectfile-placeholder": "Nula dokumento selektesis",
+       "ooui-selectfile-dragdrop-placeholder": "Pozar dokumento hike"
+}
index de52812..9d3b926 100644 (file)
@@ -3,7 +3,8 @@
                "authors": [
                        "Milicevic01",
                        "Nikola Smolenski",
-                       "Милан Јелисавчић"
+                       "Милан Јелисавчић",
+                       "Zoranzoki21"
                ]
        },
        "ooui-outline-control-move-down": "Премести ставку на доле",
@@ -19,5 +20,7 @@
        "ooui-dialog-process-retry": "Покушај поново",
        "ooui-dialog-process-continue": "Настави",
        "ooui-selectfile-button-select": "Изабери датотеку",
-       "ooui-selectfile-placeholder": "Није изабрана ниједна датотека"
+       "ooui-selectfile-not-supported": "Избор датотеке није подржан",
+       "ooui-selectfile-placeholder": "Није изабрана ниједна датотека",
+       "ooui-selectfile-dragdrop-placeholder": "Додајте датотеку овде"
 }
index b93d59e..124278b 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.18.4-fix (d4045dee45)
+ * OOjs UI v0.19.0
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
  * Copyright 2011–2017 OOjs UI Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: 2017-01-19T20:22:26Z
+ * Date: 2017-02-01T23:04:40Z
  */
 ( function ( OO ) {
 
@@ -41,5 +41,3 @@ OO.ui.ApexTheme.prototype.getDialogTransitionDuration = function () {
 OO.ui.theme = new OO.ui.ApexTheme();
 
 }( OO ) );
-
-//# sourceMappingURL=oojs-ui-apex.js.map
\ No newline at end of file
index 2bee712..d2e9674 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.18.4-fix (d4045dee45)
+ * OOjs UI v0.19.0
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
  * Copyright 2011–2017 OOjs UI Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: 2017-01-19T20:22:32Z
+ * Date: 2017-02-01T23:04:44Z
  */
 .oo-ui-element-hidden {
   display: none !important;
@@ -19,6 +19,9 @@
 .oo-ui-buttonElement > .oo-ui-buttonElement-button {
   cursor: pointer;
   display: inline-block;
+  -webkit-box-sizing: border-box;
+     -moz-box-sizing: border-box;
+          box-sizing: border-box;
   vertical-align: middle;
   font-family: inherit;
   font-size: inherit;
 .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.5;
-}
 .oo-ui-fieldLayout.oo-ui-fieldLayout-align-top .oo-ui-fieldLayout-help,
 .oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline .oo-ui-fieldLayout-help {
   margin-top: -0.3em;
@@ -434,10 +433,6 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
 .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.4;
-}
 .oo-ui-fieldsetLayout .oo-ui-fieldsetLayout-header {
   max-width: 50em;
 }
@@ -489,7 +484,9 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
   padding: 1.25em;
 }
 .oo-ui-panelLayout-framed {
+  border: 1px solid #ccc;
   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;
@@ -686,7 +683,9 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
 }
 .oo-ui-popupWidget-body {
   clear: both;
-  overflow: hidden;
+}
+.oo-ui-popupWidget-body.oo-ui-clippableElement-clippable {
+  min-height: 1em;
 }
 .oo-ui-popupWidget-popup {
   background-color: #fff;
@@ -736,8 +735,11 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
 .oo-ui-popupWidget-head > .oo-ui-labelElement-label {
   margin: 0.75em 1em;
 }
+.oo-ui-popupWidget-body {
+  line-height: 1.4;
+}
 .oo-ui-popupWidget-body-padded {
-  padding: 0 1em;
+  margin: 0.75em 1em;
 }
 .oo-ui-popupButtonWidget {
   position: relative;
@@ -745,13 +747,13 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
 .oo-ui-popupButtonWidget .oo-ui-popupWidget {
   cursor: auto;
 }
-.oo-ui-popupButtonWidget.oo-ui-buttonElement-frameless > .oo-ui-popupWidget {
+.oo-ui-popupWidget.oo-ui-popupButtonWidget-frameless-popup {
   /* @noflip */
-  left: 0.9375em;
+  margin-left: 0.9375em;
 }
-.oo-ui-popupButtonWidget.oo-ui-buttonElement-framed > .oo-ui-popupWidget {
+.oo-ui-popupWidget.oo-ui-popupButtonWidget-framed-popup {
   /* @noflip */
-  left: 1.2375em;
+  margin-left: 1.2375em;
 }
 .oo-ui-inputWidget {
   margin-right: 0.5em;
@@ -861,14 +863,10 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
   display: none;
 }
 .oo-ui-textInputWidget.oo-ui-iconElement > .oo-ui-iconElement-icon,
-.oo-ui-textInputWidget.oo-ui-indicatorElement > .oo-ui-indicatorElement-indicator,
-.oo-ui-textInputWidget > .oo-ui-labelElement-label {
+.oo-ui-textInputWidget.oo-ui-indicatorElement > .oo-ui-indicatorElement-indicator {
   display: block;
   position: absolute;
   top: 0;
-}
-.oo-ui-textInputWidget.oo-ui-iconElement > .oo-ui-iconElement-icon,
-.oo-ui-textInputWidget.oo-ui-indicatorElement > .oo-ui-indicatorElement-indicator {
   height: 100%;
   -webkit-touch-callout: none;
   -webkit-user-select: none;
@@ -876,24 +874,21 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
       -ms-user-select: none;
           user-select: none;
 }
-.oo-ui-textInputWidget > .oo-ui-iconElement-icon,
-.oo-ui-textInputWidget-labelPosition-before > .oo-ui-labelElement-label {
-  left: 0;
-}
-.oo-ui-textInputWidget > .oo-ui-indicatorElement-indicator,
-.oo-ui-textInputWidget-labelPosition-after > .oo-ui-labelElement-label {
-  right: 0;
-}
 .oo-ui-textInputWidget.oo-ui-widget-enabled > .oo-ui-iconElement-icon,
-.oo-ui-textInputWidget.oo-ui-widget-enabled > .oo-ui-indicatorElement-indicator,
-.oo-ui-textInputWidget.oo-ui-widget-enabled > .oo-ui-labelElement-label {
+.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-widget-disabled input,
-.oo-ui-textInputWidget.oo-ui-widget-disabled textarea,
+.oo-ui-textInputWidget.oo-ui-widget-disabled textarea {
+  -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-disabled .oo-ui-labelElement-label {
   -webkit-touch-callout: none;
   -webkit-user-select: none;
@@ -901,6 +896,21 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
       -ms-user-select: none;
           user-select: none;
 }
+.oo-ui-textInputWidget.oo-ui-labelElement > .oo-ui-labelElement-label {
+  display: block;
+}
+.oo-ui-textInputWidget > .oo-ui-iconElement-icon,
+.oo-ui-textInputWidget-labelPosition-before > .oo-ui-labelElement-label {
+  left: 0;
+}
+.oo-ui-textInputWidget > .oo-ui-indicatorElement-indicator,
+.oo-ui-textInputWidget-labelPosition-after > .oo-ui-labelElement-label {
+  right: 0;
+}
+.oo-ui-textInputWidget > .oo-ui-labelElement-label {
+  position: absolute;
+  top: 0;
+}
 .oo-ui-textInputWidget-php > .oo-ui-iconElement-icon,
 .oo-ui-textInputWidget-php > .oo-ui-indicatorElement-indicator,
 .oo-ui-textInputWidget-php > .oo-ui-labelElement-label {
@@ -998,6 +1008,12 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
   overflow: hidden;
   opacity: 0;
 }
+.oo-ui-menuSelectWidget.oo-ui-clippableElement-clippable {
+  min-height: 2.6em;
+}
+.oo-ui-menuSelectWidget-invisible {
+  display: none;
+}
 .oo-ui-menuOptionWidget {
   position: relative;
 }
@@ -1116,9 +1132,6 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
 }
 .oo-ui-comboBoxInputWidget-dropdownButton > .oo-ui-buttonElement-button {
   display: block;
-  -webkit-box-sizing: border-box;
-     -moz-box-sizing: border-box;
-          box-sizing: border-box;
   overflow: hidden;
 }
 .oo-ui-comboBoxInputWidget.oo-ui-comboBoxInputWidget-empty .oo-ui-comboBoxInputWidget-dropdownButton {
@@ -1129,8 +1142,8 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
   position: absolute;
   right: 0;
   top: 0;
-  height: 2.5em;
   width: 2.5em;
+  height: 2.5em;
   padding: 0;
 }
 .oo-ui-comboBoxInputWidget-php > .oo-ui-indicatorElement-indicator {
index 632c085..004a244 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.18.4-fix (d4045dee45)
+ * OOjs UI v0.19.0
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
  * Copyright 2011–2017 OOjs UI Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: 2017-01-19T20:22:32Z
+ * Date: 2017-02-01T23:04:44Z
  */
 .oo-ui-element-hidden {
   display: none !important;
@@ -19,6 +19,9 @@
 .oo-ui-buttonElement > .oo-ui-buttonElement-button {
   cursor: pointer;
   display: inline-block;
+  -webkit-box-sizing: border-box;
+     -moz-box-sizing: border-box;
+          box-sizing: border-box;
   vertical-align: middle;
   font-family: inherit;
   font-size: inherit;
 }
 .oo-ui-buttonElement-framed > .oo-ui-buttonElement-button {
   padding: 0.546875em 1em;
-  min-height: 1.25em;
-  min-width: 1em;
+  min-height: 2.5em;
+  min-width: 3.125em;
   border-radius: 2px;
   position: relative;
 }
 .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.5;
-}
 .oo-ui-fieldLayout.oo-ui-fieldLayout-align-top .oo-ui-fieldLayout-help,
 .oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline .oo-ui-fieldLayout-help {
   margin-top: -0.3em;
@@ -575,10 +574,6 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
 .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.4;
-}
 .oo-ui-fieldsetLayout .oo-ui-fieldsetLayout-header {
   max-width: 50em;
 }
@@ -844,7 +839,9 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
 }
 .oo-ui-popupWidget-body {
   clear: both;
-  overflow: hidden;
+}
+.oo-ui-popupWidget-body.oo-ui-clippableElement-clippable {
+  min-height: 1em;
 }
 .oo-ui-popupWidget-popup {
   background-color: #fff;
@@ -894,8 +891,11 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
 .oo-ui-popupWidget-head > .oo-ui-labelElement-label {
   margin: 0.75em 1em;
 }
+.oo-ui-popupWidget-body {
+  line-height: 1.4;
+}
 .oo-ui-popupWidget-body-padded {
-  padding: 0 1em;
+  margin: 0.75em 1em;
 }
 .oo-ui-popupButtonWidget {
   position: relative;
@@ -903,13 +903,13 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
 .oo-ui-popupButtonWidget .oo-ui-popupWidget {
   cursor: auto;
 }
-.oo-ui-popupButtonWidget.oo-ui-buttonElement-frameless > .oo-ui-popupWidget {
+.oo-ui-popupWidget.oo-ui-popupButtonWidget-frameless-popup {
   /* @noflip */
-  left: 0.9375em;
+  margin-left: 0.9375em;
 }
-.oo-ui-popupButtonWidget.oo-ui-buttonElement-framed > .oo-ui-popupWidget {
+.oo-ui-popupWidget.oo-ui-popupButtonWidget-framed-popup {
   /* @noflip */
-  left: 1.5em;
+  margin-left: 1.5em;
 }
 .oo-ui-inputWidget {
   margin-right: 0.5em;
@@ -1226,14 +1226,10 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
   display: none;
 }
 .oo-ui-textInputWidget.oo-ui-iconElement > .oo-ui-iconElement-icon,
-.oo-ui-textInputWidget.oo-ui-indicatorElement > .oo-ui-indicatorElement-indicator,
-.oo-ui-textInputWidget > .oo-ui-labelElement-label {
+.oo-ui-textInputWidget.oo-ui-indicatorElement > .oo-ui-indicatorElement-indicator {
   display: block;
   position: absolute;
   top: 0;
-}
-.oo-ui-textInputWidget.oo-ui-iconElement > .oo-ui-iconElement-icon,
-.oo-ui-textInputWidget.oo-ui-indicatorElement > .oo-ui-indicatorElement-indicator {
   height: 100%;
   -webkit-touch-callout: none;
   -webkit-user-select: none;
@@ -1241,24 +1237,21 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
       -ms-user-select: none;
           user-select: none;
 }
-.oo-ui-textInputWidget > .oo-ui-iconElement-icon,
-.oo-ui-textInputWidget-labelPosition-before > .oo-ui-labelElement-label {
-  left: 0;
-}
-.oo-ui-textInputWidget > .oo-ui-indicatorElement-indicator,
-.oo-ui-textInputWidget-labelPosition-after > .oo-ui-labelElement-label {
-  right: 0;
-}
 .oo-ui-textInputWidget.oo-ui-widget-enabled > .oo-ui-iconElement-icon,
-.oo-ui-textInputWidget.oo-ui-widget-enabled > .oo-ui-indicatorElement-indicator,
-.oo-ui-textInputWidget.oo-ui-widget-enabled > .oo-ui-labelElement-label {
+.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-widget-disabled input,
-.oo-ui-textInputWidget.oo-ui-widget-disabled textarea,
+.oo-ui-textInputWidget.oo-ui-widget-disabled textarea {
+  -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-disabled .oo-ui-labelElement-label {
   -webkit-touch-callout: none;
   -webkit-user-select: none;
@@ -1266,6 +1259,21 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
       -ms-user-select: none;
           user-select: none;
 }
+.oo-ui-textInputWidget.oo-ui-labelElement > .oo-ui-labelElement-label {
+  display: block;
+}
+.oo-ui-textInputWidget > .oo-ui-iconElement-icon,
+.oo-ui-textInputWidget-labelPosition-before > .oo-ui-labelElement-label {
+  left: 0;
+}
+.oo-ui-textInputWidget > .oo-ui-indicatorElement-indicator,
+.oo-ui-textInputWidget-labelPosition-after > .oo-ui-labelElement-label {
+  right: 0;
+}
+.oo-ui-textInputWidget > .oo-ui-labelElement-label {
+  position: absolute;
+  top: 0;
+}
 .oo-ui-textInputWidget-php > .oo-ui-iconElement-icon,
 .oo-ui-textInputWidget-php > .oo-ui-indicatorElement-indicator,
 .oo-ui-textInputWidget-php > .oo-ui-labelElement-label {
@@ -1273,20 +1281,20 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
 }
 .oo-ui-textInputWidget input,
 .oo-ui-textInputWidget textarea {
+  margin: 0;
   font-size: inherit;
   font-family: inherit;
   background-color: #fff;
   color: #000;
   border: 1px solid #a2a9b1;
   border-radius: 2px;
+  padding: 0.625em 0.546875em 0.546875em;
 }
 .oo-ui-textInputWidget input {
-  padding: 0.625em 0.546875em 0.546875em;
   line-height: 1.172em;
 }
 .oo-ui-textInputWidget textarea {
-  padding: 0.46875em 0.546875em 0.546875em;
-  line-height: 1.4;
+  line-height: 1.275;
 }
 .oo-ui-textInputWidget .oo-ui-pendingElement-pending {
   background-color: transparent;
@@ -1351,50 +1359,51 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
   border-color: #d33;
   box-shadow: inset 0 0 0 1px #d33;
 }
+.oo-ui-textInputWidget.oo-ui-widget-disabled input,
+.oo-ui-textInputWidget.oo-ui-widget-disabled textarea {
+  background-color: #eaecf0;
+  color: #72777d;
+  text-shadow: 0 1px 1px #fff;
+  border-color: #c8ccd1;
+}
+.oo-ui-textInputWidget.oo-ui-widget-disabled .oo-ui-iconElement-icon,
+.oo-ui-textInputWidget.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator {
+  opacity: 0.51;
+}
+.oo-ui-textInputWidget.oo-ui-widget-disabled .oo-ui-labelElement-label {
+  color: #72777d;
+  text-shadow: 0 1px 1px #fff;
+}
 .oo-ui-textInputWidget.oo-ui-iconElement input,
 .oo-ui-textInputWidget.oo-ui-iconElement textarea {
-  padding-left: 2.65625em;
+  padding-left: 2.875em;
 }
 .oo-ui-textInputWidget.oo-ui-iconElement .oo-ui-iconElement-icon {
-  max-height: 2.5em;
-  left: 0.46875em;
+  left: 0;
+  height: 100%;
+  max-height: 2.375em;
+  margin-left: 0.5em;
+  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 {
-  max-height: 2.5em;
-  right: 0.625em;
+  height: 100%;
+  max-height: 2.375em;
+  margin: 0 0.775em;
 }
 .oo-ui-textInputWidget > .oo-ui-labelElement-label {
   color: #72777d;
-  right: 0.625em;
-  border: 1px solid transparent;
-  border-width: 1px 0;
-  padding: 0.625em 0 0.546875em;
-  line-height: 1.172em;
+  padding: 0.4em;
+  line-height: 1.5;
 }
 .oo-ui-textInputWidget-labelPosition-after.oo-ui-indicatorElement > .oo-ui-labelElement-label {
-  right: 2.1875em;
+  margin-right: 2.0875em;
 }
 .oo-ui-textInputWidget-labelPosition-before.oo-ui-iconElement > .oo-ui-labelElement-label {
-  left: 2.65625em;
-}
-.oo-ui-textInputWidget.oo-ui-widget-disabled input,
-.oo-ui-textInputWidget.oo-ui-widget-disabled textarea {
-  background-color: #eaecf0;
-  color: #72777d;
-  text-shadow: 0 1px 1px #fff;
-  border-color: #c8ccd1;
-}
-.oo-ui-textInputWidget.oo-ui-widget-disabled .oo-ui-iconElement-icon,
-.oo-ui-textInputWidget.oo-ui-widget-disabled .oo-ui-indicatorElement-indicator {
-  opacity: 0.51;
-}
-.oo-ui-textInputWidget.oo-ui-widget-disabled .oo-ui-labelElement-label {
-  color: #72777d;
-  text-shadow: 0 1px 1px #fff;
+  margin-left: 2.475em;
 }
 .oo-ui-menuSelectWidget {
   position: absolute;
@@ -1413,6 +1422,12 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
   overflow: hidden;
   opacity: 0;
 }
+.oo-ui-menuSelectWidget.oo-ui-clippableElement-clippable {
+  min-height: 2.6em;
+}
+.oo-ui-menuSelectWidget-invisible {
+  display: none;
+}
 .oo-ui-menuOptionWidget {
   position: relative;
   padding: 0.5em 1em;
@@ -1577,9 +1592,6 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
 }
 .oo-ui-comboBoxInputWidget-dropdownButton > .oo-ui-buttonElement-button {
   display: block;
-  -webkit-box-sizing: border-box;
-     -moz-box-sizing: border-box;
-          box-sizing: border-box;
   overflow: hidden;
 }
 .oo-ui-comboBoxInputWidget.oo-ui-comboBoxInputWidget-empty .oo-ui-comboBoxInputWidget-dropdownButton {
@@ -1590,8 +1602,8 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
   position: absolute;
   right: 0;
   top: 0;
-  height: 2.5em;
   width: 2.5em;
+  height: 2.5em;
   padding: 0;
 }
 .oo-ui-comboBoxInputWidget-php > .oo-ui-indicatorElement-indicator {
@@ -1608,8 +1620,10 @@ body:not( :-moz-handler-blocked ) .oo-ui-fieldsetLayout {
   width: 2.5em;
 }
 .oo-ui-comboBoxInputWidget-dropdownButton .oo-ui-buttonElement-button {
+  min-width: 2.5em;
   min-height: 2.5em;
-  padding: 0.546875em;
+  padding-left: 0;
+  padding-right: 0;
 }
 .oo-ui-comboBoxInputWidget-dropdownButton .oo-ui-buttonElement-button,
 .oo-ui-comboBoxInputWidget-dropdownButton .oo-ui-buttonElement-button:focus {
index 5dc78ab..7cfa9c1 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.18.4-fix (d4045dee45)
+ * OOjs UI v0.19.0
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
  * Copyright 2011–2017 OOjs UI Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: 2017-01-19T20:22:26Z
+ * Date: 2017-02-01T23:04:40Z
  */
 ( function ( OO ) {
 
@@ -2922,17 +2922,6 @@ OO.ui.mixin.LabelElement.prototype.getLabel = function () {
        return this.label;
 };
 
-/**
- * Fit the label.
- *
- * @chainable
- * @deprecated since 0.16.0
- */
-OO.ui.mixin.LabelElement.prototype.fitLabel = function () {
-       OO.ui.warnDeprecation( 'LabelElement#fitLabel: This is a deprecated no-op.' );
-       return this;
-};
-
 /**
  * Set the content of the label.
  *
@@ -3980,6 +3969,216 @@ OO.ui.mixin.PendingElement.prototype.popPending = function () {
        return this;
 };
 
+/**
+ * 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;
+
+       if ( !this.$floatable || !this.$floatableContainer ) {
+               return this;
+       }
+
+       positioning = positioning === undefined ? !this.positioning : !!positioning;
+
+       if ( this.positioning !== positioning ) {
+               this.positioning = positioning;
+
+               closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer( this.$floatableContainer[ 0 ] );
+               this.needsCustomPosition = !OO.ui.contains( this.$floatableContainer[ 0 ], this.$floatable[ 0 ] );
+               // If the scrollable is the root, we have to listen to scroll events
+               // on the window because of browser inconsistencies.
+               if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
+                       closestScrollableOfContainer = OO.ui.Element.static.getWindow( closestScrollableOfContainer );
+               }
+
+               if ( positioning ) {
+                       this.$floatableWindow = $( this.getElementWindow() );
+                       this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
+
+                       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;
+};
+
+/**
+ * Check whether the bottom edge of the given element is within the viewport of the given container.
+ *
+ * @private
+ * @param {jQuery} $element
+ * @param {jQuery} $container
+ * @return {boolean}
+ */
+OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
+       var elemRect, contRect,
+               leftEdgeInBounds = false,
+               bottomEdgeInBounds = false,
+               rightEdgeInBounds = false;
+
+       elemRect = $element[ 0 ].getBoundingClientRect();
+       if ( $container[ 0 ] === window ) {
+               contRect = {
+                       top: 0,
+                       left: 0,
+                       right: document.documentElement.clientWidth,
+                       bottom: document.documentElement.clientHeight
+               };
+       } else {
+               contRect = $container[ 0 ].getBoundingClientRect();
+       }
+
+       // For completeness, if we still cared about topEdgeInBounds, that'd be:
+       // elemRect.top >= contRect.top && elemRect.top <= contRect.bottom
+       if ( elemRect.left >= contRect.left && elemRect.left <= contRect.right ) {
+               leftEdgeInBounds = true;
+       }
+       if ( elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom ) {
+               bottomEdgeInBounds = true;
+       }
+       if ( elemRect.right >= contRect.left && elemRect.right <= contRect.right ) {
+               rightEdgeInBounds = true;
+       }
+
+       // We only care that any part of the bottom edge is visible
+       return bottomEdgeInBounds && ( leftEdgeInBounds || rightEdgeInBounds );
+};
+
+/**
+ * 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;
+       }
+
+       if ( !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable ) ) {
+               this.$floatable.addClass( 'oo-ui-element-hidden' );
+               return;
+       } else {
+               this.$floatable.removeClass( 'oo-ui-element-hidden' );
+       }
+
+       if ( !this.needsCustomPosition ) {
+               return;
+       }
+
+       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;
+};
+
 /**
  * Element that can be automatically clipped to visible boundaries.
  *
@@ -4283,6 +4482,7 @@ OO.ui.mixin.ClippableElement.prototype.clip = function () {
  * @extends OO.ui.Widget
  * @mixins OO.ui.mixin.LabelElement
  * @mixins OO.ui.mixin.ClippableElement
+ * @mixins OO.ui.mixin.FloatableElement
  *
  * @constructor
  * @param {Object} [config] Configuration options
@@ -4328,6 +4528,7 @@ OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
                $clippable: this.$body,
                $clippableContainer: this.$popup
        } ) );
+       OO.ui.mixin.FloatableElement.call( this, config );
 
        // Properties
        this.$anchor = $( '<div>' );
@@ -4392,6 +4593,7 @@ OO.ui.PopupWidget = function OoUiPopupWidget( config ) {
 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 );
+OO.mixinClass( OO.ui.PopupWidget, OO.ui.mixin.FloatableElement );
 
 /* Methods */
 
@@ -4515,6 +4717,8 @@ OO.ui.PopupWidget.prototype.toggle = function ( show ) {
        OO.ui.PopupWidget.parent.prototype.toggle.call( this, show );
 
        if ( change ) {
+               this.togglePositioning( show && !!this.$floatableContainer );
+
                if ( show ) {
                        if ( this.autoClose ) {
                                this.bindMouseDownListener();
@@ -4737,13 +4941,23 @@ OO.ui.mixin.PopupElement.prototype.getPopup = function () {
  *
  * @constructor
  * @param {Object} [config] Configuration options
+ * @cfg {jQuery} [$overlay] Render the popup into a separate layer. This configuration is useful in cases where
+ *  the expanded popup 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 popup uses relative positioning.
  */
 OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
        // Parent constructor
        OO.ui.PopupButtonWidget.parent.call( this, config );
 
        // Mixin constructors
-       OO.ui.mixin.PopupElement.call( this, config );
+       OO.ui.mixin.PopupElement.call( this, $.extend( true, {}, config, {
+               popup: {
+                       $floatableContainer: this.$element
+               }
+       } ) );
+
+       // Properties
+       this.$overlay = config.$overlay || this.$element;
 
        // Events
        this.connect( this, { click: 'onAction' } );
@@ -4751,8 +4965,12 @@ OO.ui.PopupButtonWidget = function OoUiPopupButtonWidget( config ) {
        // Initialization
        this.$element
                .addClass( 'oo-ui-popupButtonWidget' )
-               .attr( 'aria-haspopup', 'true' )
-               .append( this.popup.$element );
+               .attr( 'aria-haspopup', 'true' );
+       this.popup.$element
+               .addClass( 'oo-ui-popupButtonWidget-popup' )
+               .toggleClass( 'oo-ui-popupButtonWidget-framed-popup', this.isFramed() )
+               .toggleClass( 'oo-ui-popupButtonWidget-frameless-popup', !this.isFramed() );
+       this.$overlay.append( this.popup.$element );
 };
 
 /* Setup */
@@ -5054,6 +5272,19 @@ OO.ui.OptionWidget.prototype.setPressed = function ( state ) {
        return this;
 };
 
+/**
+ * Get text to match search strings against.
+ *
+ * The default implementation returns the label text, but subclasses
+ * can override this to provide more complex behavior.
+ *
+ * @return {string|boolean} String to match search string against
+ */
+OO.ui.OptionWidget.prototype.getMatchText = function () {
+       var label = this.getLabel();
+       return typeof label === 'string' ? label : this.$label.text();
+};
+
 /**
  * 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},
@@ -5498,7 +5729,7 @@ OO.ui.SelectWidget.prototype.onKeyPress = function ( e ) {
  * @protected
  * @param {string} s String to match against items
  * @param {boolean} [exact=false] Only accept exact matches
- * @return {Function} function ( OO.ui.OptionItem ) => boolean
+ * @return {Function} function ( OO.ui.OptionWidget ) => boolean
  */
 OO.ui.SelectWidget.prototype.getItemMatcher = function ( s, exact ) {
        var re;
@@ -5513,14 +5744,11 @@ OO.ui.SelectWidget.prototype.getItemMatcher = function ( s, exact ) {
        }
        re = new RegExp( re, 'i' );
        return function ( item ) {
-               var l = item.getLabel();
-               if ( typeof l !== 'string' ) {
-                       l = item.$label.text();
+               var matchText = item.getMatchText();
+               if ( matchText.normalize ) {
+                       matchText = matchText.normalize();
                }
-               if ( l.normalize ) {
-                       l = l.normalize();
-               }
-               return re.test( l );
+               return re.test( matchText );
        };
 };
 
@@ -6192,7 +6420,8 @@ OO.ui.MenuSelectWidget.prototype.onKeyDown = function ( e ) {
  * @protected
  */
 OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
-       var i, item,
+       var i, item, visible,
+               anyVisible = false,
                len = this.items.length,
                showAll = !this.isVisible(),
                filter = showAll ? null : this.getItemMatcher( this.$input.val() );
@@ -6200,10 +6429,14 @@ OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
        for ( i = 0; i < len; i++ ) {
                item = this.items[ i ];
                if ( item instanceof OO.ui.OptionWidget ) {
-                       item.toggle( showAll || filter( item ) );
+                       visible = showAll || filter( item );
+                       anyVisible = anyVisible || visible;
+                       item.toggle( visible );
                }
        }
 
+       this.$element.toggleClass( 'oo-ui-menuSelectWidget-invisible', !anyVisible );
+
        // Reevaluate clipping
        this.clip();
 };
@@ -7120,213 +7353,6 @@ OO.ui.CheckboxMultiselectWidget.prototype.onClick = function ( e ) {
        }
 };
 
-/**
- * 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 ] );
-               this.needsCustomPosition = closestScrollableOfContainer !== closestScrollableOfFloatable;
-               // If the scrollable is the root, we have to listen to scroll events
-               // on the window because of browser inconsistencies.
-               if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
-                       closestScrollableOfContainer = OO.ui.Element.static.getWindow( closestScrollableOfContainer );
-               }
-
-               if ( positioning ) {
-                       this.$floatableWindow = $( this.getElementWindow() );
-                       this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
-
-                       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;
-};
-
-/**
- * Check whether the bottom edge of the given element is within the viewport of the given container.
- *
- * @private
- * @param {jQuery} $element
- * @param {jQuery} $container
- * @return {boolean}
- */
-OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
-       var elemRect, contRect,
-               leftEdgeInBounds = false,
-               bottomEdgeInBounds = false,
-               rightEdgeInBounds = false;
-
-       elemRect = $element[ 0 ].getBoundingClientRect();
-       if ( $container[ 0 ] === window ) {
-               contRect = {
-                       top: 0,
-                       left: 0,
-                       right: document.documentElement.clientWidth,
-                       bottom: document.documentElement.clientHeight
-               };
-       } else {
-               contRect = $container[ 0 ].getBoundingClientRect();
-       }
-
-       // For completeness, if we still cared about topEdgeInBounds, that'd be:
-       // elemRect.top >= contRect.top && elemRect.top <= contRect.bottom
-       if ( elemRect.left >= contRect.left && elemRect.left <= contRect.right ) {
-               leftEdgeInBounds = true;
-       }
-       if ( elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom ) {
-               bottomEdgeInBounds = true;
-       }
-       if ( elemRect.right >= contRect.left && elemRect.right <= contRect.right ) {
-               rightEdgeInBounds = true;
-       }
-
-       // We only care that any part of the bottom edge is visible
-       return bottomEdgeInBounds && ( leftEdgeInBounds || rightEdgeInBounds );
-};
-
-/**
- * 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;
-       }
-
-       if ( !this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable ) ) {
-               this.$floatable.addClass( 'oo-ui-element-hidden' );
-               return;
-       } else {
-               this.$floatable.removeClass( 'oo-ui-element-hidden' );
-       }
-
-       if ( !this.needsCustomPosition ) {
-               return;
-       }
-
-       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,
@@ -9930,7 +9956,7 @@ OO.ui.ComboBoxInputWidget.prototype.setOptions = function ( options ) {
  * @throws {Error} An error is thrown if no widget is specified
  */
 OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
-       var hasInputWidget, $div;
+       var hasInputWidget;
 
        // Allow passing positional parameters inside the config object
        if ( OO.isPlainObject( fieldWidget ) && config === undefined ) {
@@ -9966,20 +9992,18 @@ OO.ui.FieldLayout = function OoUiFieldLayout( fieldWidget, config ) {
        this.align = null;
        if ( config.help ) {
                this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
+                       popup: {
+                               padded: true
+                       },
                        classes: [ 'oo-ui-fieldLayout-help' ],
                        framed: false,
                        icon: 'info'
                } );
-
-               $div = $( '<div>' );
                if ( config.help instanceof OO.ui.HtmlSnippet ) {
-                       $div.html( config.help.toString() );
+                       this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
                } else {
-                       $div.text( config.help );
+                       this.popupButtonWidget.getPopup().$body.text( config.help );
                }
-               this.popupButtonWidget.getPopup().$body.append(
-                       $div.addClass( 'oo-ui-fieldLayout-help-content' )
-               );
                this.$help = this.popupButtonWidget.$element;
        } else {
                this.$help = $( [] );
@@ -10283,8 +10307,6 @@ OO.inheritClass( OO.ui.ActionFieldLayout, OO.ui.FieldLayout );
  *  For important messages, you are advised to use `notices`, as they are always shown.
  */
 OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
-       var $div;
-
        // Configuration initialization
        config = config || {};
 
@@ -10300,20 +10322,18 @@ OO.ui.FieldsetLayout = function OoUiFieldsetLayout( config ) {
        this.$header = $( '<div>' );
        if ( config.help ) {
                this.popupButtonWidget = new OO.ui.PopupButtonWidget( {
+                       popup: {
+                               padded: true
+                       },
                        classes: [ 'oo-ui-fieldsetLayout-help' ],
                        framed: false,
                        icon: 'info'
                } );
-
-               $div = $( '<div>' );
                if ( config.help instanceof OO.ui.HtmlSnippet ) {
-                       $div.html( config.help.toString() );
+                       this.popupButtonWidget.getPopup().$body.html( config.help.toString() );
                } else {
-                       $div.text( config.help );
+                       this.popupButtonWidget.getPopup().$body.text( config.help );
                }
-               this.popupButtonWidget.getPopup().$body.append(
-                       $div.addClass( 'oo-ui-fieldsetLayout-help-content' )
-               );
                this.$help = this.popupButtonWidget.$element;
        } else {
                this.$help = $( [] );
@@ -10586,5 +10606,3 @@ OO.inheritClass( OO.ui.HorizontalLayout, OO.ui.Layout );
 OO.mixinClass( OO.ui.HorizontalLayout, OO.ui.mixin.GroupElement );
 
 }( OO ) );
-
-//# sourceMappingURL=oojs-ui-core.js.map
\ No newline at end of file
index 32daabd..05b578a 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.18.4-fix (d4045dee45)
+ * OOjs UI v0.19.0
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
  * Copyright 2011–2017 OOjs UI Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: 2017-01-19T20:22:26Z
+ * Date: 2017-02-01T23:04:40Z
  */
 ( function ( OO ) {
 
@@ -82,5 +82,3 @@ OO.ui.MediaWikiTheme.prototype.getDialogTransitionDuration = function () {
 OO.ui.theme = new OO.ui.MediaWikiTheme();
 
 }( OO ) );
-
-//# sourceMappingURL=oojs-ui-mediawiki.js.map
\ No newline at end of file
index d781e68..327c4d5 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.18.4-fix (d4045dee45)
+ * OOjs UI v0.19.0
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
  * Copyright 2011–2017 OOjs UI Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: 2017-01-19T20:22:32Z
+ * Date: 2017-02-01T23:04:44Z
  */
 .oo-ui-popupTool .oo-ui-popupWidget-popup,
 .oo-ui-popupTool .oo-ui-popupWidget-anchor {
   -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 #ccc;
   background-color: #fff;
   box-shadow: 0 0.3125em 1.25em rgba(0, 0, 0, 0.25);
 }
+.oo-ui-toolbar-position-top .oo-ui-popupToolGroup .oo-ui-toolGroup-tools {
+  top: 2.5em;
+}
+.oo-ui-toolbar-position-bottom .oo-ui-popupToolGroup .oo-ui-toolGroup-tools {
+  bottom: 2.5em;
+}
 .oo-ui-popupToolGroup .oo-ui-tool-link {
   padding: 0.3125em 0 0.3125em 0.3125em;
 }
   pointer-events: none;
 }
 .oo-ui-toolbar-bar {
-  border-bottom: 1px solid #ccc;
   background-color: #f8fbfd;
   background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0, #fff), color-stop(100%, #F1F7FB));
   background-image: -webkit-linear-gradient(top, #fff 0, #F1F7FB 100%);
   background-image:         linear-gradient(to bottom, #fff 0, #F1F7FB 100%);
   -ms-filter: "progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffffffff', endColorstr='#fff1f7fb' )";
 }
+.oo-ui-toolbar-position-top .oo-ui-toolbar-bar {
+  border-bottom: 1px solid #ccc;
+}
+.oo-ui-toolbar-position-bottom .oo-ui-toolbar-bar {
+  border-top: 1px solid #ccc;
+}
 .oo-ui-toolbar-bar .oo-ui-toolbar-bar {
   border: 0;
   background: none;
index 9860240..86af796 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.18.4-fix (d4045dee45)
+ * OOjs UI v0.19.0
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
  * Copyright 2011–2017 OOjs UI Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: 2017-01-19T20:22:32Z
+ * Date: 2017-02-01T23:04:44Z
  */
 .oo-ui-tool.oo-ui-widget-enabled {
   -webkit-transition: background-color 100ms;
   left: 0;
 }
 .oo-ui-popupToolGroup .oo-ui-toolGroup-tools {
-  top: 3.125em;
   margin: 0 -1px;
   border: 1px solid #c8ccd1;
   background-color: #fff;
   box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.25);
   min-width: 16em;
 }
+.oo-ui-toolbar-position-top .oo-ui-popupToolGroup .oo-ui-toolGroup-tools {
+  top: 3.125em;
+}
+.oo-ui-toolbar-position-bottom .oo-ui-popupToolGroup .oo-ui-toolGroup-tools {
+  bottom: 3.125em;
+}
 .oo-ui-popupToolGroup .oo-ui-tool-link {
   padding: 0.4em 0.625em;
   -webkit-box-sizing: border-box;
 .oo-ui-toolbar-bar {
   background-color: #fff;
   color: #222;
-  border-bottom: 1px solid #c8ccd1;
   box-shadow: 0 1px 1px 0 rgba(0, 0, 0, 0.1);
   font-weight: 500;
 }
+.oo-ui-toolbar-position-top .oo-ui-toolbar-bar {
+  border-bottom: 1px solid #c8ccd1;
+}
+.oo-ui-toolbar-position-bottom .oo-ui-toolbar-bar {
+  border-top: 1px solid #c8ccd1;
+}
 .oo-ui-toolbar-bar .oo-ui-toolbar-bar {
-  border-bottom: 0;
+  border: 0;
   background-color: transparent;
   box-shadow: none;
 }
index 0b8a7f6..1ab99e2 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.18.4-fix (d4045dee45)
+ * OOjs UI v0.19.0
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
  * Copyright 2011–2017 OOjs UI Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: 2017-01-19T20:22:26Z
+ * Date: 2017-02-01T23:04:40Z
  */
 ( function ( OO ) {
 
  *  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.
+ * @cfg {string} [position='top'] Whether the toolbar is positioned above ('top') or below ('bottom') content.
  */
 OO.ui.Toolbar = function OoUiToolbar( toolFactory, toolGroupFactory, config ) {
        // Allow passing positional parameters inside the config object
@@ -317,6 +318,7 @@ OO.ui.Toolbar = function OoUiToolbar( toolFactory, toolGroupFactory, config ) {
        this.toolGroupFactory = toolGroupFactory;
        this.groups = [];
        this.tools = {};
+       this.position = config.position || 'top';
        this.$bar = $( '<div>' );
        this.$actions = $( '<div>' );
        this.initialized = false;
@@ -339,7 +341,7 @@ OO.ui.Toolbar = function OoUiToolbar( toolFactory, toolGroupFactory, config ) {
        if ( config.shadow ) {
                this.$bar.append( '<div class="oo-ui-toolbar-shadow"></div>' );
        }
-       this.$element.addClass( 'oo-ui-toolbar' ).append( this.$bar );
+       this.$element.addClass( 'oo-ui-toolbar oo-ui-toolbar-position-' + this.position ).append( this.$bar );
 };
 
 /* Setup */
@@ -1775,7 +1777,9 @@ OO.ui.PopupToolGroup = function OoUiPopupToolGroup( toolbar, config ) {
        }
 
        // Configuration initialization
-       config = config || {};
+       config = $.extend( {
+               indicator: toolbar.position === 'bottom' ? 'up' : 'down'
+       }, config );
 
        // Parent constructor
        OO.ui.PopupToolGroup.parent.call( this, toolbar, config );
@@ -2010,7 +2014,6 @@ OO.ui.PopupToolGroup.prototype.setActive = function ( value ) {
  *             // 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',
@@ -2240,7 +2243,6 @@ OO.ui.ListToolGroup.prototype.updateCollapsibleState = function () {
  *             type: 'menu',
  *             header: 'This is the (optional) header',
  *             title: 'This is the (optional) title',
- *             indicator: 'down',
  *             include: [ 'settings', 'stuff' ]
  *         }
  *     ] );
@@ -2329,5 +2331,3 @@ OO.ui.MenuToolGroup.prototype.onUpdateState = function () {
 };
 
 }( OO ) );
-
-//# sourceMappingURL=oojs-ui-toolbars.js.map
\ No newline at end of file
index ec581c1..610df2c 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.18.4-fix (d4045dee45)
+ * OOjs UI v0.19.0
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
  * Copyright 2011–2017 OOjs UI Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: 2017-01-19T20:22:32Z
+ * Date: 2017-02-01T23:04:44Z
  */
 .oo-ui-draggableElement-handle,
 .oo-ui-draggableElement-handle.oo-ui-widget {
 .oo-ui-capsuleMultiselectWidget-group {
   display: inline;
 }
+.oo-ui-capsuleMultiselectWidget-popup > .oo-ui-popupWidget-popup > .oo-ui-popupWidget-body > * {
+  display: block;
+}
+.oo-ui-capsuleMultiselectWidget-focusTrap {
+  display: inline-block;
+  height: 1px;
+  width: 1px;
+}
 .oo-ui-capsuleMultiselectWidget-handle {
   background-color: #fff;
   cursor: text;
 .oo-ui-capsuleMultiselectWidget.oo-ui-widget-disabled .oo-ui-capsuleMultiselectWidget-handle > .oo-ui-indicatorElement-indicator {
   opacity: 0.2;
 }
+.oo-ui-capsuleMultiselectWidget-popup > .oo-ui-popupWidget-popup {
+  border: 0;
+}
 .oo-ui-capsuleItemWidget {
   position: relative;
   display: inline-block;
 .oo-ui-numberInputWidget-buttoned .oo-ui-textInputWidget {
   display: table-cell;
 }
-.oo-ui-numberInputWidget-buttoned .oo-ui-buttonElement-button {
-  -webkit-box-sizing: border-box;
-     -moz-box-sizing: border-box;
-          box-sizing: border-box;
-}
 .oo-ui-numberInputWidget-field {
   display: table;
   table-layout: fixed;
index aced25b..68a5acc 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.18.4-fix (d4045dee45)
+ * OOjs UI v0.19.0
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
  * Copyright 2011–2017 OOjs UI Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: 2017-01-19T20:22:32Z
+ * Date: 2017-02-01T23:04:44Z
  */
 .oo-ui-draggableElement-handle,
 .oo-ui-draggableElement-handle.oo-ui-widget {
 .oo-ui-capsuleMultiselectWidget-group {
   display: inline;
 }
+.oo-ui-capsuleMultiselectWidget-popup > .oo-ui-popupWidget-popup > .oo-ui-popupWidget-body > * {
+  display: block;
+}
+.oo-ui-capsuleMultiselectWidget-focusTrap {
+  display: inline-block;
+  height: 1px;
+  width: 1px;
+}
 .oo-ui-capsuleMultiselectWidget-handle {
   min-height: 2.4em;
   margin-right: 0.5em;
   top: 0;
   margin: 0.3em;
 }
-.oo-ui-capsuleMultiselectWidget .oo-ui-popupWidget {
-  width: 100%;
+.oo-ui-capsuleMultiselectWidget-popup {
   margin-top: -1px;
 }
-.oo-ui-capsuleMultiselectWidget .oo-ui-popupWidget-popup {
-  min-width: 100%;
-  -webkit-box-sizing: border-box;
-     -moz-box-sizing: border-box;
-          box-sizing: border-box;
-  border-width: 0 1px;
-  border-radius: 0 0 2px 2px;
+.oo-ui-capsuleMultiselectWidget-popup > .oo-ui-popupWidget-popup {
+  border: 0;
 }
 .oo-ui-capsuleMultiselectWidget.oo-ui-widget-enabled .oo-ui-capsuleMultiselectWidget-handle {
   background-color: #fff;
 .oo-ui-numberInputWidget-buttoned .oo-ui-textInputWidget {
   display: table-cell;
 }
-.oo-ui-numberInputWidget-buttoned .oo-ui-buttonElement-button {
-  -webkit-box-sizing: border-box;
-     -moz-box-sizing: border-box;
-          box-sizing: border-box;
-}
 .oo-ui-numberInputWidget-field {
   display: table;
   table-layout: fixed;
 }
 .oo-ui-numberInputWidget-buttoned .oo-ui-buttonElement-button {
   display: block;
+  min-width: 2.5em;
   min-height: 2.5em;
   padding-left: 0;
   padding-right: 0;
index 4bf461f..55f1c9c 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.18.4-fix (d4045dee45)
+ * OOjs UI v0.19.0
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
  * Copyright 2011–2017 OOjs UI Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: 2017-01-19T20:22:26Z
+ * Date: 2017-02-01T23:04:40Z
  */
 ( function ( OO ) {
 
@@ -1779,11 +1779,9 @@ OO.ui.BookletLayout.prototype.onStackLayoutVisibleItemChange = function ( page )
 OO.ui.BookletLayout.prototype.onStackLayoutSet = function ( page ) {
        var layout = this;
        if ( !this.scrolling && page ) {
-               page.scrollElementIntoView( {
-                       complete: function () {
-                               if ( layout.autoFocus && !OO.ui.isMobile() ) {
-                                       layout.focus();
-                               }
+               page.scrollElementIntoView().done( function () {
+                       if ( layout.autoFocus && !OO.ui.isMobile() ) {
+                               layout.focus();
                        }
                } );
        }
@@ -2290,11 +2288,9 @@ OO.ui.IndexLayout.prototype.onStackLayoutFocus = function ( e ) {
 OO.ui.IndexLayout.prototype.onStackLayoutSet = function ( card ) {
        var layout = this;
        if ( card ) {
-               card.scrollElementIntoView( {
-                       complete: function () {
-                               if ( layout.autoFocus && !OO.ui.isMobile() ) {
-                                       layout.focus();
-                               }
+               card.scrollElementIntoView().done( function () {
+                       if ( layout.autoFocus && !OO.ui.isMobile() ) {
+                               layout.focus();
                        }
                } );
        }
@@ -3615,7 +3611,11 @@ OO.ui.CapsuleMultiselectWidget = function OoUiCapsuleMultiselectWidget( config )
                        align: 'forwards',
                        anchor: false
                } );
-               OO.ui.mixin.PopupElement.call( this, config );
+               OO.ui.mixin.PopupElement.call( this, $.extend( true, {}, config, {
+                       popup: {
+                               $floatableContainer: this.$element
+                       }
+               } ) );
                $tabFocus = $( '<span>' );
                OO.ui.mixin.TabIndexedElement.call( this, $.extend( {}, config, { $tabIndexed: $tabFocus } ) );
        } else {
@@ -3695,12 +3695,16 @@ OO.ui.CapsuleMultiselectWidget = function OoUiCapsuleMultiselectWidget( config )
        this.$element.addClass( 'oo-ui-capsuleMultiselectWidget' )
                .append( this.$handle );
        if ( this.popup ) {
+               this.popup.$element.addClass( 'oo-ui-capsuleMultiselectWidget-popup' );
                this.$content.append( $tabFocus );
                this.$overlay.append( this.popup.$element );
        } else {
                this.$content.append( this.$input );
                this.$overlay.append( this.menu.$element );
        }
+       if ( $tabFocus ) {
+               $tabFocus.addClass( 'oo-ui-capsuleMultiselectWidget-focusTrap' );
+       }
 
        // Input size needs to be calculated after everything else is rendered
        setTimeout( function () {
@@ -4305,7 +4309,7 @@ OO.ui.CapsuleMultiselectWidget.prototype.setDisabled = function ( disabled ) {
 OO.ui.CapsuleMultiselectWidget.prototype.focus = function () {
        if ( !this.isDisabled() ) {
                if ( this.popup ) {
-                       this.popup.setSize( this.$handle.width() );
+                       this.popup.setSize( this.$handle.outerWidth() );
                        this.popup.toggle( true );
                        OO.ui.findFocusable( this.popup.$element ).focus();
                } else {
@@ -5345,5 +5349,3 @@ OO.ui.NumberInputWidget.prototype.setDisabled = function ( disabled ) {
 };
 
 }( OO ) );
-
-//# sourceMappingURL=oojs-ui-widgets.js.map
\ No newline at end of file
index 2fc4cc8..61691b8 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.18.4-fix (d4045dee45)
+ * OOjs UI v0.19.0
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
  * Copyright 2011–2017 OOjs UI Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: 2017-01-19T20:22:32Z
+ * Date: 2017-02-01T23:04:44Z
  */
 .oo-ui-actionWidget.oo-ui-pendingElement-pending {
   background-image: /* @embed */ url(themes/apex/images/textures/pending.gif);
index c643c28..6f3298a 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.18.4-fix (d4045dee45)
+ * OOjs UI v0.19.0
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
  * Copyright 2011–2017 OOjs UI Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: 2017-01-19T20:22:32Z
+ * Date: 2017-02-01T23:04:44Z
  */
 .oo-ui-window {
   background: transparent;
index ab83a55..11e63cb 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.18.4-fix (d4045dee45)
+ * OOjs UI v0.19.0
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
  * Copyright 2011–2017 OOjs UI Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: 2017-01-19T20:22:26Z
+ * Date: 2017-02-01T23:04:40Z
  */
 ( function ( OO ) {
 
@@ -1455,11 +1455,8 @@ OO.ui.WindowManager.prototype.addWindows = function ( windows ) {
                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' );
-                       }
                        if ( !name ) {
-                               OO.ui.warnDeprecation( 'OO.ui.WindowManager#addWindows: Windows must have a `name` static property defined.' );
+                               throw new Error( 'Windows must have a `name` static property defined.' );
                        }
                        list[ name ] = windows[ i ];
                }
@@ -3496,5 +3493,3 @@ OO.ui.prompt = function ( text, options ) {
 };
 
 }( OO ) );
-
-//# sourceMappingURL=oojs-ui-windows.js.map
\ No newline at end of file
index f138d7e..b58dfdf 100644 (file)
        &-search {
                max-width: none;
                margin-top: -0.5em;
+
+               input {
+                       // Make sure this uses the interface direction, not the content direction
+                       direction: ltr;
+               }
        }
 
        &-capsule-invalid-filter {
diff --git a/resources/src/mediawiki.widgets/mw.widgets.UsersMultiselectWidget.js b/resources/src/mediawiki.widgets/mw.widgets.UsersMultiselectWidget.js
new file mode 100644 (file)
index 0000000..70d7cb5
--- /dev/null
@@ -0,0 +1,163 @@
+/*!
+ * MediaWiki Widgets - UsersMultiselectWidget class.
+ *
+ * @copyright 2017 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+( function ( $, mw ) {
+
+       /**
+        * UsersMultiselectWidget can be used to input list of users in a single
+        * line.
+        *
+        * If used inside HTML form the results will be sent as the list of
+        * newline-separated usernames.
+        *
+        * @class
+        * @extends OO.ui.CapsuleMultiselectWidget
+        *
+        * @constructor
+        * @param {Object} [config] Configuration options
+        * @cfg {mw.Api} [api] Instance of mw.Api (or subclass thereof) to use for queries
+        * @cfg {number} [limit=10] Number of results to show in autocomplete menu
+        * @cfg {string} [name] Name of input to submit results (when used in HTML forms)
+        */
+       mw.widgets.UsersMultiselectWidget = function MwWidgetsUsersMultiselectWidget( config ) {
+               // Config initialization
+               config = $.extend( {
+                       limit: 10
+               }, config, {
+                       // Because of using autocomplete (constantly changing menu), we need to
+                       // allow adding usernames, which do not present in the menu.
+                       allowArbitrary: true
+               } );
+
+               // Parent constructor
+               mw.widgets.UsersMultiselectWidget.parent.call( this, $.extend( {}, config, {} ) );
+
+               // Mixin constructors
+               OO.ui.mixin.PendingElement.call( this, $.extend( {}, config, { $pending: this.$handle } ) );
+
+               // Properties
+               this.limit = config.limit;
+
+               if ( 'name' in config ) {
+                       // If used inside HTML form, then create hidden input, which will store
+                       // the results.
+                       this.hiddenInput = $( '<input>' )
+                               .attr( 'type', 'hidden' )
+                               .attr( 'name', config.name )
+                               .appendTo( this.$element );
+
+                       // Update with preset values
+                       this.updateHiddenInput();
+               }
+
+               this.menu = this.getMenu();
+
+               // Events
+               // Update contents of autocomplete menu as user types letters
+               this.$input.on( {
+                       keyup: this.updateMenuItems.bind( this )
+               } );
+               // When option is selected from autocomplete menu, update the menu
+               this.menu.connect( this, {
+                       select: 'updateMenuItems'
+               } );
+               // When list of selected usernames changes, update hidden input
+               this.connect( this, {
+                       change: 'updateHiddenInput'
+               } );
+
+               // API init
+               this.api = config.api || new mw.Api();
+       };
+
+       /* Setup */
+
+       OO.inheritClass( mw.widgets.UsersMultiselectWidget, OO.ui.CapsuleMultiselectWidget );
+       OO.mixinClass( mw.widgets.UsersMultiselectWidget, OO.ui.mixin.PendingElement );
+
+       /* Methods */
+
+       /**
+        * Get currently selected usernames
+        *
+        * @return {Array} usernames
+        */
+       mw.widgets.UsersMultiselectWidget.prototype.getSelectedUsernames = function() {
+               return this.getItemsData();
+       };
+
+       /**
+        * Update autocomplete menu with items
+        *
+        * @private
+        */
+       mw.widgets.UsersMultiselectWidget.prototype.updateMenuItems = function() {
+               var inputValue = this.$input.val();
+
+               if ( inputValue === this.inputValue ) {
+                       // Do not restart api query if nothing has changed in the input
+                       return;
+               } else {
+                       this.inputValue = inputValue;
+               }
+
+               this.api.abort(); // Abort all unfinished api requests
+
+               if ( inputValue.length > 0 ) {
+                       this.pushPending();
+
+                       this.api.get( {
+                               action: 'query',
+                               list: 'allusers',
+                               // Prefix of list=allusers is case sensitive. Normalise first
+                               // character to uppercase so that "fo" may yield "Foo".
+                               auprefix: inputValue[ 0 ].toUpperCase() + inputValue.slice( 1 ),
+                               aulimit: this.limit
+                       } ).done( function( response ) {
+                               var suggestions = response.query.allusers,
+                                       selected = this.getSelectedUsernames();
+
+                               // Remove usernames, which are already selected from suggestions
+                               suggestions = suggestions.map( function ( user ) {
+                                       if ( selected.indexOf( user.name ) === -1 ) {
+                                               return new OO.ui.MenuOptionWidget( {
+                                                       data: user.name,
+                                                       label: user.name
+                                               } );
+                                       }
+                               } ).filter( function( item ) {
+                                       return item !== undefined;
+                               } );
+
+                               // Remove all items from menu add fill it with new
+                               this.menu.clearItems();
+
+                               // Additional check to prevent bug of autoinserting first suggestion
+                               // while removing user from the list
+                               if ( inputValue.length > 1 || suggestions.length > 1 ) {
+                                       this.menu.addItems( suggestions );
+                               }
+
+                               this.popPending();
+                       }.bind( this ) ).fail( this.popPending.bind( this ) );
+               } else {
+                       this.menu.clearItems();
+               }
+       };
+
+       /**
+        * If used inside HTML form, then update hiddenInput with list o
+        * newline-separated usernames.
+        *
+        * @private
+        */
+       mw.widgets.UsersMultiselectWidget.prototype.updateHiddenInput = function() {
+               if ( 'hiddenInput' in this ) {
+                       this.hiddenInput.val( this.getSelectedUsernames().join( '\n' ) );
+               }
+       };
+
+}( jQuery, mediaWiki ) );
index d5032da..5abd2d4 100644 (file)
                'stashwrongowner',
                'stashnosuchfilekey'
        ];
-       mw.log.deprecate( mw.Api, 'errors', mw.Api.errors );
+       mw.log.deprecate( mw.Api, 'errors', mw.Api.errors, 'mw.Api.errors' );
 
        /**
         * @static
                'duplicate',
                'exists'
        ];
-       mw.log.deprecate( mw.Api, 'warnings', mw.Api.warnings );
+       mw.log.deprecate( mw.Api, 'warnings', mw.Api.warnings, 'mw.Api.warnings' );
 
 }( mediaWiki, jQuery ) );
index 97ea23c..e3ea139 100644 (file)
@@ -17,7 +17,9 @@ class RCFeedIntegrationTest extends MediaWikiTestCase {
        /**
         * @covers RecentChange::notifyRCFeeds
         * @covers RecentChange::getEngine
-        * @covers RCFeedEngine
+        * @covers RCFeed::factory
+        * @covers FormattedRCFeed::__construct
+        * @covers FormattedRCFeed::notify
         * @covers JSONRCFeedFormatter::formatArray
         * @covers MachineReadableRCFeedFormatter::getLine
         */